diff --git a/.agents/skills/test-shield-playback/agents/openai.yaml b/.agents/skills/test-shield-playback/agents/openai.yaml deleted file mode 100644 index 1a84a47f3..000000000 --- a/.agents/skills/test-shield-playback/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Test Shield Playback" - short_description: "Verify playback paths on an NVIDIA Shield" - default_prompt: "Use $test-shield-playback to launch a title on the Shield and verify video, audio, and display output." diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 668d418fe..7d34529a6 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -1,12 +1,9 @@ name: Android Builds -# CI gate: :shared debug unit tests + Kover line-coverage verify (commonMain). -# Floor is 95% line coverage across commonMain (androidMain / Koin / serializers -# excluded — see shared/build.gradle.kts). App-module unit tests (androidApp / -# androidTvApp / android-shared) are not part of this gate and are skipped here -# to keep the longest client CI focused on the shared coverage contract. -# Release artifacts (Play Store bundles, sideload APKs, GitHub releases) are -# owned by release.yml — triggered by v* tags or its workflow_dispatch. +# CI gate: unit tests + Kover line-coverage verify on :shared commonMain, plus +# lint. Floor is configured in shared/build.gradle.kts. Release artifacts +# (Play Store bundles, sideload APKs, GitHub releases) are owned by +# release.yml — triggered by v* tags or its workflow_dispatch. on: push: @@ -47,20 +44,47 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 - - name: Run :shared unit tests and coverage gate + # Robolectric does NOT resolve its Android runtimes through Gradle. At test + # execution time it fetches android-all-instrumented straight from Maven + # Central into ~/.m2, which setup-gradle's cache does not cover — so every + # run re-downloaded 85-204 MB per SDK level. On 2026-08-11 Maven Central + # answered 403 and took out 31 unrelated tests in android-shared, and the + # download time is a large part of this job. + # + # Which runtimes get fetched depends on the Robolectric version AND on the + # SDK each test resolves to, so the key hashes the version catalogue and + # the module build files (min/target SDK) together. A new @Config(sdk=NN) + # in a test is deliberately NOT in the key: hashing test sources would + # bust this cache on nearly every pull request, which costs more than it + # saves. The residual gap is bounded — that one runtime is re-fetched each + # run until something else moves the key — and the restore-key prefix + # still seeds from the previous cache rather than starting cold. + # + # A run-id-suffixed key would close the gap by saving every run, but this + # cache is hundreds of MB; a new entry per run would evict the Gradle + # cache against the repository's 10 GB budget. + - name: Cache Robolectric Android runtimes + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml', '**/build.gradle.kts') }} + restore-keys: | + robolectric-${{ runner.os }}- + + - name: Run unit tests and coverage gate shell: bash run: | set -euo pipefail - # Only :shared debug unit tests feed the Kover commonMain gate. - # Release unit tests are disabled on :shared (see build.gradle.kts). - # App modules are intentionally out of this job. + # Debug-variant unit tests only. The release variant compiles and runs + # the exact same sources for no extra signal, and doubles CI time. + # :shared koverXmlReport/koverVerify enforce the commonMain coverage floor. ./gradlew \ -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ - :shared:testDebugUnitTest \ + testDebugUnitTest \ :shared:koverXmlReport \ :shared:koverVerify \ - --max-workers=4 \ + --max-workers=2 \ --no-configuration-cache - name: Upload test reports @@ -80,3 +104,55 @@ jobs: path: | **/build/reports/kover/** if-no-files-found: ignore + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6 + + - name: Run lint + shell: bash + run: | + set -euo pipefail + + # NewApi and InlinedApi are fatal here. Two startup crashes reached + # users because nothing checked API levels: a Spatializer call gated + # at 31 for a class introduced at 32, and an unguarded API 28 call. + # Existing findings are held in per-module lint-baseline.xml, so only + # NEW violations fail. If this job fails, fix the call — do not + # regenerate the baseline to make it pass. + # + # lintVitalRelease runs here too. checkReleaseBuilds makes it part of + # assembleRelease, which only ever runs from release.yml on a v* tag — + # so without this the release variant is gated by a check that no pull + # request exercises, and the first run would be mid-release. It also + # covers the release-only source sets that lintDebug never sees. + ./gradlew \ + -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ + :android-shared:lintDebug :androidApp:lintDebug :androidTvApp:lintDebug \ + :androidApp:lintVitalRelease :androidTvApp:lintVitalRelease \ + --max-workers=2 + + - name: Upload lint reports + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: lint-reports + path: | + **/build/reports/lint-results-*.html + **/build/reports/lint-results-*.xml + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee3a2f850..e2e3479cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,13 @@ name: Release -# One release pipeline, playing the role of prairie-apple's TestFlight +# One release pipeline, playing the role of silo-apple's TestFlight # (release.yml) and sideload (sideload-ipa.yml) workflows combined: a version # tag or a manual dispatch publishes both App Bundles to Google Play, then # builds the signed sideload APKs and attaches them to a GitHub release for # the same version. Dispatch runs mint the v tag when creating the # GitHub release — no manual tagging is necessary (apple's sideload pattern). # -# Versioning mirrors prairie-apple's marketing-version + build-number split. +# Versioning mirrors silo-apple's marketing-version + build-number split. # Play itself has no such split — versionCode is the only monotonic counter, # and versionName is a free-form display string that may repeat. So a build # number is folded into the versionCode while versionName stays put, letting @@ -125,6 +125,13 @@ jobs: echo "::error::Tag must look like v1.2.3, v1.2.3+2, or v1.2.3-rc.1." exit 1 fi + # A prerelease suffix deliberately does NOT feed the build counter. + # The counter is folded into the versionCode, so deriving it from + # -rc.N would make a sideloaded v1.2.3-rc.2 (base+2) outrank the + # official v1.2.3 (base+1) and block that upgrade on the device — + # and it would still not tell -rc.2 apart from +2, which resolve to + # the same counter. Prerelease artifacts are distinguished by their + # tag; see the note in the PR for the reporting limitation. if [[ "${version}" == *+* ]]; then build="${version##*+}" else @@ -187,6 +194,7 @@ jobs: - name: Check build supply chain run: | + ./scripts/test-release-workflow.sh ./scripts/test-check-build-supply-chain.sh ./scripts/check-build-supply-chain.sh @@ -316,7 +324,7 @@ jobs: exit 1 fi - keystore_path="${RUNNER_TEMP}/prairie-release.jks" + keystore_path="${RUNNER_TEMP}/silo-release.jks" printf '%s' "${KEYSTORE_B64}" | base64 -d > "${keystore_path}" echo "PRAIRIE_RELEASE_KEYSTORE=${keystore_path}" >> "${GITHUB_ENV}" @@ -343,10 +351,12 @@ jobs: shell: bash env: PRAIRIE_VERSION_NAME: ${{ needs.setup.outputs.version_name }} + PRAIRIE_DISPLAY_VERSION: ${{ needs.setup.outputs.version }} PRAIRIE_VERSION_CODE: ${{ needs.setup.outputs.version_code }} PRAIRIE_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.PRAIRIE_RELEASE_KEYSTORE_PASSWORD }} PRAIRIE_RELEASE_KEY_PASSWORD: ${{ secrets.PRAIRIE_RELEASE_KEY_PASSWORD }} PRAIRIE_RELEASE_KEY_ALIAS: ${{ secrets.PRAIRIE_RELEASE_KEY_ALIAS }} + PRAIRIE_BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} run: | set -euo pipefail @@ -354,13 +364,16 @@ jobs: -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8" \ ":${{ matrix.module }}:assembleRelease" \ "-PprairieVersionName=${PRAIRIE_VERSION_NAME}" \ + "-PprairieDisplayVersion=${PRAIRIE_DISPLAY_VERSION}" \ "-PprairieVersionCode=${PRAIRIE_VERSION_CODE}" \ + "-PprairieBuildNumber=${PRAIRIE_BUILD_NUMBER}" \ + "-PprairieReleaseChannel=sideload" \ --max-workers=2 - name: Collect release APKs shell: bash env: - PRAIRIE_VERSION_NAME: ${{ needs.setup.outputs.version_name }} + PRAIRIE_VERSION_NAME: ${{ needs.setup.outputs.version }} run: | set -euo pipefail @@ -391,12 +404,16 @@ jobs: # Create or update the GitHub release and attach the APKs. For dispatch # runs the tag does not exist yet — gh release create mints it at the - # released commit (--target), mirroring prairie-apple's sideload workflow, so + # released commit (--target), mirroring silo-apple's sideload workflow, so # no manual tagging is necessary. Tags created with GITHUB_TOKEN do not # re-trigger workflows, so this cannot recurse. publish-release: name: Publish GitHub Release needs: [setup, apks] + if: >- + ${{ !cancelled() && + needs.setup.result == 'success' && + needs.apks.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write @@ -418,7 +435,7 @@ jobs: set -euo pipefail shopt -s globstar nullglob - release_title="Prairie Android ${RELEASE_TAG}" + release_title="Silo Android ${RELEASE_TAG}" android_latest_url="https://github.com/${GH_REPO}/releases/latest/download/prairie-android-latest-universal-release.apk" android_tv_latest_url="https://github.com/${GH_REPO}/releases/latest/download/prairie-android-tv-latest-universal-release.apk" android_downloader_url="http://aftv.news/1051382" diff --git a/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md b/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md index 5508e5c20..99cb01a2d 100644 --- a/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md +++ b/.superpowers/sdd/2026-07-27-pr108-slice-f-watch-together/task-1-report.md @@ -45,7 +45,7 @@ owner-driven cancellation remains silent. ### Credential boundary The access token is still required to attempt the room socket but is no longer -placed in the request target. The existing same-origin Silo auth plugin adds +placed in the request target. The existing same-origin Prairie auth plugin adds `Authorization`, `X-Profile-Id`, and `X-Profile-Token` headers. The server currently still requires `room_token`, `profile_id`, and `profile_token` query parameters; the client KDoc records that residual request-target exposure. @@ -213,9 +213,9 @@ The exact-scope validation at connect start is not the final token read: the auth plugin reads the scoped token again while building the request so it can honor rotation. A credential scope can disappear between those reads. The Watch Together connector now marks its pinned request with the internal, -opt-in `requireSiloAuth()` attribute. When that marked request's exact scoped -token is null or blank, `PrairieAuthPlugin` removes Silo headers and throws before -the engine runs. Unmarked public requests and `skipSiloAuth()` paths retain +opt-in `requirePrairieAuth()` attribute. When that marked request's exact scoped +token is null or blank, `PrairieAuthPlugin` removes Prairie headers and throws before +the engine runs. Unmarked public requests and `skipPrairieAuth()` paths retain their existing behavior. ### Correction 2 TDD evidence diff --git a/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md b/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md new file mode 100644 index 000000000..1a6e46880 --- /dev/null +++ b/.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md @@ -0,0 +1,172 @@ +# Task 4 Report: Android TV Focus and Phone/TV Hero Verification + +Date: 2026-07-28 +Worktree: `fix/tv-for-you-cold-navigation worktree` +Final local HEAD: `9c251293dc321385ea9be205a0732cc7b14b1251` + +## Summary + +Task 4 verification found one Important review issue in the previously approved Task 1 focus path. I fixed it locally in `9c251293` by preventing non-Search secondary routes from falling through to Home focus when `selectedRoot == null`. The repeat independent review approved the updated diff. + +Automated tests, supply-chain checks, release builds, APK signing verification, and artifact packaging completed. Dedicated emulator smoke did not produce a fully clean pass, so I did not push or update PR #126. + +## Local Commits Added + +- `9c251293 fix(tv): avoid home focus fallback on secondary routes` + +## Verification Commands + +Focused TV regressions: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL`, 68 tasks executed. + +Focused phone regression: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL`, 80 tasks executed. + +Supply-chain checks: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Result: both exited 0. Self-test output: `All supply-chain policy self-tests passed`. + +Full release gate: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL in 6m 29s`, 313 tasks executed. + +The test XML directory immediately after the full release gate only contained focused suites, so I reran the unfiltered unit-test tasks explicitly: + +```bash +./gradlew :androidApp:testDebugUnitTest :androidTvApp:testDebugUnitTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Result: `BUILD SUCCESSFUL in 1m 20s`, 104 tasks executed. + +XML totals after explicit unfiltered rerun: + +- `androidApp/build/test-results/testDebugUnitTest`: 85 suites, 477 tests, 0 skipped, 0 failures, 0 errors. +- `androidTvApp/build/test-results/testDebugUnitTest`: 88 suites, 646 tests, 0 skipped, 0 failures, 0 errors. + +Diff hygiene: + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Result before writing this report: no whitespace errors; branch ahead of `origin/fix/tv-for-you-cold-navigation`. + +## Independent Review + +Initial review finding: + +- Important: non-Search secondary routes could call `requestMenuFocus(null)` and fall through to Home in `TvTopMenuBar`. + +Fix: + +- Added `TvShellFocusState.requestMenuFocusIfAvailable`. +- Updated content-Up routing in `TvMainShell` to allow null targeting only for Search. +- Added regression coverage in `TvShellFocusStateTest`. + +Repeat review result: + +- No blocking findings. Approved. +- Residual risk noted by reviewer: the content-Up fix is covered by state/helper unit tests, not a full Compose focus integration test. + +## Emulator Smoke + +Physical device safety: + +- ADB showed an unapproved physical TV device; I did not target it. +- TV smoke used the dedicated TV emulator. +- Phone smoke used the dedicated phone emulator. + +TV smoke on final TV APK: + +- Installed `androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk` + on the dedicated TV emulator. +- Home: from content card, fresh Up focused the Home top pill. Pass. +- Movies library: from first content card, first Up left focus on the content card; second Up focused the Movies pill. Not a clean pass. +- For You: from content, fresh Up focused the For You control. Pass. +- Calendar: targeted Calendar and entered content; Up focused the in-page `Following` control rather than the top Calendar pill. Not a clean top-pill pass. +- Hold-Up from lower row: not cleanly completed after the caveats above. +- Search route focus: not completed after the caveats above. +- TV hero metadata: visual/UIAutomator dumps showed editorial hero metadata for focused content and no technical badges in the large hero; content cards still show technical badges outside the hero. + +Phone smoke on final phone APK: + +- Installed `androidApp/build/outputs/apk/release/androidApp-universal-release.apk` + on the dedicated phone emulator. +- Opened Libraries > Movies. The Movies library Recommended hero displayed editorial chips `2004`, `7.3`, `War`, `R`, with no generic `Movie` chip, and Play / More Info visible. +- The selected movie did not show a runtime chip in the UIAutomator dump, so the requested movie smoke is partial rather than a full pass. +- Episode carousel page smoke was not completed. +- Narrow viewport wrapping was not completed beyond the default `1080x2400` phone emulator viewport. + +Smoke conclusion: not fully green. PR #126 was not pushed or updated. + +## APK Artifacts + +Built universal minified release APKs from local HEAD `9c251293` with `-PallowDebugReleaseSigning=true`. + +Signing caveat: these are debug-signed release builds. They only upgrade installations signed with the same debug certificate. + +Copied artifacts: + +- `Prairie-Phone-Universal-0.3.11-FocusHeroFix-9c251293.apk` +- `Prairie-TV-Universal-0.3.11-TVFocusHeroFix-9c251293.apk` + +SHA-256: + +- Phone: `0c4c68d15d47c28de41ef1fc1db080ba266f899aa2c3dad663a20fdb5dd5ed50` +- TV: `9e135e3cdad20f1b000ae2ef55573ceb6070e1412718d4ebde47a1977e492a09` + +`apksigner verify --verbose` on copied APKs: + +- Phone: verifies; v2 scheme true; number of signers 1. +- TV: verifies; v2 scheme true; number of signers 1. + +`apkanalyzer` status: + +- `apkanalyzer` failed with `IllegalStateException: Cannot locate latest build tools`, even with `ANDROID_HOME` and `ANDROID_SDK_ROOT` set. +- Used SDK `aapt` as fallback for package/version/ABI metadata. + +`aapt dump badging` metadata: + +- Phone: `applicationId=org.prairieserver.prairie`, `versionCode=14`, `versionName=0.3.11`, native ABIs `arm64-v8a, armeabi-v7a, x86, x86_64`. +- TV: `applicationId=org.prairieserver.prairie`, `versionCode=15`, `versionName=0.3.11`, native ABIs `arm64-v8a, armeabi-v7a, x86, x86_64`. + +## PR Status + +No push performed. + +No PR #126 description update performed because the required emulator smoke was not fully green. diff --git a/AGENTS.md b/AGENTS.md index a7a5e2438..a00f4e893 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This repository contains only the Prairie Android clients. Shared Kotlin logic l - Ebooks/Reading are phone-only. Do not expose ebooks or Reading on Android TV. - Android mobile navigation is Home, Libraries, For You, Calendar, and Downloads only when the active profile has downloads. Video, Audio, and Reading are library modes reached through Libraries, not bottom-nav tabs. - Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), and Calendar, plus search and profile actions. Reading/ebooks are excluded. -- Requests is live on phone and TV, server-gated by `requests_enabled` (profile menu + search entry points, matching Apple). The Admin STATS dashboard is live for acting admins (Settings entry on both platforms, matching Apple's dashboard design); the richer admin screens (users/sessions/logs/scans) and Watch Together are not accessible — do not add them to menus without an explicit product decision. +- Requests is live on phone and TV, server-gated by `requests_enabled` (profile menu + search entry points, matching Apple). Admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. ## Build, Test, and Development Commands diff --git a/FEATURES.md b/FEATURES.md index 8dca60090..34e779fe3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -9,7 +9,7 @@ A detailed inventory of what the Android **phone** and **TV** clients do today. File pointers are repository-relative. -> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. The admin stats dashboard is live for acting admins via Settings. The richer admin screens (users/sessions/logs/scans) and Watch Together remain inaccessible. +> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. Admin surfaces (including the STATS dashboard) and Watch Together remain inaccessible. --- @@ -153,6 +153,6 @@ File pointers are repository-relative. **TV** is a 10-foot, D-pad client focused on browsing and playback, including audiobooks, calendar, the subtitle suite, person detail, and system Watch Next integration. It intentionally omits ebooks/reading and downloads management. -**Not currently exposed on either Android surface:** full admin management (users/sessions/logs/scans) and Watch Together. The admin **stats dashboard** is exposed (Settings → Admin, acting admins only). +**Not currently exposed on either Android surface:** Admin (including the STATS dashboard and users/sessions/logs/scans) and Watch Together. Both apps share the same networking, auth, repositories, most ViewModels, and the entire Media3 playback/capability stack. diff --git a/Gemfile.lock b/Gemfile.lock index 83ef8e02d..1ca8bb94c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -173,7 +173,7 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.21.1) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) @@ -300,7 +300,7 @@ CHECKSUMS http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 diff --git a/README.md b/README.md index d93fbabd9..debd2e640 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Prairie Android +# Silo Android -Android **phone** and **Android TV** clients for the [Prairie](https://github.com/Prairie-Server/prairie-server) self-hosted media server — stream and download your movies, shows, music, audiobooks, and ebooks, with quality-aware playback and multi-server/multi-profile support. +Android **phone** and **Android TV** clients for the [Silo](https://github.com/Silo-Server/silo-server) self-hosted media server — stream and download your movies, shows, music, audiobooks, and ebooks, with quality-aware playback and multi-server/multi-profile support. -Built as a Kotlin Multiplatform project: one shared business-logic core, two Jetpack Compose apps (touch + 10-foot TV). This branch uses the full Prairie namespace cut: Kotlin packages live under `org.prairieserver.prairie`, and both apps share a single application ID `org.prairieserver.prairie` so they publish as one Google Play listing (Play routes each build by manifest feature filtering). Installs under legacy IDs do not upgrade in place; users should expect a fresh app install, sign-in, and offline media download. +Built as a Kotlin Multiplatform project: one shared business-logic core, two Jetpack Compose apps (touch + 10-foot TV). This branch uses the full Silo namespace cut: Kotlin packages live under `org.prairieserver.prairie`, and both apps share a single application ID `org.prairieserver.prairie` so they publish as one Google Play listing (Play routes each build by manifest feature filtering). Installs under legacy IDs do not upgrade in place; users should expect a fresh app install, sign-in, and offline media download. > **Status:** WIP (`v0.2.x`). The architecture is solid and the feature surface is broad; some areas are intentionally "bones-level" and under active redesign (see [Roadmap](#roadmap)). > -> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. The admin stats dashboard is live for acting admins via Settings (Apple's dashboard design). The richer admin screens (users/sessions/logs/scans) and Watch Together remain inaccessible. +> **Current exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v1/requests/status`), and reached from the profile menu and search — matching the Apple clients. Admin surfaces (including the STATS dashboard) and Watch Together are not exposed in the Android clients. --- @@ -36,11 +36,11 @@ Built as a Kotlin Multiplatform project: one shared business-logic core, two Jet | **Networking** | **Ktor** 3.1.2 client · kotlinx.serialization · WebSockets for realtime | | **DI** | **Koin** 4.1.0 | | **Persistence** | AndroidX DataStore · EncryptedSharedPreferences (tokens) · WorkManager (downloads) | -| **Diagnostics** | Native bounded capture · local review/consent · self-hosted Prairie upload | +| **Diagnostics** | Native bounded capture · local review/consent · hosted default or self-hosted upload | | **Images** | Coil 3 (Ktor-backed) | -| **SDK** | Android 7.0+ / minSdk 24 · targetSdk 35 · compileSdk 36 · JDK 21 | +| **SDK** | Android 7.0+ / minSdk 24 · targetSdk 36 · compileSdk 36 · JDK 21 | -The clients talk to a Prairie server over its `/api/v1/*` REST + WebSocket API. The server owns the library, scanning, metadata, transcoding decisions, and auth; the clients render it and drive playback. +The clients talk to a Silo server over its `/api/v1/*` REST + WebSocket API. The server owns the library, scanning, metadata, transcoding decisions, and auth; the clients render it and drive playback. Android 7.0 and 7.1 (API 24/25) are supported on both phone and Android TV. Video and audiobook playback use the same Media3 service on every supported Android version. @@ -52,12 +52,10 @@ Latest debug APKs are published on each tagged release. | App | Downloader code | Downloader link | Direct APK | |---|---:|---|---| -| Android | `1051382` | | | -| Android TV | `1636227` | | | +| Android | `1051382` | | | +| Android TV | `1636227` | | | -Install **Downloader by AFTVnews** on Android TV / Google TV / Fire TV, enter the code for the app you want, then allow APK installs from Downloader when prompted. - -**Fire TV:** use the Android TV APK (same application ID — no separate Fire package). Sideloaded Fire OS tiles use the square launcher icon; Amazon’s full-bleed tile requires the Appstore listing asset under [`docs/store/amazon/`](docs/store/amazon/). After installing or updating, clear the Fire launcher cache or reinstall so the tile artwork refreshes. +Install **Downloader by AFTVnews** on Android TV / Google TV, enter the code for the app you want, then allow APK installs from Downloader when prompted. --- @@ -85,25 +83,27 @@ WorkManager-backed downloads of video, audiobooks, and books to public device st - **Browse** with genre/rating filters, sorting, and infinite-scroll grids; **collections** are browse-only in the Android clients, while collection authoring/management remains web-only. - **Item detail** for movies and series includes seasons → episodes, multi-version files, cast/crew, local download controls, and phone-to-TV playback handoff. - **Search** scoped by media type, debounced and paginated. -- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (profile menu + search). **Admin** — stats dashboard only, role-gated in Settings. **Not exposed** — full admin management and Watch Together are not reachable app surfaces today. +- **Requests** — live on phone and TV behind the server's `requests_enabled` flag (profile menu + search). **Not exposed** — Admin (including STATS) and Watch Together are not reachable app surfaces today. ### 📖 Reading & 🎧 Audio - **Ebook reader (phone only)** — EPUB, PDF, CBZ (comics), TXT/Markdown, FB2/FBZ, plus MOBI/AZW/AZW3 when the server can convert to EPUB; CBR and unsupported originals can be downloaded/opened externally. Themes, text size, margins, table of contents, bookmarks, and progress are supported. - **Audiobook player (phone + TV)** — cover/metadata, chapters, resume, playback speed, sleep timer (incl. end-of-chapter), and bookmarks, sharing the same Media3 engine as video. TV has a dedicated ten-foot audiobook detail/player flow. ### PrairieControl (phone + TV) -Android phone can discover PrairieCast receivers on the local network, launch movies/episodes on TV with the selected file/track/resume context, and act as a lightweight remote for play/pause, seek, quality, audio, and subtitle changes. TV advertises the local PrairieCast receiver only while authenticated and foregrounded. The channel is TLS-PSK and wire-compatible with the Apple clients' PrairieControl protocol (same `_prairiecast._tcp` service, hello/serverId authorization, heartbeat), so Android phones can cast to Apple TVs and iPhones to Android TVs. +Android phone can discover PrairieCast receivers on the local network, launch movies/episodes on TV with the selected file/track/resume context, and act as a lightweight remote for play/pause, seek, quality, audio, and subtitle changes. TV advertises the local PrairieCast receiver only while authenticated and foregrounded. The channel is TLS-PSK and wire-compatible with the Apple clients' PrairieControl protocol (same `_silocast._tcp` service, hello/serverId authorization, heartbeat), so Android phones can cast to Apple TVs and iPhones to Android TVs. ### 🔔 Personalization & engagement (phone + TV) Multiple **household profiles** per account (PINs, child profiles, content-rating limits, per-profile language/subtitle prefs), favorites & watchlist, ratings, a release **calendar**, and an in-app **notifications inbox** with realtime updates. Android push has a guarded client-side registration/data-message path, but real FCM delivery requires server provider support plus Firebase configuration in the phone app. TV mirrors continue-watching into the system **Watch Next** row. ### 🌐 Multi-server & accounts (phone + TV) -Add and switch between multiple Prairie servers (encrypted per-server token slots), use username/password or device/QR sign-in, and manage household profiles. Admin screens are not currently exposed in the Android apps. +Add and switch between multiple Silo servers (encrypted per-server token slots), use username/password or device/QR sign-in, and manage household profiles. Admin screens are not currently exposed in the Android apps. ### Client diagnostics (phone + TV) -Android-native diagnostics can retain a bounded, redacted local report for crashes, ANRs, playback, networking, focus, cast, downloads, and lifecycle events. A two-segment journal keeps only curated, already-redacted lifecycle breadcrumbs so next-launch ANR/native-crash reports retain pre-exit context; identity transitions rotate it, and Never/sign-out purges it. Adult profiles can review and delete account-scoped reports on-device, choose Ask / Always / Never consent, or run a timed diagnostic capture. Child profiles cannot capture, review, or upload reports. Reports upload only to the originating self-hosted Prairie server when that server advertises diagnostics support. Profile transitions close the capture gate and rotate live evidence without discarding retained account reports; sign-out, server removal, and Never consent purge the applicable evidence. One-off manual reports remain available under Never without enabling persistent capture. +Android-native diagnostics can retain a bounded, redacted local report for crashes, ANRs, playback, networking, focus, cast, downloads, and lifecycle events. A two-segment journal keeps only curated, already-redacted lifecycle breadcrumbs so next-launch ANR/native-crash reports retain pre-exit context; identity transitions rotate it, and Never/sign-out purges it. Adult profiles can review and delete account-scoped reports on-device, choose consent, or run a timed diagnostic capture. Child profiles cannot capture, review, or upload reports. + +The default destination is Silo's hosted collector at `diagnostics.prairieserver.org`; self-hosted Silo ingest remains an explicit compatibility choice. Hosted collection is manual/Ask-only, verifies the live collector identity before a new capture, and re-attests the authenticated source-server account before a first upload. Self-hosted collection supports Ask / Always / Never when the originating server advertises diagnostics support. Reports are never retargeted across destination, server, account, or profile boundaries. Profile transitions close the capture gate and rotate live evidence without discarding retained account reports; sign-out, server removal, and Never consent purge the applicable evidence. One-off manual reports remain available under Never without enabling persistent capture. -This feature does not use Sentry, GlitchTip, Crashlytics, OpenTelemetry, ACRA, or another hosted observability SDK. Crash-time work is local and bounded; exact credential values receive a bounded replacement before the app-private marker is written, structural redaction runs during next-launch report assembly, and archive construction and upload occur after restart. +This feature does not use Sentry, GlitchTip, Crashlytics, OpenTelemetry, ACRA, or another hosted observability SDK. Crash-time work is local and bounded; exact credential values receive a bounded replacement before the app-private marker is written, structural redaction runs during next-launch report assembly, and archive construction and upload occur after restart. Hosted reports preserve the exact application version, build number, and OS version; privacy filtering targets server network identity, account or personal identity, and credentials rather than release metadata. --- @@ -183,7 +183,7 @@ Tests live in each module's test source set (`commonTest`, `androidUnitTest`) us ### Prerequisites - **JDK 21** - Android SDK with the configured compile SDK (36) -- A running **Prairie server** for auth, browsing, and playback validation — see [`Prairie-Server/prairie-server`](https://github.com/Prairie-Server/prairie-server) +- A running **Silo server** for auth, browsing, and playback validation — see [`Silo-Server/silo-server`](https://github.com/Silo-Server/silo-server) ### Build @@ -199,7 +199,7 @@ Tests live in each module's test source set (`commonTest`, `androidUnitTest`) us ./gradlew :androidTvApp:installDebug ``` -On first launch, point the app at your Prairie server URL, sign in, and pick a profile. (Android TV can't bootstrap first-time server setup — set the server up from the phone app or a web browser, then sign the TV in via username/password or QR/device pairing.) +On first launch, point the app at your Silo server URL, sign in, and pick a profile. (Android TV can't bootstrap first-time server setup — set the server up from the phone app or a web browser, then sign the TV in via username/password or QR/device pairing.) --- @@ -221,7 +221,7 @@ On first launch, point the app at your Prairie server URL, sign in, and pick a p - **Shared first** — put platform-agnostic logic (models, networking, view-model logic, pure algorithms) in `shared`; keep Android-only concerns in `android-shared`; keep each app's module to its UI. New non-UI behavior that both apps need belongs in a shared module, not duplicated per app. - **Compose** screens are thin; logic lives in ViewModels (testable in `commonTest` where possible). - Design specs and implementation plans for larger efforts live under `docs/superpowers/{specs,plans}/`. -- This is part of a multi-repo Prairie workspace — client-visible API/auth/playback changes often need coordinated work in `prairie-server` (and the sibling `prairie-apple` clients). +- This is part of a multi-repo Silo workspace — client-visible API/auth/playback changes often need coordinated work in `silo-server` (and the sibling `silo-apple` clients). --- @@ -232,9 +232,9 @@ Active design work lives in `docs/superpowers/specs/` with phased plans in `docs - **Audiobook polish** — the phone and TV players have chapter-aware UI, speed, bookmarks, and sleep timers. Remaining work includes skip-silence, volume normalization, rich notification polish, Android Auto, and a phone widget. - **Ebook reader enhancements** — real paginated EPUB (page turns), in-text search, highlights & notes (with a coordinated server change), font/brightness controls, and reading-time estimates across all server formats. - **Picture-in-Picture** — not yet implemented on phone. -- **Admin management (users/sessions/logs/scans), Watch Together** — code/design work exists, but these are not currently exposed to users in the Android apps and need product/navigation decisions before being treated as live features. +- **Admin (including STATS) and Watch Together** — not currently exposed to users in the Android apps; do not re-add without an explicit product decision. -Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; Requests/Admin/Watch Together are not accessible on either Android surface today. +Known gaps the docs track: TV has no reader/ebooks and no downloads management by design; Admin/Watch Together are not accessible on either Android surface today (Requests is live). --- @@ -242,16 +242,17 @@ Known gaps the docs track: TV has no reader/ebooks and no downloads management b - The Android phone and TV apps share one application ID, `org.prairieserver.prairie`, and publish as a single Google Play listing; Play delivers the right build per device via manifest feature filtering (phone requires a touchscreen, TV requires leanback). - The Android modules target Java 21. -- The server repo lives at [`Prairie-Server/prairie-server`](https://github.com/Prairie-Server/prairie-server). +- The server repo lives at [`Silo-Server/silo-server`](https://github.com/Silo-Server/silo-server). ## License & Trademarks -Prairie Android is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). +Silo Android is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). -Prairie is a rebranded fork of Silo. The AGPL covers the code, but it does not -license the Silo name, logo, wordmark, or other Silo Media L.L.C. marks. This -repository replaces Silo product identity with Prairie identifiers and assets; -factual references such as "fork of Silo" remain permitted when they are -truthful and non-confusing. See [TRADEMARK.md](TRADEMARK.md). +The **Silo name, logo, and wordmark are trademarks of Prairie L.L.C.** and +are **not** covered by the AGPL. You're free to fork and redistribute the code, +but forks and redistributions must not use the Silo brand as their identity and +must remove or replace the brand assets. Publishing a Silo-branded app to an app +store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's +permitted — including referential use like "compatible with Silo." The checked-in Media3 FFmpeg decoder AAR and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/android-shared/build.gradle.kts b/android-shared/build.gradle.kts index ee5b590fc..33d0d7e87 100644 --- a/android-shared/build.gradle.kts +++ b/android-shared/build.gradle.kts @@ -130,13 +130,14 @@ kotlin { android { namespace = "org.prairieserver.prairie.common" compileSdk = 36 - sourceSets.getByName("debug").assets.srcDir("$projectDir/schemas") + // Room migration schemas are local-test fixtures shared by both build variants. + sourceSets.getByName("test").assets.srcDir("$projectDir/schemas") defaultConfig { minSdk = 24 // Gate for preferring FFmpeg audio decoders over platform decoders. // The AAR is always on the classpath (see dependencies above); this // flag only controls whether DefaultRenderersFactory is set to - // EXTENSION_RENDERER_MODE_PREFER (true) or _MODE_OFF (false). + // EXTENSION_RENDERER_MODE_ON (true) or _MODE_OFF (false). // Flip to false at compile time to bisect regressions — with _MODE_OFF // FFmpeg renderers are not even instantiated, so any FFmpeg-related // bug can't manifest regardless of classpath presence. @@ -159,6 +160,23 @@ android { isReturnDefaultValues = true } } + + // Lint had never run on this project. The two crashes it would have caught + // — a Spatializer call gated at API 31 when the class arrives at 32, and a + // getAddress() call with no guard at all — both shipped from this module, + // which app-level lint does not analyse unless asked. Hence the gate here + // and checkDependencies in the apps. + // + // The baseline holds today's known findings (overwhelmingly desugared + // java.* calls and deliberate media3 @UnstableApi usage) so that only NEW + // violations fail. Delete it and regenerate deliberately; do not add to it + // to make a build pass. + lint { + baseline = file("lint-baseline.xml") + abortOnError = true + fatal += setOf("NewApi", "InlinedApi") + checkReleaseBuilds = true + } } // Room schema export — the generated JSON schemas are committed under diff --git a/android-shared/lint-baseline.xml b/android-shared/lint-baseline.xml new file mode 100644 index 000000000..6a8e36410 --- /dev/null +++ b/android-shared/lint-baseline.xml @@ -0,0 +1,3294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt index 27dd3c98f..6cf05d4ce 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt @@ -9,7 +9,10 @@ import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import kotlinx.serialization.json.Json /** @@ -21,6 +24,7 @@ import kotlinx.serialization.json.Json class RoomCatalogCacheRepository( db: PrairieDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, ) : CatalogCachePort { @@ -28,44 +32,84 @@ class RoomCatalogCacheRepository( private val json = Json { ignoreUnknownKeys = true } override suspend fun cacheLibraries(libraries: List) = - put(KEY_LIBRARIES, json.encodeToString(libraries)) + cacheLibraries(libraries, currentWriteLease()) + + override suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) = + put(KEY_LIBRARIES, json.encodeToString(libraries), lease) override suspend fun getCachedLibraries(): List? = get(KEY_LIBRARIES)?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } override suspend fun cacheDefaultLibraryPage(libraryId: Int, response: CatalogResponse) = - put(libraryKey(libraryId), json.encodeToString(response)) + cacheDefaultLibraryPage(libraryId, response, currentWriteLease()) + + override suspend fun cacheDefaultLibraryPage( + libraryId: Int, + response: CatalogResponse, + lease: CatalogCacheWriteLease, + ) = put(libraryKey(libraryId), json.encodeToString(response), lease) override suspend fun getCachedDefaultLibraryPage(libraryId: Int): CatalogResponse? = get(libraryKey(libraryId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheLibrarySections(libraryId: Int, sections: List) = - put(librarySectionsKey(libraryId), json.encodeToString(sections)) + cacheLibrarySections(libraryId, sections, currentWriteLease()) + + override suspend fun cacheLibrarySections( + libraryId: Int, + sections: List, + lease: CatalogCacheWriteLease, + ) = put(librarySectionsKey(libraryId), json.encodeToString(sections), lease) override suspend fun getCachedLibrarySections(libraryId: Int): List? = get(librarySectionsKey(libraryId))?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } override suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) = - put(itemDetailKey(contentId), json.encodeToString(detail)) + cacheItemDetail(contentId, detail, currentWriteLease()) + + override suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) = put(itemDetailKey(contentId), json.encodeToString(detail), lease) override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = get(itemDetailKey(contentId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) = - put(seasonsKey(seriesId), json.encodeToString(response)) + cacheSeasons(seriesId, response, currentWriteLease()) + + override suspend fun cacheSeasons( + seriesId: String, + response: SeasonsResponse, + lease: CatalogCacheWriteLease, + ) = put(seasonsKey(seriesId), json.encodeToString(response), lease) override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = get(seasonsKey(seriesId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } override suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) = - put(episodesKey(seriesId, seasonNumber), json.encodeToString(response)) + cacheEpisodes(seriesId, seasonNumber, response, currentWriteLease()) + + override suspend fun cacheEpisodes( + seriesId: String, + seasonNumber: Int, + response: EpisodesResponse, + lease: CatalogCacheWriteLease, + ) = put(episodesKey(seriesId, seasonNumber), json.encodeToString(response), lease) override suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int): EpisodesResponse? = get(episodesKey(seriesId, seasonNumber))?.let { runCatching { json.decodeFromString(it) }.getOrNull() } - private suspend fun put(cacheKey: String, jsonStr: String) { + private suspend fun put( + cacheKey: String, + jsonStr: String, + lease: CatalogCacheWriteLease, + ) { + if (lease.identityGeneration != identityTransitions.generation.value) return val snapshot = snapshotProvider() ?: return val profileId = snapshot.profileId ?: return + if (lease.identityGeneration != identityTransitions.generation.value) return // A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws // SQLiteBlobTooBigException. Big library pages can exceed it, so don't // store an unreadable row — drop any prior row for this key and skip. @@ -84,6 +128,9 @@ class RoomCatalogCacheRepository( ) } + private fun currentWriteLease() = + CatalogCacheWriteLease(identityTransitions.generation.value) + private suspend fun get(cacheKey: String): String? { val snapshot = snapshotProvider() ?: return null val profileId = snapshot.profileId ?: return null diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt index 41df86b16..93bedcc53 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt @@ -4,8 +4,11 @@ import org.prairieserver.prairie.common.data.db.PrairieDatabase import org.prairieserver.prairie.common.data.db.entity.HomeCacheEntity import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.port.HomeCachePort import org.prairieserver.prairie.repository.port.HomeCacheSnapshot +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease import kotlinx.serialization.json.Json /** @@ -19,6 +22,7 @@ import kotlinx.serialization.json.Json class RoomHomeCacheRepository( db: PrairieDatabase, private val snapshotProvider: suspend () -> AuthScopeSnapshot?, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), private val now: () -> Long = { System.currentTimeMillis() }, ) : HomeCachePort { @@ -26,8 +30,20 @@ class RoomHomeCacheRepository( private val json = Json { ignoreUnknownKeys = true } override suspend fun cacheHome(sections: List) { + cacheHome( + sections = sections, + lease = HomeCacheWriteLease(identityTransitions.generation.value), + ) + } + + override suspend fun cacheHome( + sections: List, + lease: HomeCacheWriteLease, + ) { + if (lease.identityGeneration != identityTransitions.generation.value) return val snapshot = snapshotProvider() ?: return val profileId = snapshot.profileId ?: return + if (lease.identityGeneration != identityTransitions.generation.value) return val sectionsJson = json.encodeToString(sections) // A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws // SQLiteBlobTooBigException. Large reorganized home layouts can exceed diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt index a939f4b71..edfadcf1f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerInfraModule.kt @@ -71,6 +71,10 @@ val playerInfraModule = module { DefaultServerSettingsFlusher( settingsApi = get(), scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + // Same source the settings store stamps its ops with, so a + // retained retry can tell whether the server it was authored + // against is still the one requests would reach. + getServerUrl = { get().getServerUrl() }, ) } @@ -166,7 +170,15 @@ val playerInfraModule = module { single { PlaybackSessionLifecycle( sessionManager = get(), - profileRepository = get(), + healthApi = get(), + personalDataRepository = get(), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), + playbackSessions = get(), + ) + } + single(AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER) { + PlaybackSessionLifecycle( + sessionManager = get(AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER), healthApi = get(), personalDataRepository = get(), scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerModule.kt index a34240120..ff590109d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerModule.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/di/PlayerModule.kt @@ -28,6 +28,8 @@ import org.koin.dsl.module val PLAYER_OKHTTP_QUALIFIER = named("player-okhttp") val PLAYER_TRANSPORT_OKHTTP_QUALIFIER = named("player-transport-okhttp") val PLAYER_HTTP_DATA_SOURCE_FACTORY_QUALIFIER = named("player-http-data-source-factory") +val AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER = named("audiobook-playback-session-manager") +val AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER = named("audiobook-playback-session-lifecycle") val playerModule = module { // Lightweight bootstrap client for the refresh RPC — no interceptors so a diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournal.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournal.kt index 6a2d94d25..faf7ad0db 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournal.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournal.kt @@ -36,6 +36,9 @@ class BreadcrumbJournal( noBackupFilesDir: File, writerDispatcher: CoroutineDispatcher = Dispatchers.IO, private val maxSegmentBytes: Int = DEFAULT_MAX_SEGMENT_BYTES, + private val listFiles: (File) -> Array? = File::listFiles, + private val deleteRecursively: (File) -> Boolean = File::deleteRecursively, + private val directorySync: (File) -> Unit = ::syncDiagnosticsDirectory, ) : DiagnosticsLogSink, DiagnosticsBreadcrumbSource { private val root = noBackupFilesDir.resolve("client-diagnostics/breadcrumbs") private val scope = CoroutineScope(SupervisorJob() + writerDispatcher) @@ -138,8 +141,8 @@ class BreadcrumbJournal( fun resetFiles(nextGeneration: Long, nextOwner: String?) { closeOutput() - allSegmentFiles().forEach { file -> - check(file.delete() || !file.exists()) { "unable to delete breadcrumb segment ${file.name}" } + allEvidenceFilesStrictly().forEach { file -> + deleteDiagnosticsEvidenceStrictly(file, deleteRecursively, directorySync) } diskGeneration = nextGeneration diskOwner = nextOwner @@ -161,7 +164,7 @@ class BreadcrumbJournal( closeOutput() activeSegment = 1 - activeSegment val stale = segmentFile(checkNotNull(diskOwner), activeSegment) - check(stale.delete() || !stale.exists()) { "unable to rotate breadcrumb segment ${stale.name}" } + deleteDiagnosticsEvidenceStrictly(stale, deleteRecursively, directorySync) } openOutput().apply { write(bytes) @@ -214,9 +217,11 @@ class BreadcrumbJournal( private fun segmentFiles(owner: String): List = listOf(segmentFile(owner, 0), segmentFile(owner, 1)).filter(File::isFile) - private fun allSegmentFiles(): List = root.listFiles { file -> - file.isFile && SEGMENT_PATTERN.matches(file.name) - }.orEmpty().toList() + private fun allEvidenceFilesStrictly(): List { + if (!root.exists()) return emptyList() + check(root.isDirectory) { "diagnostics breadcrumb path is not a directory" } + return checkNotNull(listFiles(root)) { "unable to enumerate diagnostics breadcrumbs" }.toList() + } private fun DiagnosticsIdentityKey.ownerKey(): String { val source = listOf( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCapture.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCapture.kt index 1e4202ed8..b1116e362 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCapture.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCapture.kt @@ -59,14 +59,24 @@ fun interface CrashMarkerSink { fun write(thread: Thread, throwable: Throwable, runtime: CrashRuntimeSnapshot) } -class CrashExceptionHandler( +internal class JvmCrashMarkerFileGate { + private val monitor = Any() + + fun withLock(block: () -> T): T = synchronized(monitor, block) +} + +internal val JVM_CRASH_MARKER_FILE_GATE = JvmCrashMarkerFileGate() + +internal class CrashExceptionHandler( private val markerSink: CrashMarkerSink, private val runtimeSnapshot: () -> CrashRuntimeSnapshot, private val previous: Thread.UncaughtExceptionHandler?, + private val writeGate: JvmCrashMarkerFileGate? = null, ) : Thread.UncaughtExceptionHandler { override fun uncaughtException(thread: Thread, throwable: Throwable) { try { - markerSink.write(thread, throwable, runtimeSnapshot()) + val write = { markerSink.write(thread, throwable, runtimeSnapshot()) } + writeGate?.withLock(write) ?: write() } catch (_: Throwable) { // The platform/default handler remains authoritative even if evidence capture fails. } finally { @@ -242,6 +252,7 @@ class CrashMarkerRenderer { append(",\"account_user_id\":").appendJsonString(binding.accountUserId) binding.profileId?.let { append(",\"profile_id\":").appendJsonString(it) } append(",\"ownership_generation\":").append(binding.ownershipGeneration) + append(",\"destination_kind\":").appendJsonString(binding.destinationKind.name) append('}') } ?: append("null") marker.captureSessionId?.let { append(",\"capture_session_id\":").appendJsonString(it) } @@ -360,14 +371,23 @@ object CrashCapture { fun install(context: Context) { if (!installed.compareAndSet(false, true)) return val previous = Thread.getDefaultUncaughtExceptionHandler() + val writer = FileCrashMarkerWriter(context.noBackupFilesDir) val handler = CrashExceptionHandler( - markerSink = FileCrashMarkerWriter(context.noBackupFilesDir), + markerSink = CrashMarkerSink { thread, throwable, snapshot -> + // Once the runtime privacy gate is closed, no raw unbound crash + // marker should be created. The same file gate serializes this + // decision and publication with close+purge during identity change. + if (snapshot.identityKey != null && snapshot.binding != null) { + writer.write(thread, throwable, snapshot) + } + }, runtimeSnapshot = { runtime.get().let { snapshot -> if (snapshot.identityKey == null) snapshot else snapshot.copy(logBuffer = logBuffer.get()) } }, previous = previous, + writeGate = JVM_CRASH_MARKER_FILE_GATE, ) Thread.setDefaultUncaughtExceptionHandler(handler) } @@ -377,26 +397,45 @@ object CrashCapture { } fun updateSnapshot(snapshot: CrashRuntimeSnapshot) { - runtime.set( - snapshot.copy( - playbackSessionIds = snapshot.playbackSessionIds.toList(), - logLines = snapshot.logLines.toList(), - redactionTokens = snapshot.redactionTokens.filter(String::isNotEmpty).toList(), - ), - ) + JVM_CRASH_MARKER_FILE_GATE.withLock { + runtime.set( + snapshot.copy( + playbackSessionIds = snapshot.playbackSessionIds.toList(), + logLines = snapshot.logLines.toList(), + redactionTokens = snapshot.redactionTokens.filter(String::isNotEmpty).toList(), + ), + ) + } + } + + fun closeGate() { + JVM_CRASH_MARKER_FILE_GATE.withLock { + runtime.set(CrashRuntimeSnapshot.empty()) + } } fun updatePlaybackSessionIds(identityKey: DiagnosticsIdentityKey, sessionIds: List) { - runtime.updateAndGet { current -> - if (current.identityKey == identityKey) { - current.copy(playbackSessionIds = sessionIds.toList()) - } else { - current + JVM_CRASH_MARKER_FILE_GATE.withLock { + runtime.updateAndGet { current -> + if (current.identityKey == identityKey) { + current.copy( + playbackSessionIds = if ( + current.binding?.destinationKind == DiagnosticsDestinationKind.HOSTED + ) { + emptyList() + } else { + sessionIds.toList() + }, + ) + } else { + current + } } } } - internal fun currentSnapshotForTests(): CrashRuntimeSnapshot = runtime.get() + internal fun currentSnapshotForTests(): CrashRuntimeSnapshot = + JVM_CRASH_MARKER_FILE_GATE.withLock(runtime::get) } private fun PendingReportBinding.bounded(): PendingReportBinding = copy( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsArchiveEncoder.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsArchiveEncoder.kt new file mode 100644 index 000000000..6270dc0aa --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsArchiveEncoder.kt @@ -0,0 +1,124 @@ +package org.prairieserver.prairie.common.diagnostics + +import java.io.ByteArrayOutputStream +import java.security.MessageDigest +import java.util.Locale +import java.util.zip.GZIPOutputStream + +internal data class DiagnosticsArchiveEntry(val name: String, val bytes: ByteArray) + +internal data class EncodedDiagnosticsArchive( + val bytes: ByteArray, + val uncompressedBytes: Long, + val sha256: String, +) + +/** Deterministic USTAR/gzip mechanics, deliberately separate from privacy-policy sanitization. */ +internal object DiagnosticsArchiveEncoder { + fun encode( + entries: List, + canonicalHostedGzip: Boolean, + ): EncodedDiagnosticsArchive { + val tarBytes = writeUstar(entries) + val gzipBytes = gzip(tarBytes, canonicalHostedGzip) + return EncodedDiagnosticsArchive( + bytes = gzipBytes, + uncompressedBytes = tarBytes.size.toLong(), + sha256 = sha256Hex(gzipBytes), + ) + } + + private fun gzip(bytes: ByteArray, canonicalHostedGzip: Boolean): ByteArray = + ByteArrayOutputStream().use { output -> + GZIPOutputStream(output).use { gzip -> gzip.write(bytes) } + output.toByteArray().also { compressed -> + if (canonicalHostedGzip) { + check( + compressed.size >= GZIP_HEADER_BYTES && + compressed[0] == GZIP_MAGIC_ID1 && + compressed[1] == GZIP_MAGIC_ID2, + ) { "hosted diagnostics gzip header is unavailable" } + compressed[GZIP_OS_OFFSET] = GZIP_CANONICAL_OS + } + } + } + + private fun writeUstar(entries: List): ByteArray = + ByteArrayOutputStream().use { output -> + entries.forEach { entry -> + val header = ustarHeader(entry) + output.write(header) + output.write(entry.bytes) + val padding = (BLOCK_SIZE - entry.bytes.size % BLOCK_SIZE) % BLOCK_SIZE + if (padding > 0) output.write(ByteArray(padding)) + } + output.write(ByteArray(BLOCK_SIZE * 2)) + output.toByteArray() + } + + private fun ustarHeader(entry: DiagnosticsArchiveEntry): ByteArray { + val nameBytes = entry.name.encodeToByteArray() + require(nameBytes.size <= NAME_BYTES) { "USTAR entry name is too long: ${entry.name}" } + require(entry.bytes.size.toLong() <= MAX_ENTRY_BYTES) { "USTAR entry is too large: ${entry.name}" } + val header = ByteArray(BLOCK_SIZE) + nameBytes.copyInto(header, destinationOffset = 0) + writeOctal(header, MODE_OFFSET, MODE_LENGTH, FILE_MODE) + writeOctal(header, UID_OFFSET, UID_LENGTH, 0) + writeOctal(header, GID_OFFSET, GID_LENGTH, 0) + writeOctal(header, SIZE_OFFSET, SIZE_LENGTH, entry.bytes.size.toLong()) + writeOctal(header, MTIME_OFFSET, MTIME_LENGTH, 0) + repeat(CHECKSUM_LENGTH) { header[CHECKSUM_OFFSET + it] = ' '.code.toByte() } + header[TYPE_OFFSET] = REGULAR_FILE_TYPE + USTAR_MAGIC.copyInto(header, destinationOffset = MAGIC_OFFSET) + USTAR_VERSION.copyInto(header, destinationOffset = VERSION_OFFSET) + writeChecksum(header, header.sumOf { it.toUByte().toLong() }) + return header + } + + private fun writeOctal(target: ByteArray, offset: Int, length: Int, value: Long) { + val encoded = value.toString(8).padStart(length - 1, '0').encodeToByteArray() + require(encoded.size == length - 1) { "USTAR numeric field overflow" } + encoded.copyInto(target, destinationOffset = offset) + target[offset + length - 1] = 0 + } + + private fun writeChecksum(target: ByteArray, checksum: Long) { + val encoded = checksum.toString(8).padStart(CHECKSUM_LENGTH - 2, '0').encodeToByteArray() + require(encoded.size == CHECKSUM_LENGTH - 2) { "USTAR checksum overflow" } + encoded.copyInto(target, destinationOffset = CHECKSUM_OFFSET) + target[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 2] = 0 + target[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 1] = ' '.code.toByte() + } + + private fun sha256Hex(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString(separator = "") { byte -> "%02x".format(Locale.ROOT, byte.toInt() and 0xff) } + + private const val BLOCK_SIZE = 512 + private const val NAME_BYTES = 100 + private const val MODE_OFFSET = 100 + private const val MODE_LENGTH = 8 + private const val UID_OFFSET = 108 + private const val UID_LENGTH = 8 + private const val GID_OFFSET = 116 + private const val GID_LENGTH = 8 + private const val SIZE_OFFSET = 124 + private const val SIZE_LENGTH = 12 + private const val MTIME_OFFSET = 136 + private const val MTIME_LENGTH = 12 + private const val CHECKSUM_OFFSET = 148 + private const val CHECKSUM_LENGTH = 8 + private const val TYPE_OFFSET = 156 + private const val MAGIC_OFFSET = 257 + private const val VERSION_OFFSET = 263 + private const val FILE_MODE = 420L + private const val REGULAR_FILE_TYPE = '0'.code.toByte() + private const val MAX_ENTRY_BYTES = 8_589_934_591L + private const val GZIP_HEADER_BYTES = 10 + private const val GZIP_OS_OFFSET = 9 + private const val GZIP_MAGIC_ID1: Byte = 0x1f + private const val GZIP_MAGIC_ID2: Byte = -0x75 + private const val GZIP_CANONICAL_OS: Byte = 0 + private val USTAR_MAGIC = byteArrayOf('u'.code.toByte(), 's'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), 'r'.code.toByte(), 0) + private val USTAR_VERSION = byteArrayOf('0'.code.toByte(), '0'.code.toByte()) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilder.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilder.kt index 2c20c2f5c..ce278131e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilder.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilder.kt @@ -1,12 +1,12 @@ package org.prairieserver.prairie.common.diagnostics -import java.io.ByteArrayOutputStream import java.io.File +import java.net.URI import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.security.MessageDigest +import java.text.Normalizer import java.util.Locale -import java.util.zip.GZIPOutputStream import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -16,6 +16,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.prairieserver.prairie.model.diagnostics.DiagnosticsArchive import org.prairieserver.prairie.model.diagnostics.DiagnosticsManifest import org.prairieserver.prairie.model.diagnostics.validate @@ -24,10 +25,17 @@ data class DiagnosticsBundle( val manifest: DiagnosticsManifest, val manifestBytes: ByteArray, val bytes: ByteArray, + /** Already-sanitized members used only to safely reframe stale hosted consent. */ + val sanitizedEntries: Map = emptyMap(), ) interface DiagnosticsBundleBuilder { fun build(report: PendingReport, redactionTokens: List): DiagnosticsBundle + + fun reframeHosted( + cached: DiagnosticsBundle, + consent: org.prairieserver.prairie.model.diagnostics.DiagnosticsConsent, + ): DiagnosticsBundle = error("hosted consent reframing is unsupported") } val CANONICAL_ARCHIVE_ORDER = listOf( @@ -44,14 +52,28 @@ val CANONICAL_ARCHIVE_ORDER = listOf( class FileDiagnosticsBundleBuilder : DiagnosticsBundleBuilder { override fun build(report: PendingReport, redactionTokens: List): DiagnosticsBundle { val tokens = redactionTokens.filter(String::isNotEmpty).distinct().sortedByDescending(String::length) + val hosted = report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED val artifactEntries = CANONICAL_ARCHIVE_ORDER.drop(1).mapNotNull { path -> + // ApplicationExitInfo tombstones are opaque protobuf bytes. They + // cannot pass the hosted collector's textual privacy admission + // boundary, so retain them only for self-hosted diagnostics. + if (hosted && path == CRASH_TOMBSTONE_FILE) return@mapNotNull null val file = report.directory.resolve(path) if (!file.isFile) return@mapNotNull null require(file.isWithin(report.directory)) { "diagnostics artifact escapes report directory: $path" } - val bytes = if (path in TEXT_ENTRIES) sanitizeText(path, file.readBytes(), tokens) else file.readBytes() - ArchiveEntry(path, bytes) + val bytes = if (path in TEXT_ENTRIES) { + sanitizeText( + path = path, + bytes = file.readBytes(), + tokens = tokens, + hosted = hosted, + ) + } else { + file.readBytes() + } + DiagnosticsArchiveEntry(path, bytes) } - val sanitizedManifest = sanitizeManifest(report.manifest, tokens).let { manifest -> + val sanitizedManifest = sanitizeManifest(report.manifest, tokens, hosted).let { manifest -> val logs = artifactEntries.firstOrNull { it.name == LOGS_FILE }?.bytes manifest.copy( logSummary = DiagnosticsLogSummaryBuilder.build( @@ -68,30 +90,71 @@ class FileDiagnosticsBundleBuilder : DiagnosticsBundleBuilder { .encodeToByteArray() val entries = buildList { - add(ArchiveEntry(MANIFEST_FILE, embeddedManifest)) + add(DiagnosticsArchiveEntry(MANIFEST_FILE, embeddedManifest)) addAll(artifactEntries) } require(entries.any { it.name == DEVICE_FILE }) { "device.json is required" } - val tarBytes = UstarWriter.write(entries) - val gzipBytes = gzip(tarBytes) - val externalManifest = sanitizedManifest.copy( + return finalize(sanitizedManifest, entries, canonicalHostedGzip = hosted) + } + + override fun reframeHosted( + cached: DiagnosticsBundle, + consent: org.prairieserver.prairie.model.diagnostics.DiagnosticsConsent, + ): DiagnosticsBundle { + require(cached.sanitizedEntries.isNotEmpty()) { "hosted sanitized evidence is unavailable" } + require(cached.manifest.destination.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID) + require(cached.manifest.report.profileId == null) + require(cached.manifest.playbackSessionIds.isEmpty()) + require(CRASH_TOMBSTONE_FILE !in cached.manifest.archive.entries) + val reframedManifest = cached.manifest.copy(consent = consent) + val embeddedManifest = JSON.encodeToJsonElement(DiagnosticsManifest.serializer(), reframedManifest) + .jsonObject + .jsonObjectWithoutArchive() + .let(JSON::encodeToString) + .encodeToByteArray() + val entries = cached.manifest.archive.entries.map { name -> + val bytes = if (name == MANIFEST_FILE) { + embeddedManifest + } else { + checkNotNull(cached.sanitizedEntries[name]) { "missing sanitized hosted member: $name" } + } + DiagnosticsArchiveEntry(name, bytes) + } + return finalize(reframedManifest, entries, canonicalHostedGzip = true) + } + + private fun finalize( + manifest: DiagnosticsManifest, + entries: List, + canonicalHostedGzip: Boolean, + ): DiagnosticsBundle { + val archive = DiagnosticsArchiveEncoder.encode(entries, canonicalHostedGzip) + val externalManifest = manifest.copy( archive = DiagnosticsArchive( - entries = entries.map(ArchiveEntry::name), - bytes = gzipBytes.size.toLong(), - uncompressedBytes = tarBytes.size.toLong(), - sha256 = sha256Hex(gzipBytes), + entries = entries.map(DiagnosticsArchiveEntry::name), + bytes = archive.bytes.size.toLong(), + uncompressedBytes = archive.uncompressedBytes, + sha256 = archive.sha256, ), ).also(DiagnosticsManifest::validate) val externalManifestBytes = JSON.encodeToString(externalManifest).encodeToByteArray() - return DiagnosticsBundle(externalManifest, externalManifestBytes, gzipBytes) + return DiagnosticsBundle( + externalManifest, + externalManifestBytes, + archive.bytes, + entries.associate { entry -> entry.name to entry.bytes }, + ) } private fun sanitizeManifest( manifest: DiagnosticsManifest, tokens: List, + hosted: Boolean, ): DiagnosticsManifest { - val sanitized = JSON.encodeToJsonElement(DiagnosticsManifest.serializer(), manifest).redact(tokens) + val redacted = JSON.encodeToJsonElement(DiagnosticsManifest.serializer(), manifest) + .redact(tokens) + val sanitized = if (hosted) redacted.sanitizeHostedManifestStrings() else redacted val encoded = JSON.encodeToString(sanitized) check(tokens.none(encoded::contains)) { "manifest redaction could not be verified" } return JSON.decodeFromString(encoded) @@ -101,130 +164,1190 @@ class FileDiagnosticsBundleBuilder : DiagnosticsBundleBuilder { path: String, bytes: ByteArray, tokens: List, + hosted: Boolean, ): ByteArray = runCatching { val decoded = checkNotNull(UTF8_DECODER.get()).decode(ByteBuffer.wrap(bytes)).toString() val sanitized = when { - path.endsWith(".json") -> JSON.encodeToString(JSON.parseToJsonElement(decoded).redact(tokens)) - path.endsWith(".jsonl") -> redactJsonLines(decoded, tokens) - else -> decoded.redact(tokens) + path.endsWith(".json") -> JSON.encodeToString( + JSON.parseToJsonElement(decoded) + .redact(tokens) + .stripHostedForbiddenIdentifiersIf(hosted) + .stripHostedDeviceIdentifiersIf(hosted && path == DEVICE_FILE) + .stripHostedCrashIdentifiersIf(hosted && path == CRASH_SUMMARY_FILE) + .normalizeHostedDeviceDecodersIf(hosted && path == DEVICE_FILE) + .sanitizeHostedStringsIf(hosted), + ) + path.endsWith(".jsonl") -> redactJsonLines(decoded, tokens, hosted) + path == CRASH_STACK_FILE && hosted -> decoded.redact(tokens).sanitizeHostedCrashStack() + else -> decoded.redact(tokens).sanitizeHostedTextIf(hosted) } check(tokens.none(sanitized::contains)) { "artifact redaction could not be verified" } sanitized.encodeToByteArray() }.getOrElse { REDACTION_FAILURE_SENTINEL } - private fun redactJsonLines(value: String, tokens: List): String { + private fun redactJsonLines( + value: String, + tokens: List, + hosted: Boolean, + ): String { val hadTrailingNewline = value.endsWith('\n') val lines = value.split('\n').let { if (hadTrailingNewline) it.dropLast(1) else it } val redacted = lines.joinToString("\n") { line -> - if (line.isBlank()) line else JSON.encodeToString(JSON.parseToJsonElement(line).redact(tokens)) + if (line.isBlank()) { + line + } else { + val sanitized = JSON.parseToJsonElement(line).redact(tokens).let { element -> + if (hosted) element.toHostedDiagnosticsLogLine().sanitizeHostedLogLineStrings() else element + } + JSON.encodeToString(sanitized) + } } return if (hadTrailingNewline) "$redacted\n" else redacted } + private fun JsonElement.toHostedDiagnosticsLogLine(): JsonElement { + if (this !is JsonObject) return this + val output = toMutableMap() + val category = output["cat"]?.jsonPrimitive?.contentOrNull + val allowedAttributes = HOSTED_V1_LOG_ATTRIBUTES[category].orEmpty() + val filteredAttributes = (output["attrs"] as? JsonObject) + ?.filterKeys(allowedAttributes::contains) + ?.mapValues { (key, value) -> + when { + category == "network" && key == "path" && value is JsonPrimitive && value.isString -> + JsonPrimitive(checkNotNull(value.contentOrNull).templateHostedPrivatePathSegments()) + category == "playback" && key == "decoder" && value is JsonPrimitive && value.isString -> + JsonPrimitive(checkNotNull(value.contentOrNull).hostedDecoderFamily()) + else -> value + } + } + .orEmpty() + if (filteredAttributes.isEmpty()) { + output.remove("attrs") + } else { + output["attrs"] = JsonObject(filteredAttributes) + } + return JsonObject(output) + } + private fun JsonElement.redact(tokens: List): JsonElement = when (this) { is JsonObject -> JsonObject(mapValues { (_, value) -> value.redact(tokens) }) is JsonArray -> JsonArray(map { value -> value.redact(tokens) }) is JsonPrimitive -> if (isString) JsonPrimitive(checkNotNull(contentOrNull).redact(tokens)) else this } + private fun JsonElement.sanitizeHostedStringsIf(hosted: Boolean): JsonElement = + if (hosted) sanitizeHostedStrings() else this + + private fun JsonElement.stripHostedDeviceIdentifiersIf(strip: Boolean): JsonElement = + if (strip) stripHostedDeviceIdentifiers() else this + + private fun JsonElement.stripHostedForbiddenIdentifiersIf(strip: Boolean): JsonElement = + if (strip) stripHostedForbiddenIdentifiers() else this + + private fun JsonElement.normalizeHostedDeviceDecodersIf(normalize: Boolean): JsonElement = + if (normalize) normalizeHostedDeviceDecoders() else this + + private fun JsonElement.stripHostedCrashIdentifiersIf(strip: Boolean): JsonElement = + if (strip && this is JsonObject) { + JsonObject(filterKeys { key -> key.normalizedPrivacyKey() !in HOSTED_CRASH_IDENTIFIER_KEYS }) + } else { + this + } + + private fun JsonElement.normalizeHostedDeviceDecoders(): JsonElement { + if (this !is JsonObject) return this + val videoCodecs = this["video_codecs"] as? JsonArray ?: return this + val normalizedCodecs = videoCodecs.map { codec -> + if (codec !is JsonObject) return@map codec + val decoderName = codec["decoder_name"] as? JsonPrimitive + if (decoderName?.isString != true) return@map codec + JsonObject( + codec.toMutableMap().also { fields -> + fields["decoder_name"] = JsonPrimitive( + checkNotNull(decoderName.contentOrNull).hostedDecoderFamily(), + ) + }, + ) + } + return JsonObject(toMutableMap().also { it["video_codecs"] = JsonArray(normalizedCodecs) }) + } + + private fun JsonElement.stripHostedDeviceIdentifiers(): JsonElement = when (this) { + is JsonObject -> JsonObject( + entries + .filterNot { (key, _) -> key.normalizedPrivacyKey() in HOSTED_DEVICE_IDENTIFIER_KEYS } + .associate { (key, value) -> key to value.stripHostedDeviceIdentifiers() }, + ) + is JsonArray -> JsonArray(map { value -> value.stripHostedDeviceIdentifiers() }) + is JsonPrimitive -> this + } + + private fun JsonElement.stripHostedForbiddenIdentifiers(): JsonElement = when (this) { + is JsonObject -> JsonObject( + entries + .filterNot { (key, _) -> + key.normalizedAssignmentKey().let { normalized -> + normalized in NETWORK_ASSIGNMENT_KEYS || normalized.hasSensitiveAssignmentKey() + } + } + .associate { (key, value) -> key to value.stripHostedForbiddenIdentifiers() }, + ) + is JsonArray -> JsonArray(map { value -> value.stripHostedForbiddenIdentifiers() }) + is JsonPrimitive -> this + } + + private fun String.normalizedPrivacyKey(): String = + lowercase(Locale.ROOT).filter(Char::isLetterOrDigit) + + private fun String.hostedDecoderFamily(): String { + val normalized = lowercase(Locale.ROOT) + return when { + normalized in HOSTED_DECODER_FAMILIES -> normalized + normalized.startsWith("c2.android.") -> HOSTED_C2_PLATFORM_DECODER + normalized.startsWith("c2.") -> HOSTED_C2_VENDOR_DECODER + normalized.startsWith("omx.google.") || normalized.startsWith("omx.android.") -> + HOSTED_OMX_PLATFORM_DECODER + normalized.startsWith("omx.") -> HOSTED_OMX_VENDOR_DECODER + else -> HOSTED_GENERIC_DECODER + } + } + + private fun JsonElement.sanitizeHostedStrings(): JsonElement = when (this) { + is JsonObject -> JsonObject(mapValues { (key, value) -> + if (key.isStructuredTimestampKey() && value is JsonPrimitive && value.isString) { + value + } else { + value.sanitizeHostedStrings() + } + }) + is JsonArray -> JsonArray(map { value -> value.sanitizeHostedStrings() }) + is JsonPrimitive -> if (isString) JsonPrimitive(checkNotNull(contentOrNull).sanitizeHostedText()) else this + } + + private fun JsonElement.sanitizeHostedManifestStrings(): JsonElement { + if (this !is JsonObject) return sanitizeHostedStrings() + return JsonObject( + mapValues { (key, value) -> + if (key == "crash" && value is JsonObject) { + value.sanitizeHostedCrashManifest() + } else if (key != "report" || value !is JsonObject) { + value.sanitizeHostedStrings() + } else { + JsonObject( + value.mapValues { (reportKey, reportValue) -> + if (reportValue.isSafeHostedReleaseMetadata(reportKey)) { + reportValue + } else if ( + reportKey == "capture_session_id" && + reportValue is JsonPrimitive && + reportValue.isString + ) { + JsonPrimitive( + checkNotNull(reportValue.contentOrNull) + .hostedAnonymousUuid(), + ) + } else if ( + reportKey.isStructuredTimestampKey() && + reportValue is JsonPrimitive && + reportValue.isString + ) { + reportValue + } else { + reportValue.sanitizeHostedStrings() + } + }, + ) + } + }, + ) + } + + private fun JsonObject.sanitizeHostedCrashManifest(): JsonObject = JsonObject( + mapValues { (key, value) -> + if (key == "stack_excerpt" && value is JsonPrimitive && value.isString) { + JsonPrimitive( + checkNotNull(value.contentOrNull) + .sanitizeHostedCrashStack(MAX_HOSTED_CRASH_EXCERPT_BYTES), + ) + } else if (key.isStructuredTimestampKey() && value is JsonPrimitive && value.isString) { + value + } else { + value.sanitizeHostedStrings() + } + }, + ) + + private fun JsonElement.sanitizeHostedLogLineStrings(): JsonElement { + if (this !is JsonObject) return sanitizeHostedStrings() + return JsonObject( + mapValues { (key, value) -> + if (key == "run" && value is JsonPrimitive && value.isString) { + JsonPrimitive( + checkNotNull(value.contentOrNull) + .hostedAnonymousUuid(), + ) + } else if (key.isStructuredTimestampKey() && value is JsonPrimitive && value.isString) { + value + } else { + value.sanitizeHostedStrings() + } + }, + ) + } + + private fun JsonElement.isSafeHostedReleaseMetadata(key: String): Boolean = + this is JsonPrimitive && isString && when (key) { + "app_version" -> checkNotNull(contentOrNull).matches(APP_VERSION_VALUE) + "app_build" -> checkNotNull(contentOrNull).matches(APP_BUILD_VALUE) + else -> false + } + private fun String.redact(tokens: List): String { var output = this tokens.forEach { token -> output = output.replace(token, REDACTED_VALUE) } return output } - private fun gzip(bytes: ByteArray): ByteArray = ByteArrayOutputStream().use { output -> - GZIPOutputStream(output).use { gzip -> gzip.write(bytes) } - output.toByteArray() - } - - private fun sha256Hex(bytes: ByteArray): String = - MessageDigest.getInstance("SHA-256") - .digest(bytes) - .joinToString(separator = "") { byte -> "%02x".format(Locale.ROOT, byte.toInt() and 0xff) } - - private data class ArchiveEntry(val name: String, val bytes: ByteArray) - - private object UstarWriter { - fun write(entries: List): ByteArray = ByteArrayOutputStream().use { output -> - entries.forEach { entry -> - val header = header(entry) - output.write(header) - output.write(entry.bytes) - val padding = (BLOCK_SIZE - entry.bytes.size % BLOCK_SIZE) % BLOCK_SIZE - if (padding > 0) output.write(ByteArray(padding)) - } - output.write(ByteArray(BLOCK_SIZE * 2)) - output.toByteArray() - } - - private fun header(entry: ArchiveEntry): ByteArray { - val nameBytes = entry.name.encodeToByteArray() - require(nameBytes.size <= NAME_BYTES) { "USTAR entry name is too long: ${entry.name}" } - require(entry.bytes.size.toLong() <= MAX_ENTRY_BYTES) { "USTAR entry is too large: ${entry.name}" } - val header = ByteArray(BLOCK_SIZE) - nameBytes.copyInto(header, destinationOffset = 0) - writeOctal(header, MODE_OFFSET, MODE_LENGTH, FILE_MODE) - writeOctal(header, UID_OFFSET, UID_LENGTH, 0) - writeOctal(header, GID_OFFSET, GID_LENGTH, 0) - writeOctal(header, SIZE_OFFSET, SIZE_LENGTH, entry.bytes.size.toLong()) - writeOctal(header, MTIME_OFFSET, MTIME_LENGTH, 0) - repeat(CHECKSUM_LENGTH) { header[CHECKSUM_OFFSET + it] = ' '.code.toByte() } - header[TYPE_OFFSET] = REGULAR_FILE_TYPE - USTAR_MAGIC.copyInto(header, destinationOffset = MAGIC_OFFSET) - USTAR_VERSION.copyInto(header, destinationOffset = VERSION_OFFSET) - val checksum = header.sumOf { it.toUByte().toLong() } - writeChecksum(header, checksum) - return header - } - - private fun writeOctal(target: ByteArray, offset: Int, length: Int, value: Long) { - val encoded = value.toString(8).padStart(length - 1, '0').encodeToByteArray() - require(encoded.size == length - 1) { "USTAR numeric field overflow" } - encoded.copyInto(target, destinationOffset = offset) - target[offset + length - 1] = 0 - } - - private fun writeChecksum(target: ByteArray, checksum: Long) { - val encoded = checksum.toString(8).padStart(CHECKSUM_LENGTH - 2, '0').encodeToByteArray() - require(encoded.size == CHECKSUM_LENGTH - 2) { "USTAR checksum overflow" } - encoded.copyInto(target, destinationOffset = CHECKSUM_OFFSET) - target[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 2] = 0 - target[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 1] = ' '.code.toByte() - } - - private const val BLOCK_SIZE = 512 - private const val NAME_BYTES = 100 - private const val MODE_OFFSET = 100 - private const val MODE_LENGTH = 8 - private const val UID_OFFSET = 108 - private const val UID_LENGTH = 8 - private const val GID_OFFSET = 116 - private const val GID_LENGTH = 8 - private const val SIZE_OFFSET = 124 - private const val SIZE_LENGTH = 12 - private const val MTIME_OFFSET = 136 - private const val MTIME_LENGTH = 12 - private const val CHECKSUM_OFFSET = 148 - private const val CHECKSUM_LENGTH = 8 - private const val TYPE_OFFSET = 156 - private const val MAGIC_OFFSET = 257 - private const val VERSION_OFFSET = 263 - private const val FILE_MODE = 420L - private const val REGULAR_FILE_TYPE = '0'.code.toByte() - private const val MAX_ENTRY_BYTES = 8_589_934_591L - private val USTAR_MAGIC = byteArrayOf('u'.code.toByte(), 's'.code.toByte(), 't'.code.toByte(), 'a'.code.toByte(), 'r'.code.toByte(), 0) - private val USTAR_VERSION = byteArrayOf('0'.code.toByte(), '0'.code.toByte()) + private fun String.hostedAnonymousUuid(): String { + if (CANONICAL_UUID.matches(this)) return lowercase(Locale.ROOT) + val digest = MessageDigest.getInstance("SHA-256").digest(encodeToByteArray()) + val hexadecimal = digest.take(16).joinToString(separator = "") { byte -> + "%02x".format(Locale.ROOT, byte.toInt() and 0xff) + } + return "${hexadecimal.take(8)}-${hexadecimal.substring(8, 12)}-" + + "${hexadecimal.substring(12, 16)}-${hexadecimal.substring(16, 20)}-" + + hexadecimal.substring(20) + } + + private fun String.sanitizeHostedTextIf(hosted: Boolean): String = + if (hosted) sanitizeHostedText() else this + + private fun String.sanitizeHostedCrashStack(maxUtf8Bytes: Int? = null): String { + val sanitizedWholeStack = sanitizeHostedText() + if (sanitizedWholeStack != HOSTED_UNSAFE_TEXT) { + return sanitizedWholeStack.boundHostedCrashText(maxUtf8Bytes) + } + val hadTrailingNewline = endsWith('\n') + val lines = split('\n').let { if (hadTrailingNewline) it.dropLast(1) else it } + val sanitized = buildList { + lines.forEach { line -> + val sanitizedLine = line.sanitizeHostedCrashStackLine() + val retained = sanitizedLine.takeIf { it.isUsefulHostedCrashStackLine() } + ?: HOSTED_UNSAFE_TEXT + if (retained != HOSTED_UNSAFE_TEXT || lastOrNull() != HOSTED_UNSAFE_TEXT) { + add(retained) + } + } + } + if (sanitized.none { it.isUsefulHostedCrashStackLine() }) return HOSTED_UNSAFE_TEXT + val joined = sanitized.joinToString("\n") + if (joined.hasUnsafeHostedResidue()) return HOSTED_UNSAFE_TEXT + return (if (hadTrailingNewline) "$joined\n" else joined) + .boundHostedCrashText(maxUtf8Bytes) + } + + private fun String.sanitizeHostedCrashStackLine(): String { + val withoutFrameQualifiers = HOSTED_QUALIFIED_STACK_FRAME.matchEntire(this)?.let { match -> + match.groupValues[1] + match.groupValues[2] + } ?: this + val sanitized = withoutFrameQualifiers.sanitizeHostedText() + val throwable = HOSTED_THROWABLE_LINE.matchEntire(sanitized.trim()) ?: return sanitized + return throwable.groupValues[1] + throwable.groupValues[2] + } + + private fun String.isUsefulHostedCrashStackLine(): Boolean { + val line = trim() + if (line.isEmpty() || line == HOSTED_UNSAFE_TEXT) return false + if (HOSTED_STACK_FRAME_LINE.matches(line) || HOSTED_STACK_OMITTED_LINE.matches(line)) return true + return HOSTED_THROWABLE_LINE.matches(line) + } + + private fun String.boundHostedCrashText(maxUtf8Bytes: Int?): String { + if (maxUtf8Bytes == null || encodeToByteArray().size <= maxUtf8Bytes) return this + val result = StringBuilder(length.coerceAtMost(maxUtf8Bytes)) + var index = 0 + var usedBytes = 0 + var lastLineBoundary = -1 + while (index < length) { + val codePoint = codePointAt(index) + val value = String(Character.toChars(codePoint)) + val bytes = value.encodeToByteArray().size + if (usedBytes + bytes > maxUtf8Bytes) break + result.append(value) + usedBytes += bytes + index += Character.charCount(codePoint) + if (codePoint == '\n'.code) lastLineBoundary = result.length + } + val bounded = if (lastLineBoundary > 0) result.substring(0, lastLineBoundary) else HOSTED_UNSAFE_TEXT + return bounded.takeIf { !it.hasUnsafeHostedResidue() } ?: HOSTED_UNSAFE_TEXT + } + + private fun String.sanitizeHostedText(): String { + val comparable = Normalizer.normalize(this, Normalizer.Form.NFKC) + .replace('\u3002', '.') + .replace('\uff0e', '.') + .replace('\uff61', '.') + var output = comparable + output = SENSITIVE_ASSIGNMENT.replace(output) { match -> + val key = match.groupValues[2].normalizedAssignmentKey() + when { + match.isCollectorSafeSourceLocationAssignment() -> match.value + match.groupValues[2].matches(QUALIFIED_ERROR_TYPE) || + match.groupValues[2].matches(QUALIFIED_ERROR_TERMINAL) -> + match.sanitizeQualifiedErrorMessage() + key in NETWORK_ASSIGNMENT_KEYS -> REDACTED_NETWORK_IDENTITY + key.hasSensitiveAssignmentKey() -> REDACTED_PRIVATE_ID + else -> match.value + } + } + output = LOOPBACK_IDENTITY.replace(output, REDACTED_HOST_VALUE) + output = HOST_TOKEN.replace(output, REDACTED_HOST_VALUE) + output = REDACTED_AUTHORITY.replace(output) { match -> + "${match.groupValues[1]}$REDACTED_HOST_VALUE" + } + output = OBFUSCATED_STACK_FRAME.replace(output) { match -> + val symbol = match.groupValues[2] + if (symbol.isObfuscatedStackSymbol()) { + "${match.groupValues[1]}$HOSTED_OBFUSCATED_FRAME" + } else { + match.value + } + } + output = OBFUSCATED_ERROR_TYPE.replace(output) { match -> + val symbol = match.groupValues[2] + if (symbol.isObfuscatedErrorType()) { + "${match.groupValues[1]}$HOSTED_OBFUSCATED_ERROR" + } else { + match.value + } + } + output = HOSTED_AUTHORITY_URL.replace(output) { match -> sanitizeHostedUrl(match.value) } + output = ANDROID_PRIVATE_PATH.replace(output, REDACTED_PRIVATE_ID) + output = BEARER_TOKEN.replace(output, REDACTED_PRIVATE_ID) + output = COLLECTOR_TOKEN.replace(output, REDACTED_PRIVATE_ID) + output = JWT_TOKEN.replace(output, REDACTED_PRIVATE_ID) + output = EMAIL_ADDRESS.replace(output, REDACTED_PRIVATE_ID) + output = MAC_NETWORK_IDENTITY.replace(output, REDACTED_HOST_VALUE) + output = LEGACY_LOOPBACK_NETWORK_IDENTITY.replace(output) { match -> + if (match.value.isLegacyLoopbackAddress()) REDACTED_HOST_VALUE else match.value + } + output = IPV4_NETWORK_IDENTITY.replace(output) { match -> + REDACTED_HOST_VALUE + match.validPortSuffix(groupIndex = 2) + } + output = BRACKETED_IPV6_NETWORK_IDENTITY.replace(output) { match -> + REDACTED_HOST_VALUE + match.validPortSuffix(groupIndex = 2) + } + output = BARE_IPV6_NETWORK_IDENTITY.replace(output) { match -> + if (match.value.count { it == ':' } >= 2) REDACTED_HOST_VALUE else match.value + } + output = BARE_PATH_IN_TEXT.replace(output) { match -> + val path = match.groupValues[2] + if (path.startsWith("//")) { + match.value + } else { + match.groupValues[1] + sanitizeHostedPath(path) + } + } + output = SENSITIVE_QUERY.replace(output, REDACTED_PRIVATE_ID) + output = BARE_UUID_ANYWHERE.replace(output, REDACTED_PRIVATE_ID) + output = COMPACT_UUID_ANYWHERE.replace(output, REDACTED_PRIVATE_ID) + output = COMPACT_HEX_ID.replace(output, REDACTED_PRIVATE_ID) + output = HIGH_CONFIDENCE_PRIVATE_ID.replace(output) { match -> + if (match.value.lowercase(Locale.ROOT) in SAFE_SEMANTIC_TOKENS) match.value else REDACTED_PRIVATE_ID + } + if (output.hasUnsafeHostedResidue()) return HOSTED_UNSAFE_TEXT + // NFKC is used for admission so full-width and alternate-dot bypasses + // cannot hide identities. Preserve byte-for-byte diagnostic prose when + // normalization did not reveal anything that needed rewriting. + return if (output == comparable) this else output + } + + private fun String.isObfuscatedStackSymbol(): Boolean { + val labels = split('.') + if (labels.size < 3) return false + val owner = labels.getOrNull(labels.lastIndex - 1).orEmpty() + return owner.firstOrNull()?.isLowerCase() == true + } + + private fun String.isObfuscatedErrorType(): Boolean { + val lower = lowercase(Locale.ROOT) + if ( + lower == REDACTED_HOST_VALUE || + lower in CANONICAL_ARTIFACT_NAMES || + lower in CANONICAL_ARTIFACT_BASENAMES + ) { + return false + } + val terminal = substringAfterLast('.') + if (terminal.matches(QUALIFIED_ERROR_TERMINAL)) return false + if (startsWith("org.prairieserver.prairie.") && terminal.firstOrNull()?.isUpperCase() == true) return false + return terminal.firstOrNull()?.isLowerCase() == true + } + + private fun String.normalizedAssignmentKey(): String = + replace(LOWER_CAMEL_KEY_BOUNDARY, "$1_$2") + .replace(UPPER_CAMEL_KEY_BOUNDARY, "$1_$2") + .replace(NON_KEY_CHARACTER, "_") + .trim('_') + .lowercase(Locale.ROOT) + + private fun String.isStructuredTimestampKey(): Boolean = + normalizedAssignmentKey().let { normalized -> + normalized == "ts" || + normalized == "timestamp" || + normalized == "captured_at" || + normalized == "occurred_at" || + normalized == "started_at" || + normalized == "ended_at" + } + + private fun String.hasSensitiveAssignmentKey(): Boolean = + SENSITIVE_ASSIGNMENT_KEYS.any { candidate -> + this == candidate || + startsWith("${candidate}_") || + endsWith("_${candidate}") || + contains("_${candidate}_") + } || replace("_", "") in SENSITIVE_ASSIGNMENT_COMPACT_KEYS || + split('_').any(CREDENTIAL_KEY_SEGMENTS::contains) || + substringAfterLast('_') in IDENTIFIER_KEY_SUFFIXES + + private fun String.hasUnsafeHostedResidue(): Boolean { + if (RFC3339_UTC.matches(this)) return false + val residue = APPROVED_HOSTED_AUTHORITY_URL.replace(this, "") + if (residue.contains("//") || residue.contains('@') || residue.contains('\u0000')) return true + if (UNAPPROVED_URI_SCHEME.containsMatchIn(residue)) return true + if (BARE_PATH_IN_TEXT.findAll(residue).any { match -> + val path = match.groupValues[2] + !path.startsWith("//") && !path.isCollectorSafeHostedPath() + } + ) { + return true + } + if (SENSITIVE_QUERY.containsMatchIn(residue)) return true + if (SENSITIVE_ASSIGNMENT.findAll(residue).any { match -> + !match.isCollectorSafeSourceLocationAssignment() && + !match.groupValues[2].matches(QUALIFIED_ERROR_TYPE) && + !match.groupValues[2].matches(QUALIFIED_ERROR_TERMINAL) && + match.groupValues[2].normalizedAssignmentKey().hasSensitiveAssignmentKey() + } + ) { + return true + } + if ( + LEGACY_HOST_TOKEN.containsMatchIn(residue) || + PRIVATE_SERVER_TOKEN.containsMatchIn(residue) || + MAC_NETWORK_IDENTITY.containsMatchIn(residue) || + BRACKETED_IPV6_NETWORK_IDENTITY.containsMatchIn(residue) || + BARE_IPV6_NETWORK_IDENTITY.findAll(residue).any { match -> match.value.count { it == ':' } >= 2 } || + residue.hasUnsafeHostWithPort() || + BARE_UUID_ANYWHERE.containsMatchIn(residue) || + COMPACT_UUID_ANYWHERE.containsMatchIn(residue) || + residue.hasUnsafeLegacyNetworkAddress() || + residue.hasUnsafeNetworkContext() + ) { + return true + } + return DOTTED_NETWORK_TOKEN.findAll(residue).any { match -> + !match.value.isCollectorSafeDottedToken(residue, match.range) + } + } + + private fun String.hasUnsafeHostWithPort(): Boolean = HOST_WITH_PORT.findAll(this).any { match -> + val label = match.groupValues[1] + val port = match.groupValues[2].toIntOrNull() ?: return@any false + if (port !in 1..65_535) return@any false + label.normalizedAssignmentKey() !in SAFE_NUMERIC_ASSIGNMENT_KEYS && + label !in SAFE_SOURCE_LOCATION_LABELS + } + + private fun MatchResult.isCollectorSafeSourceLocationAssignment(): Boolean { + val label = groupValues.getOrNull(2).orEmpty() + if (label !in SAFE_SOURCE_LOCATION_LABELS) return false + val suffix = value.substringAfter(':', missingDelimiterValue = "") + .ifEmpty { value.substringAfter('=', missingDelimiterValue = "") } + .trim() + return suffix.matches(SOURCE_LOCATION_LINE) + } + + private fun MatchResult.sanitizeQualifiedErrorMessage(): String { + val separator = value.indexOf(':') + if (separator < 0) return value + return value.take(separator + 1) + value.drop(separator + 1).sanitizeHostedText() + } + + private fun String.hasUnsafeLegacyNetworkAddress(): Boolean = + LEGACY_NETWORK_TOKEN.findAll(this).any { match -> + val token = match.value + val encoded = token.parseLegacyIpv4() ?: return@any false + val components = token.split('.') + val explicitLegacy = components.any { component -> + component.startsWith("0x", ignoreCase = true) || + (component.length > 1 && component.startsWith('0')) + } + val longInteger = components.size == 1 && token.matches(LONG_IPV4_INTEGER) + explicitLegacy || longInteger || (components.size > 1 && encoded.isNonPublicIpv4()) + } + + private fun String.hasUnsafeNetworkContext(): Boolean { + var networkContextRemaining = 0 + var networkContextWord = "" + var numericNetworkContextRemaining = 0 + NETWORK_SCAN_TOKEN.findAll(this).forEach { match -> + val token = match.value.trimEnd('.', '-').lowercase(Locale.ROOT) + if (token == REDACTED_HOST_VALUE) { + networkContextRemaining = 0 + networkContextWord = "" + numericNetworkContextRemaining = 0 + return@forEach + } + if (token in NUMERIC_NETWORK_CONTEXT_WORDS) { + numericNetworkContextRemaining = NETWORK_CONTEXT_WINDOW + return@forEach + } + if (numericNetworkContextRemaining > 0) { + if (token in NETWORK_CONNECTOR_WORDS) { + numericNetworkContextRemaining -= 1 + return@forEach + } + if (token.parseLegacyIpv4() != null) return true + numericNetworkContextRemaining = 0 + } + if (token in NETWORK_CONTEXT_WORDS) { + networkContextRemaining = NETWORK_CONTEXT_WINDOW + networkContextWord = token + return@forEach + } + if (networkContextRemaining > 0) { + if (token in NETWORK_CONNECTOR_WORDS) { + networkContextRemaining -= 1 + return@forEach + } + if (token in NETWORK_NON_HOST_WORDS) { + networkContextRemaining = 0 + networkContextWord = "" + return@forEach + } + if (networkContextWord == "server" && token in SERVER_STATE_WORDS) { + networkContextRemaining = 0 + networkContextWord = "" + return@forEach + } + if (networkContextWord == "backend" && token in SAFE_BACKEND_VALUES) { + networkContextRemaining = 0 + networkContextWord = "" + return@forEach + } + if ( + token.parseLegacyIpv4() != null || + token.matches(NETWORK_LABEL_TOKEN) + ) { + return true + } + networkContextRemaining = 0 + networkContextWord = "" + } + } + return false + } + + private fun String.isCollectorSafeDottedToken(context: String, range: IntRange): Boolean { + val lower = lowercase(Locale.ROOT) + if (lower == REDACTED_HOST_VALUE) return true + if (lower in CANONICAL_ARTIFACT_NAMES || lower in CANONICAL_ARTIFACT_BASENAMES) return true + if (lower in SAFE_DOTTED_TELEMETRY_KEYS || lower in SAFE_DOTTED_SETTING_KEYS) return true + if (lower in KNOWN_NATIVE_LIBRARIES) return true + if (lower in SAFE_MEDIA_DOTTED_SUBTYPES) return true + if (lower in SAFE_MEDIA_RESOURCE_NAMES && context.getOrNull(range.first - 1) == '/') return true + if (all { character -> character.isDigit() || character == '.' }) return true + if (matches(RFC3339_UTC)) return true + if (matches(QUALIFIED_ERROR_TYPE)) return true + if (startsWith("org.prairieserver.prairie.") && substringAfterLast('.').firstOrNull()?.isUpperCase() == true) { + return true + } + val after = context.getOrNull(range.last + 1) + if (after == '(' && matches(QUALIFIED_STACK_SYMBOL)) return true + if ( + after == ':' && + context.drop(range.last + 2).takeWhile(Char::isDigit).isNotEmpty() && + matches(SOURCE_FILE_TOKEN) + ) { + return true + } + return false } + private fun String.parseLegacyIpv4(): Long? { + val components = split('.') + if (components.size !in 1..4) return null + val values = components.map { component -> + when { + component.isEmpty() || component.length > 16 -> null + component.startsWith("0x", ignoreCase = true) -> + component.drop(2).takeIf(String::isNotEmpty)?.toLongOrNull(16) + component.length > 1 && component.startsWith('0') -> component.drop(1).toLongOrNull(8) + else -> component.toLongOrNull() + } + } + if (values.any { it == null }) return null + val concrete = values.map(::checkNotNull) + if (concrete.dropLast(1).any { it > 0xff }) return null + val lastLimit = when (concrete.size) { + 1 -> 0xffff_ffffL + 2 -> 0xff_ffffL + 3 -> 0xffffL + else -> 0xffL + } + if (concrete.last() > lastLimit) return null + return concrete.dropLast(1).foldIndexed(concrete.last()) { index, result, component -> + result + (component shl (24 - index * 8)) + } + } + + private fun Long.isNonPublicIpv4(): Boolean { + val first = (this ushr 24).toInt() + val second = ((this ushr 16) and 0xff).toInt() + return first == 0 || first == 10 || + (first == 100 && second in 64..127) || + first == 127 || + (first == 169 && second == 254) || + (first == 172 && second in 16..31) || + (first == 192 && (second == 0 || second == 168)) || + (first == 198 && second in 18..19) || + first >= 224 + } + + private fun String.isLegacyLoopbackAddress(): Boolean { + val components = split('.') + val parsed = components.map { component -> + when { + component.startsWith("0x", ignoreCase = true) -> component.drop(2).toLongOrNull(16) + component.length > 1 && component.startsWith('0') -> component.drop(1).toLongOrNull(8) + else -> component.toLongOrNull() + } + } + if (parsed.any { it == null }) return false + val values = parsed.map(::checkNotNull) + val encoded = when (values.size) { + 1 -> values[0].takeIf { it <= 0xffff_ffffL } + 2 -> if (values[0] <= 0xff && values[1] <= 0xff_ffff) { + (values[0] shl 24) or values[1] + } else { + null + } + 3 -> if (values[0] <= 0xff && values[1] <= 0xff && values[2] <= 0xffff) { + (values[0] shl 24) or (values[1] shl 16) or values[2] + } else { + null + } + 4 -> if (values.all { it <= 0xff }) { + (values[0] shl 24) or (values[1] shl 16) or (values[2] shl 8) or values[3] + } else { + null + } + else -> null + } ?: return false + return (encoded ushr 24) == 127L + } + + private fun MatchResult.validPortSuffix(groupIndex: Int): String = + groupValues.getOrNull(groupIndex) + ?.takeIf(String::isNotEmpty) + ?.toIntOrNull() + ?.takeIf { it in 1..65_535 } + ?.let { ":$it" } + .orEmpty() + + private fun sanitizeHostedUrl(candidate: String): String { + val trailing = candidate.takeLastWhile { it in TRAILING_URL_PUNCTUATION } + val core = candidate.dropLast(trailing.length) + if (core.isCollectorSafeHostedAuthority()) return candidate + val scheme = core.substringBefore("://", missingDelimiterValue = "").lowercase(Locale.ROOT) + if (scheme !in HOSTED_URL_SCHEMES) return candidate + val uri = runCatching { URI(core) }.getOrNull() + ?: return "$scheme://$REDACTED_HOST_VALUE/redacted$trailing" + uri.host ?: return "$scheme://$REDACTED_HOST_VALUE/redacted$trailing" + val port = uri.port.takeIf { it in 1..65_535 } ?: -1 + val rawPath = uri.rawPath.orEmpty() + val sanitizedPath = sanitizeHostedPath(rawPath).let { path -> + if (path == REDACTED_PRIVATE_ID) "/redacted" else path + } + return runCatching { + URI( + uri.scheme, + null, + REDACTED_HOST_VALUE, + port, + sanitizedPath, + null, + null, + ).toASCIIString() + .replace("%7Bid%7D", "{id}", ignoreCase = true) + trailing + }.getOrDefault(candidate) + } + + private fun String.isCollectorSafeHostedAuthority(): Boolean { + val scheme = substringBefore("://", missingDelimiterValue = "").lowercase(Locale.ROOT) + if (scheme !in HOSTED_URL_SCHEMES) return false + val remainder = substringAfter("://", missingDelimiterValue = "") + if (remainder.isEmpty() || '?' in remainder || '#' in remainder || '@' in remainder) return false + val authority = remainder.substringBefore('/') + val host = authority.substringBefore(':') + if (!host.equals(REDACTED_HOST_VALUE, ignoreCase = false)) return false + val port = authority.substringAfter(':', missingDelimiterValue = "") + if (port.isNotEmpty() && (port.toIntOrNull() !in 1..65_535)) return false + val path = remainder.substringAfter('/', missingDelimiterValue = "") + return "/$path".isCollectorSafeHostedPath() + } + + private fun sanitizeHostedPath(candidate: String): String { + if (candidate.isCollectorSafeHostedPath()) return candidate + if ( + candidate.contains('?') || + candidate.contains('#') || + candidate.contains('%') || + candidate.startsWith("/users/", ignoreCase = true) || + candidate.startsWith("/private/", ignoreCase = true) || + candidate.startsWith("/var/mobile/", ignoreCase = true) || + candidate.startsWith("/data/user/", ignoreCase = true) || + candidate.startsWith("/data/", ignoreCase = true) || + candidate.startsWith("/storage/", ignoreCase = true) || + candidate.startsWith("/sdcard/", ignoreCase = true) || + candidate.startsWith("/mnt/", ignoreCase = true) || + candidate.startsWith("/system/", ignoreCase = true) || + candidate.startsWith("/vendor/", ignoreCase = true) || + candidate.startsWith("/apex/", ignoreCase = true) || + candidate.startsWith("/proc/", ignoreCase = true) || + candidate.startsWith("/dev/", ignoreCase = true) || + candidate.startsWith("/Users/", ignoreCase = true) + ) { + return REDACTED_PRIVATE_ID + } + val templated = candidate.templateHostedPrivatePathSegments() + return if (templated.isCollectorSafeHostedPath()) templated else REDACTED_PRIVATE_ID + } + + private fun String.isCollectorSafeHostedPath(): Boolean { + if ('?' in this || '#' in this || '%' in this) return false + val lower = lowercase(Locale.ROOT) + if ( + lower.startsWith("/users/") || + lower.startsWith("/private/") || + lower.startsWith("/var/mobile/") || + lower.startsWith("/data/user/") + ) { + return false + } + return split('/').all { rawSegment -> + if (rawSegment.isEmpty()) return@all true + val segment = rawSegment.trim(*PATH_SEGMENT_PUNCTUATION) + if (segment.isEmpty() || TEMPLATE_SEGMENT.matches(segment) || SAFE_VERSION_SEGMENT.matches(segment)) { + return@all true + } + val candidates = listOf(segment) + segment.split(PATH_CANDIDATE_DELIMITER) + candidates.none { value -> + CANONICAL_UUID.containsMatchIn(value) || + value.matches(NUMERIC_ID_PATH_SEGMENT) || + value.matches(HEX_ID_PATH_SEGMENT) || + value.matches(OPAQUE_ID_PATH_SEGMENT) || + value.matches(PRIVATE_ID_PATH_SEGMENT) + } + } + } + + private fun String.templateHostedPrivatePathSegments(): String = split('/') + .joinToString("/") { segment -> + if ( + UUID_PATH_SEGMENT.matches(segment) || + NUMERIC_ID_PATH_SEGMENT.matches(segment) || + HEX_ID_PATH_SEGMENT.matches(segment) || + OPAQUE_ID_PATH_SEGMENT.matches(segment) || + BARE_UUID.containsMatchIn(segment) || + segment.containsHighConfidencePrivateId() + ) { + "{id}" + } else { + segment + } + } + + private fun String.containsHighConfidencePrivateId(): Boolean = + HIGH_CONFIDENCE_PRIVATE_ID.findAll(this).any { match -> + match.value.lowercase(Locale.ROOT) !in SAFE_SEMANTIC_TOKENS + } + private companion object { const val MANIFEST_FILE = "manifest.json" const val DEVICE_FILE = "device.json" + const val CRASH_SUMMARY_FILE = "crash/summary.json" + const val CRASH_STACK_FILE = "crash/stack.txt" const val LOGS_FILE = "logs.jsonl" + const val CRASH_TOMBSTONE_FILE = "crash/tombstone.pb" const val REDACTED_VALUE = "[REDACTED]" + const val REDACTED_HOST_VALUE = "redacted.invalid" + const val REDACTED_NETWORK_IDENTITY = "[redacted_network_identity]" + const val REDACTED_PRIVATE_ID = "[redacted_private_id]" + const val HOSTED_UNSAFE_TEXT = "[redacted_private_id]" + const val HOSTED_C2_PLATFORM_DECODER = "android-c2-platform-decoder" + const val HOSTED_C2_VENDOR_DECODER = "android-c2-vendor-decoder" + const val HOSTED_OMX_PLATFORM_DECODER = "android-omx-platform-decoder" + const val HOSTED_OMX_VENDOR_DECODER = "android-omx-vendor-decoder" + const val HOSTED_GENERIC_DECODER = "android-decoder" + const val HOSTED_OBFUSCATED_FRAME = "android-obfuscated-frame" + const val HOSTED_OBFUSCATED_ERROR = "android-obfuscated-error" + const val MAX_HOSTED_CRASH_EXCERPT_BYTES = 8 * 1_024 val REDACTION_FAILURE_SENTINEL = "{\"redaction_failure\":true}\n".encodeToByteArray() + val HOSTED_URL_SCHEMES = setOf("http", "https", "ws", "wss") val TEXT_ENTRIES = CANONICAL_ARCHIVE_ORDER.toSet() - MANIFEST_FILE - "crash/tombstone.pb" + // A privacy allowlist for the hosted destination, not a second copy of the + // emission registry in PrairieLog. Every key here must also appear in the + // canonical attribute registry (vendored at + // shared/src/commonTest/resources/diagnostics/v1/attr-registry.json) with the + // same type — the hosted collector rejects the whole bundle on an + // unregistered key. The reverse does not hold. The canonical playback keys + // that describe one user's specific viewing session — session_id, + // play_method, reason, position_ms — plus network attempt are deliberately + // withheld from the third-party collector even though it registers them; + // they are server-issued identifiers, operator free text, a viewing + // position, and a retry counter the collector's privacy scanner treats as a + // correlation handle. Self-hosted uploads still carry them. This mirrors the + // Apple client's hostedAttributeRegistry decision key for key, so both + // clients withhold the same set for the same stated reason. Note that + // lifecycle "reason" is a client-side classification, not the playback + // operator free text, and stays allowed. See + // hostedBundleWithholdsPrivatePlaybackAndAttemptAttributesFromCollector for + // the pinned set. + val HOSTED_V1_LOG_ATTRIBUTES = mapOf( + "playback" to setOf( + "sink", + "fmt", + "decoder", + "width", + "height", + "hdr_mode", + "bitrate_kbps", + "dropped_frames", + "audio_underruns", + ), + "focus" to setOf("target", "action"), + "network" to setOf( + "method", + "path", + "status", + "duration_ms", + "outcome", + "error_code", + ), + "lifecycle" to setOf( + "state", + "phase", + "duration_ms", + "outcome", + "reason", + "launch_type", + ), + "crash" to setOf("fingerprint", "source"), + ) + val APP_VERSION_VALUE = Regex("^[0-9]+(?:\\.[0-9]+){1,3}(?:[-+][A-Za-z0-9._-]+)?$") + val APP_BUILD_VALUE = Regex("^[0-9]+$") + val HOSTED_DECODER_FAMILIES = setOf( + HOSTED_C2_PLATFORM_DECODER, + HOSTED_C2_VENDOR_DECODER, + HOSTED_OMX_PLATFORM_DECODER, + HOSTED_OMX_VENDOR_DECODER, + HOSTED_GENERIC_DECODER, + ) + val HOSTED_DEVICE_IDENTIFIER_KEYS = setOf( + "id", + "address", + "routehash", + "routehashes", + "deviceid", + "deviceaddress", + "deviceidhash", + "deviceaddresshash", + "serial", + "serialnumber", + "imei", + "meid", + "mac", + "macaddress", + "ssid", + "bssid", + "ip", + "ipaddress", + "buildfingerprinthash", + "host", + "hostname", + "server", + "serverurl", + "baseurl", + "origin", + "originurl", + "endpoint", + "endpointurl", + "url", + ) + val HOSTED_FORBIDDEN_IDENTIFIER_KEYS = setOf( + "account", "accountid", "user", "userid", "email", "profile", "profileid", + "profiletoken", "cookie", "authorization", "password", "passwd", "secret", + "apikey", "credential", "privatekey", "authtoken", "accesstoken", "refreshtoken", + "clientsecret", "uploadtoken", "serverurl", "baseurl", "originurl", "hostname", + "deviceid", "devicename", "devicetoken", "deviceidentifier", "serial", "serialnumber", + "imei", "meid", "androidid", "advertisingid", "advertisingidentifier", "adid", "aaid", + "gaid", "vendorid", "vendoridentifier", "identifierforvendor", "idfa", "idfv", "mac", + "macaddress", "ip", "ipaddress", "ssid", "bssid", "uidhash", "routehash", "routehashes", + ) + val HOSTED_CRASH_IDENTIFIER_KEYS = setOf("process", "processname", "processhash") + val NETWORK_ASSIGNMENT_KEYS = setOf( + "host", "hostname", "server", "server_url", "base_url", "origin", "origin_url", + "endpoint", "endpoint_url", "address", "url", "peer", "ip", "ip_address", "mac", + "mac_address", "ssid", "bssid", + ) + val SENSITIVE_ASSIGNMENT_KEYS = setOf( + "account", "account_id", "user", "user_id", "email", "profile", "profile_id", + "profile_token", "cookie", "authorization", "auth", "bearer", "password", "passwords", + "pass", "passwd", "passphrase", "pwd", "secret", "secrets", "api_key", "apikey", + "credential", "credentials", "passcode", "pin", "otp", "private_key", "auth_token", "access_token", "refresh_token", + "client_secret", "upload_token", "server_url", "base_url", "origin_url", "hostname", + "device_id", "device_name", "device_token", "device_identifier", "serial", "serial_number", + "imei", "meid", "android_id", "advertising_id", "advertising_identifier", "ad_id", "aaid", + "gaid", "vendor_id", "vendor_identifier", "identifier_for_vendor", "idfa", "idfv", "mac", + "mac_address", "ip", "ip_address", "ssid", "bssid", "uid_hash", "route_hash", "route_hashes", + "capture_session_id", "session", "session_id", "playback", "playback_id", "playback_session", + "playback_session_id", "playback_session_ids", "file", "file_id", "selected_file_id", + "effective_file_id", "requested_file_id", "selected_media_file_id", "effective_media_file_id", + "requested_media_file_id", "media", "media_id", "media_file", "media_file_id", "item", "item_id", + "content_id", "content_identifier", "library_id", "library_identifier", "plan", "plan_id", + "plan_attempt", "plan_attempt_id", "plan_attempt_key", "attempt", "attempt_id", "playback_attempt", + "playback_attempt_id", "subtitle", "subtitle_id", "track", "track_id", "request_id", "req_id", + "request_identifier", "req_identifier", "request_uuid", "req_uuid", "correlation_id", + "correlation_identifier", "correlation_uuid", "trace", "trace_id", "traceparent", "trace_parent", + "span", "span_id", "transaction_id", "transaction_identifier", "server", "server_id", + "server_instance", "server_instance_id", "peer", "host", "jwt", "key", "login", "origin", + "endpoint", "address", "sig", "signature", "token", "tokens", "url", "username", + ) + val SENSITIVE_ASSIGNMENT_COMPACT_KEYS = + SENSITIVE_ASSIGNMENT_KEYS.mapTo(mutableSetOf()) { key -> key.replace("_", "") } + val CREDENTIAL_KEY_SEGMENTS = setOf( + "auth", "authorization", "bearer", "credential", "credentials", "jwt", "key", "login", + "otp", "pass", "passcode", "passphrase", "passwd", "password", "passwords", "pin", + "secret", "secrets", "sig", "signature", "token", "tokens", "username", + ) + val IDENTIFIER_KEY_SUFFIXES = setOf("id", "ids", "identifier", "identifiers", "uuid", "uuids") + val SENSITIVE_ASSIGNMENT = Regex( + """(?i)(?|))+)(?=\()""", + ) + val OBFUSCATED_ERROR_TYPE = Regex( + """(?im)^([ \t]*(?:(?:caused[ \t]+by|suppressed):?[ \t]+)?)([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+)(?=[ \t]*(?::|$))""", + ) + val QUALIFIED_ERROR_TERMINAL = Regex("^[A-Z][A-Za-z0-9_$]*(?:Exception|Error)$") + val QUALIFIED_ERROR_TYPE = Regex( + "^[A-Za-z_][A-Za-z0-9_$]*(?:\\.[A-Za-z_][A-Za-z0-9_$]*)*\\.[A-Z][A-Za-z0-9_$]*(?:Exception|Error)$", + ) + val QUALIFIED_STACK_SYMBOL = Regex( + "^[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)+\\.[a-z_$][A-Za-z0-9_$]*$", + ) + val HOSTED_STACK_FRAME_LINE = Regex( + "^at[ \\t]+(?:android-obfuscated-frame|" + + "[A-Za-z_$][A-Za-z0-9_$]*(?:\\.(?:[A-Za-z_$][A-Za-z0-9_$]*||))+" + + ")\\([^\\r\\n]*\\)$", + ) + val HOSTED_QUALIFIED_STACK_FRAME = Regex( + "^([ \\t]*at[ \\t]+)" + + "(?:(?:[^\\s/]+/){1,2}|[^\\s/]+//)" + + "([A-Za-z_$][A-Za-z0-9_$]*(?:\\.(?:[A-Za-z_$][A-Za-z0-9_$]*||))+" + + "\\([^\\r\\n]*\\))$", + ) + val HOSTED_THROWABLE_LINE = Regex( + "^((?:(?i:caused[ \\t]+by|suppressed):?[ \\t]*)?)" + + "(android-obfuscated-error|" + + "[A-Za-z_][A-Za-z0-9_$]*(?:\\.[A-Za-z_][A-Za-z0-9_$]*)*\\." + + "[A-Z][A-Za-z0-9_$]*(?:Exception|Error))" + + "(?:[ \\t]*:.*)?$", + ) + val HOSTED_STACK_OMITTED_LINE = Regex("^\\.\\.\\.[ \\t]+[0-9]+[ \\t]+more$") + val SOURCE_FILE_TOKEN = Regex("^[A-Z][A-Za-z0-9_$-]*\\.(?:c|cc|cpp|h|java|kt|m|mm|swift)$") + val HOST_TOKEN = Regex("(?i)\\bhost_[0-9a-f]{16}\\b") + val REDACTED_AUTHORITY = Regex("(?i)\\b((?:https?|wss?)://)\\[REDACTED]") + val HOSTED_AUTHORITY_URL = Regex("(?i)\\b(?:https?|wss?)://[^\\s<>\\\"']+") + val APPROVED_HOSTED_AUTHORITY_URL = Regex( + """(?i)\b(?:https?|wss?)://redacted\.invalid(?::(?:[1-9][0-9]{0,4}))?(?:/[^\s<>\"'?#]*)?""", + ) + val UNAPPROVED_URI_SCHEME = Regex("(?i)(?\\]+)""") + val SENSITIVE_QUERY = Regex( + """(?i)(?:[?&](?:token|signature|x-amz-credential|x-amz-signature)=[^\s,;)\]}]+)+""", + ) + val NON_ASCII_TEXT = Regex("[^\\x00-\\x7f]+") + val MAC_NETWORK_IDENTITY = Regex( + """(?i)(?:(? = ThreadLocal.withInitial { Charsets.UTF_8.newDecoder() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinator.kt index 5bb2403fe..ef70f51be 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinator.kt @@ -2,7 +2,9 @@ package org.prairieserver.prairie.common.diagnostics import java.io.File import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -15,6 +17,7 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.IdentityTransitionPhase +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportState data class ActiveDiagnosticsCapture( val generation: Long, @@ -32,13 +35,24 @@ interface DiagnosticsCaptureController { suspend fun captureNow(context: DiagnosticsCaptureContext): PendingReport? suspend fun setDebugLogging(context: DiagnosticsCaptureContext?, enabled: Boolean) = Unit suspend fun setPersistentBreadcrumbs(context: DiagnosticsCaptureContext?, enabled: Boolean) = Unit - suspend fun purge(binding: DiagnosticsBinding) + /** Removes detached crash-interrupted evidence before capture state is exposed or enabled. */ + suspend fun reconcileStoredEvidence() = Unit + /** Removes all live/global evidence after [closeGate] has stopped new writes. */ + suspend fun purgeCurrentEvidence() } fun interface DiagnosticsUploadScheduler { fun enqueue(reportId: String) } +fun interface HostedDiagnosticsDeletionScheduler { + fun enqueue() + + data object None : HostedDiagnosticsDeletionScheduler { + override fun enqueue() = Unit + } +} + interface DiagnosticsRuntimePublisher { fun closeGate() suspend fun publish(context: DiagnosticsCaptureContext) @@ -56,19 +70,29 @@ fun interface DiagnosticsIncidentCollector { ): List } +fun interface DiagnosticsStoredEvidenceReconciler { + fun reconcile() + + data object None : DiagnosticsStoredEvidenceReconciler { + override fun reconcile() = Unit + } +} + interface DiagnosticsCoordinator { val state: StateFlow fun start() suspend fun refresh() suspend fun setConsent(mode: DiagnosticsConsentMode, expectedNoticeVersion: Int? = null) + suspend fun setDestination(destinationKind: DiagnosticsDestinationKind) suspend fun setDebugLogging(enabled: Boolean) suspend fun captureNow(): String? suspend fun startTimedCapture() suspend fun stopTimedCapture(): String? suspend fun cancelTimedCapture() suspend fun upload(reportId: String, expectedNoticeVersion: Int? = null): DiagnosticsUploadDecision - suspend fun delete(reportId: String) + suspend fun uploadAutomatically(reportId: String): DiagnosticsUploadDecision = upload(reportId) + suspend fun delete(reportId: String): Boolean suspend fun decline(reportId: String) } @@ -76,13 +100,17 @@ class DefaultDiagnosticsCoordinator( private val scope: CoroutineScope, private val identity: DiagnosticsIdentityResolver, private val identityTransitions: IdentityTransitionBarrier, + private val privacyBarrier: DiagnosticsPrivacyBarrier = DiagnosticsPrivacyBarrier(), private val settings: DiagnosticsSettingsStore, private val reports: PendingReportStore, private val capture: DiagnosticsCaptureController, private val uploader: DiagnosticsUploader, private val uploadScheduler: DiagnosticsUploadScheduler, + private val hostedDeletionScheduler: HostedDiagnosticsDeletionScheduler = HostedDiagnosticsDeletionScheduler.None, + private val hostedReportDeleter: HostedDiagnosticsReportDeleter = HostedDiagnosticsReportDeleter.None, private val runtimePublisher: DiagnosticsRuntimePublisher = DiagnosticsRuntimePublisher.None, private val incidentCollector: DiagnosticsIncidentCollector = DiagnosticsIncidentCollector { _, _ -> emptyList() }, + private val storedEvidenceReconciler: DiagnosticsStoredEvidenceReconciler = DiagnosticsStoredEvidenceReconciler.None, actorDispatcher: CoroutineDispatcher = Dispatchers.IO, private val nowMs: () -> Long = System::currentTimeMillis, ) : DiagnosticsCoordinator { @@ -90,6 +118,10 @@ class DefaultDiagnosticsCoordinator( private val commands = Channel(Channel.UNLIMITED) private val mutableState = MutableStateFlow(DiagnosticsUiState()) private val actorScope = CoroutineScope(scope.coroutineContext + actorDispatcher) + private val currentPurgeScope = AtomicReference(null) + private val liveEvidenceCleanupPending = AtomicBoolean(false) + private val hostedDeletionDrainRunning = AtomicBoolean(false) + private val hostedDeletionDrainRequested = AtomicBoolean(false) private var currentContext: DiagnosticsCaptureContext? = null private var activeCapture: ActiveDiagnosticsCapture? = null @@ -100,10 +132,74 @@ class DefaultDiagnosticsCoordinator( if (!started.compareAndSet(false, true)) return capture.closeGate() runtimePublisher.closeGate() - identityTransitions.installGate { - capture.closeGate() - runtimePublisher.closeGate() - commands.trySend(Command.IdentityWillChange(it.kind)) + identityTransitions.installGate { transition -> + // IdentityTransitionBarrier owns the outer lock. Keeping privacy + // revocation inside it establishes one global order: identity, then + // diagnostics transport. Uploaders use the same order. + privacyBarrier.withRevocation { + val mirroredScope = currentPurgeScope.get() + val purgeScope = mirroredScope ?: settings.cachedContext()?.toPurgeScope() + if (transition.affectsCurrentIdentity) { + capture.closeGate() + runtimePublisher.closeGate() + capture.purgeCurrentEvidence() + liveEvidenceCleanupPending.set(false) + } + when (transition.kind) { + IdentityTransitionKind.SIGN_OUT -> { + if (transition.purgesPersistentIdentity) { + val targetServerId = transition.targetServerId + when { + targetServerId != null -> settings.purgeLocalServer( + localServerId = targetServerId, + fallbackBinding = purgeScope + ?.takeIf { it.localServerId == targetServerId } + ?.binding, + allowLegacyAllEvidenceFallback = false, + ) + purgeScope != null -> settings.purgeBinding( + binding = purgeScope.binding, + includeLiveCapture = false, + ) + else -> settings.clearCachedContext() + } + } + } + IdentityTransitionKind.SERVER_REMOVE -> { + val targetServerId = requireNotNull(transition.targetServerId) { + "${transition.kind} requires a target server id" + } + settings.purgeLocalServer( + localServerId = targetServerId, + fallbackBinding = purgeScope + ?.takeIf { it.localServerId == targetServerId } + ?.binding, + ) + } + IdentityTransitionKind.ACCOUNT_REPLACE -> { + val targetServerId = requireNotNull(transition.targetServerId) { + "${transition.kind} requires a target server id" + } + settings.purgeLocalServer( + localServerId = targetServerId, + fallbackBinding = purgeScope + ?.takeIf { it.localServerId == targetServerId } + ?.binding, + allowLegacyAllEvidenceFallback = false, + ) + } + else -> Unit + } + if (transition.affectsCurrentIdentity) currentPurgeScope.set(null) + commands.trySend( + Command.IdentityWillChange( + kind = transition.kind, + previousBinding = purgeScope?.binding, + affectsCurrentIdentity = transition.affectsCurrentIdentity, + purgesPersistentIdentity = transition.purgesPersistentIdentity, + ), + ) + } } actorScope.launch { for (command in commands) handle(command) @@ -123,6 +219,9 @@ class DefaultDiagnosticsCoordinator( override suspend fun setConsent(mode: DiagnosticsConsentMode, expectedNoticeVersion: Int?) = request { Command.SetConsent(mode, expectedNoticeVersion, it) } + override suspend fun setDestination(destinationKind: DiagnosticsDestinationKind) = + request { Command.SetDestination(destinationKind, it) } + override suspend fun setDebugLogging(enabled: Boolean) = request { Command.SetDebugLogging(enabled, it) } @@ -137,13 +236,16 @@ class DefaultDiagnosticsCoordinator( override suspend fun upload(reportId: String, expectedNoticeVersion: Int?): DiagnosticsUploadDecision = requestResult { Command.Upload(reportId, expectedNoticeVersion, it) } - override suspend fun delete(reportId: String) = request { Command.Delete(reportId, it) } + override suspend fun uploadAutomatically(reportId: String): DiagnosticsUploadDecision = + requestResult { Command.UploadAutomatically(reportId, it) } + + override suspend fun delete(reportId: String): Boolean = requestResult { Command.Delete(reportId, it) } override suspend fun decline(reportId: String) = request { Command.Decline(reportId, it) } private suspend fun handle(command: Command) { when (command) { - is Command.IdentityWillChange -> identityWillChangeOwned(command.kind) + is Command.IdentityWillChange -> identityWillChangeOwned(command) Command.IdentityDidChange -> { currentContext = null refreshOwnedState() @@ -152,6 +254,7 @@ class DefaultDiagnosticsCoordinator( is Command.SetConsent -> complete(command.completion) { setConsentOwned(command.mode, command.expectedNoticeVersion) } + is Command.SetDestination -> complete(command.completion) { setDestinationOwned(command.destinationKind) } is Command.SetDebugLogging -> complete(command.completion) { setDebugLoggingOwned(command.enabled) } is Command.CaptureNow -> completeResult(command.completion) { captureNowOwned() } is Command.StartTimedCapture -> complete(command.completion) { startTimedCaptureOwned() } @@ -160,13 +263,59 @@ class DefaultDiagnosticsCoordinator( is Command.Upload -> completeResult(command.completion) { uploadOwned(command.reportId, command.expectedNoticeVersion) } - is Command.Delete -> complete(command.completion) { deleteOwned(command.reportId) } + is Command.UploadAutomatically -> completeResult(command.completion) { + uploadAutomaticallyOwned(command.reportId) + } + is Command.Delete -> completeResult(command.completion) { deleteOwned(command.reportId) } is Command.Decline -> complete(command.completion) { declineOwned(command.reportId) } } } private suspend fun refreshOwnedState() { + try { + storedEvidenceReconciler.reconcile() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + val selectedDestination = runCatching { settings.destinationKind() } + .getOrDefault(mutableState.value.destinationKind) + failClosedUnresolvedRefresh(selectedDestination) + return + } + scheduleHostedDeletionDrain() + val selectedDestination = runCatching { settings.destinationKind() } + .getOrDefault(DiagnosticsDestinationKind.HOSTED) val resolved = runCatching { identity.resolve(requirePersistentCapture = true) }.getOrNull() + try { + capture.reconcileStoredEvidence() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + capture.closeGate() + runtimePublisher.closeGate() + currentContext = null + val active = activeCapture + activeCapture = null + runCatching { if (active != null) capture.cancel(active) } + runCatching { capture.setDebugLogging(null, false) } + runCatching { capture.setPersistentBreadcrumbs(null, false) } + val purgeFailure = runCatching { capture.purgeCurrentEvidence() }.exceptionOrNull() + liveEvidenceCleanupPending.set(purgeFailure != null) + mutableState.value = mutableState.value.copy( + availability = DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, + profileEligible = resolved?.profileEligible == true, + consent = DiagnosticsConsentMode.ASK, + debugLogging = false, + pending = emptyList(), + prompt = null, + timedCapture = TimedCaptureState(), + sentHistory = emptyList(), + destinationKind = selectedDestination, + allowsAutomaticUpload = selectedDestination.allowsAutomaticUpload, + retentionDays = resolved?.retentionDays ?: mutableState.value.retentionDays, + ) + return + } val previous = currentContext if ( activeCapture != null && @@ -175,13 +324,30 @@ class DefaultDiagnosticsCoordinator( invalidateActiveCapture() } if (previous != null && resolved?.identityKey != previous.identityKey) capture.closeGate() - currentContext = resolved if (resolved == null) { + currentContext = null runCatching { capture.setDebugLogging(null, false) } runCatching { capture.setPersistentBreadcrumbs(null, false) } runtimePublisher.closeGate() val cached = trustedCachedContext() + try { + settings.retryPendingErasures(cached?.binding) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + mutableState.value = mutableState.value.copy( + availability = DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, + profileEligible = false, + consent = DiagnosticsConsentMode.NEVER, + debugLogging = false, + pending = emptyList(), + prompt = null, + sentHistory = emptyList(), + ) + return + } + if (cached != null) currentPurgeScope.set(cached.toPurgeScope()) val cachedReports = cached?.let { context -> runCatching { reports.list(context.binding) }.getOrDefault(emptyList()) }.orEmpty() @@ -199,10 +365,25 @@ class DefaultDiagnosticsCoordinator( sentHistory = cached?.let { context -> runCatching { settings.sentHistory(context.binding) }.getOrDefault(emptyList()) }.orEmpty(), + destinationKind = selectedDestination, + allowsAutomaticUpload = selectedDestination.allowsAutomaticUpload, + retentionDays = cached?.retentionDays ?: selectedDestination.defaultRetentionDays, ) return } + try { + identityTransitions.withCurrentGeneration(resolved.ownershipGeneration) { + settings.retryPendingErasures(resolved.binding) + Unit + } ?: return + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + failClosedEligibleRefresh(resolved, selectedDestination) + return + } if (!resolved.profileEligible) { + currentContext = resolved runCatching { capture.setDebugLogging(null, false) } runCatching { capture.setPersistentBreadcrumbs(null, false) } runtimePublisher.closeGate() @@ -215,41 +396,109 @@ class DefaultDiagnosticsCoordinator( pending = emptyList(), prompt = null, sentHistory = emptyList(), + destinationKind = selectedDestination, + allowsAutomaticUpload = selectedDestination.allowsAutomaticUpload, + retentionDays = resolved.retentionDays, ) return } - runCatching { settings.cacheContext(resolved) } - val consent = runCatching { settings.consent(resolved.binding, resolved.noticeVersion) } - .getOrElse { DiagnosticsConsentRecord(DiagnosticsConsentMode.ASK, resolved.noticeVersion) } - if (consent.mode == DiagnosticsConsentMode.NEVER) { - runtimePublisher.closeGate() + val liveCaptureContext = if (resolved.destinationKind == DiagnosticsDestinationKind.HOSTED) { + runCatching { identity.resolveForCapture(requirePersistentCapture = true) } + .getOrNull() + ?.takeIf { live -> + live.profileEligible && + live.status == DiagnosticsAvailabilityStatus.AVAILABLE && + live.identityKey == resolved.identityKey && + live.destinationKind == resolved.destinationKind && + live.ownershipGeneration == resolved.ownershipGeneration + } } else { - runCatching { runtimePublisher.publish(resolved) } - runCatching { incidentCollector.collect(resolved, consent.mode) } + resolved } - val debugLogging = runCatching { settings.debugLogging() }.getOrDefault(false) - val pendingReports = runCatching { reports.list(resolved.binding) }.getOrDefault(emptyList()) + + val guardedRefresh = try { + identityTransitions.withCurrentGeneration(resolved.ownershipGeneration) { + if (liveEvidenceCleanupPending.get()) { + capture.closeGate() + runtimePublisher.closeGate() + capture.purgeCurrentEvidence() + liveEvidenceCleanupPending.set(false) + } + // The local-server -> binding index is the durable erasure authority for an + // inactive server. Do not create or re-enable any identity-scoped evidence + // until that index and the matching cached context are committed atomically. + settings.cacheContext(resolved) + currentPurgeScope.set(resolved.toPurgeScope()) + + val consent = settings.consent(resolved.binding, resolved.noticeVersion) + val debugLogging = runCatching { settings.debugLogging() }.getOrDefault(false) + if (consent.mode == DiagnosticsConsentMode.NEVER) { + runtimePublisher.closeGate() + capture.setDebugLogging(null, false) + capture.setPersistentBreadcrumbs(null, false) + } else if (liveCaptureContext != null) { + // The generation mutex covers every commit that can publish a crash + // snapshot or persist identity-owned incident/capture evidence. An + // identity mutation either waits and purges this work, or wins first and + // prevents this block from running. + runtimePublisher.publish(liveCaptureContext) + incidentCollector.collect(liveCaptureContext, consent.mode) + capture.setDebugLogging( + liveCaptureContext, + debugLogging && activeCapture == null, + ) + capture.setPersistentBreadcrumbs(liveCaptureContext, true) + } else { + runtimePublisher.closeGate() + capture.setDebugLogging(null, false) + capture.setPersistentBreadcrumbs(null, false) + } + + // A store cleanup/enumeration failure is a privacy boundary, + // not an empty report list. Let the outer fail-closed path keep + // every evidence gate shut until strict cleanup can succeed. + val pendingReports = reports.list(resolved.binding) + reports.hostedReadyReports() + .filter { receipt -> receipt.binding == resolved.binding } + .forEach { receipt -> + settings.recordSent( + binding = receipt.binding, + shortId = receipt.shortId, + sentAtEpochMs = receipt.readyAtEpochMs, + state = HostedDiagnosticsReportState.READY.wireValue, + ) + } + val history = runCatching { settings.sentHistory(resolved.binding) }.getOrDefault(emptyList()) + currentContext = resolved + EligibleRefresh( + consent = consent, + debugLogging = debugLogging, + pendingReports = pendingReports, + history = history, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + failClosedEligibleRefresh(resolved, selectedDestination) + return + } ?: return + + val consent = guardedRefresh.consent + val debugLogging = guardedRefresh.debugLogging + val pendingReports = guardedRefresh.pendingReports val summaries = pendingReports.map { report -> report.summary(resolved.retentionDays) } val promptReports = pendingReports.filter { report -> consent.mode == DiagnosticsConsentMode.ASK && + report.state.status != PendingReportStatus.PROCESSING && !runCatching { reports.isThrottled(promptThrottleKey(report), PROMPT_THROTTLE_MS) }.getOrDefault(true) } - val history = runCatching { settings.sentHistory(resolved.binding) }.getOrDefault(emptyList()) - runCatching { - capture.setDebugLogging( - resolved, - debugLogging && consent.mode != DiagnosticsConsentMode.NEVER && activeCapture == null, - ) - } - runCatching { - capture.setPersistentBreadcrumbs(resolved, consent.mode != DiagnosticsConsentMode.NEVER) - } mutableState.value = mutableState.value.copy( availability = resolved.status.toUiAvailability(), - profileEligible = true, + profileEligible = resolved.profileEligible, consent = consent.mode, debugLogging = debugLogging, pending = summaries, @@ -262,58 +511,152 @@ class DefaultDiagnosticsCoordinator( noticeVersion = resolved.noticeVersion, ) }, - sentHistory = history, + sentHistory = guardedRefresh.history, + destinationKind = resolved.destinationKind, + allowsAutomaticUpload = resolved.destinationKind.allowsAutomaticUpload, + retentionDays = resolved.retentionDays, ) - if (consent.mode == DiagnosticsConsentMode.ALWAYS && resolved.status == DiagnosticsAvailabilityStatus.AVAILABLE) { + if ( + resolved.destinationKind.allowsAutomaticUpload && + consent.mode == DiagnosticsConsentMode.ALWAYS && + resolved.status == DiagnosticsAvailabilityStatus.AVAILABLE + ) { pendingReports.forEach { report -> uploadScheduler.enqueue(report.id) } } } + private suspend fun failClosedEligibleRefresh( + resolved: DiagnosticsCaptureContext, + selectedDestination: DiagnosticsDestinationKind, + ) { + capture.closeGate() + runtimePublisher.closeGate() + currentContext = null + val active = activeCapture + activeCapture = null + runCatching { if (active != null) capture.cancel(active) } + runCatching { capture.setDebugLogging(null, false) } + runCatching { capture.setPersistentBreadcrumbs(null, false) } + val purgeFailure = runCatching { capture.purgeCurrentEvidence() }.exceptionOrNull() + liveEvidenceCleanupPending.set(purgeFailure != null) + mutableState.value = mutableState.value.copy( + availability = DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, + profileEligible = true, + consent = DiagnosticsConsentMode.ASK, + debugLogging = false, + pending = emptyList(), + prompt = null, + timedCapture = TimedCaptureState(), + sentHistory = emptyList(), + destinationKind = selectedDestination, + allowsAutomaticUpload = selectedDestination.allowsAutomaticUpload, + retentionDays = resolved.retentionDays, + ) + } + + private suspend fun failClosedUnresolvedRefresh(selectedDestination: DiagnosticsDestinationKind) { + capture.closeGate() + runtimePublisher.closeGate() + currentContext = null + val active = activeCapture + activeCapture = null + runCatching { if (active != null) capture.cancel(active) } + runCatching { capture.setDebugLogging(null, false) } + runCatching { capture.setPersistentBreadcrumbs(null, false) } + val purgeFailure = runCatching { capture.purgeCurrentEvidence() }.exceptionOrNull() + liveEvidenceCleanupPending.set(purgeFailure != null) + mutableState.value = mutableState.value.copy( + availability = DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, + profileEligible = false, + consent = DiagnosticsConsentMode.ASK, + debugLogging = false, + pending = emptyList(), + prompt = null, + timedCapture = TimedCaptureState(), + sentHistory = emptyList(), + destinationKind = selectedDestination, + allowsAutomaticUpload = selectedDestination.allowsAutomaticUpload, + retentionDays = selectedDestination.defaultRetentionDays, + ) + } + private suspend fun setConsentOwned( mode: DiagnosticsConsentMode, expectedNoticeVersion: Int?, ) { if (expectedNoticeVersion != null) refreshOwnedState() val context = currentEligibleContext() ?: return + if (mode == DiagnosticsConsentMode.ALWAYS && !context.destinationKind.allowsAutomaticUpload) return if (expectedNoticeVersion != null && context.noticeVersion != expectedNoticeVersion) return - if (mode == DiagnosticsConsentMode.NEVER) { + val committed = identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + privacyBarrier.withRevocation { + if (mode == DiagnosticsConsentMode.NEVER) { + capture.closeGate() + runtimePublisher.closeGate() + val active = activeCapture + activeCapture = null + if (active != null) runCatching { capture.cancel(active) } + mutableState.value = mutableState.value.copy(timedCapture = TimedCaptureState()) + } + settings.setConsent(context.binding, mode, context.noticeVersion) + } + } + if (committed == null) return + refreshOwnedState() + } + + private suspend fun setDestinationOwned(destinationKind: DiagnosticsDestinationKind) { + if (settings.destinationKind() == destinationKind) return + privacyBarrier.withRevocation { capture.closeGate() + runtimePublisher.closeGate() val active = activeCapture activeCapture = null if (active != null) runCatching { capture.cancel(active) } + runCatching { settings.clearCachedContext() } + settings.setDestinationKind(destinationKind) + currentContext = null mutableState.value = mutableState.value.copy(timedCapture = TimedCaptureState()) } - settings.setConsent(context.binding, mode, context.noticeVersion) refreshOwnedState() } private suspend fun setDebugLoggingOwned(enabled: Boolean) { val context = currentEligibleContext() ?: return val allowed = enabled && mutableState.value.consent != DiagnosticsConsentMode.NEVER - settings.setDebugLogging(allowed) - if (activeCapture == null) capture.setDebugLogging(context, allowed) - mutableState.value = mutableState.value.copy(debugLogging = allowed) + identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + settings.setDebugLogging(allowed) + if (activeCapture == null) capture.setDebugLogging(context, allowed) + mutableState.value = mutableState.value.copy(debugLogging = allowed) + Unit + } } private suspend fun captureNowOwned(): String? { - val context = currentEligibleContext() ?: return null - val report = runCatching { capture.captureNow(context) }.getOrNull() ?: return null + val context = liveCaptureContext() ?: return null + val captured = identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + GuardedValue(runCatching { capture.captureNow(context) }.getOrNull()) + } ?: return null + val report = captured.value ?: return null refreshOwnedState() return report.id } private suspend fun startTimedCaptureOwned() { - val context = currentEligibleContext() ?: return - activeCapture?.let { previous -> runCatching { capture.cancel(previous) } } - val active = runCatching { capture.start(context) }.getOrNull() ?: return - activeCapture = active - mutableState.value = mutableState.value.copy( - timedCapture = TimedCaptureState( - status = TimedCaptureStatus.ACTIVE, - generation = active.generation, - startedAtEpochMs = active.startedAtEpochMs, - ), - ) + val context = liveCaptureContext() ?: return + identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + activeCapture?.let { previous -> runCatching { capture.cancel(previous) } } + val active = runCatching { capture.start(context) }.getOrNull() ?: return@withCurrentGeneration Unit + activeCapture = active + mutableState.value = mutableState.value.copy( + timedCapture = TimedCaptureState( + status = TimedCaptureStatus.ACTIVE, + generation = active.generation, + startedAtEpochMs = active.startedAtEpochMs, + ), + ) + Unit + } } private suspend fun stopTimedCaptureOwned(): String? { @@ -323,11 +666,14 @@ class DefaultDiagnosticsCoordinator( invalidateActiveCapture() return null } - activeCapture = null - val report = runCatching { capture.stop(active, context) }.getOrNull() - mutableState.value = mutableState.value.copy(timedCapture = TimedCaptureState()) + val stopped = identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + activeCapture = null + val report = runCatching { capture.stop(active, context) }.getOrNull() + mutableState.value = mutableState.value.copy(timedCapture = TimedCaptureState()) + GuardedValue(report) + } ?: return null refreshOwnedState() - return report?.id + return stopped.value?.id } private suspend fun cancelTimedCaptureOwned(invalidated: Boolean) { @@ -337,7 +683,10 @@ class DefaultDiagnosticsCoordinator( if (!invalidated) { val context = currentEligibleContext() if (context != null && mutableState.value.debugLogging) { - runCatching { capture.setDebugLogging(context, true) } + identityTransitions.withCurrentGeneration(context.ownershipGeneration) { + runCatching { capture.setDebugLogging(context, true) } + Unit + } } } mutableState.value = mutableState.value.copy( @@ -352,18 +701,30 @@ class DefaultDiagnosticsCoordinator( cancelTimedCaptureOwned(invalidated = true) } - private suspend fun identityWillChangeOwned(kind: IdentityTransitionKind) { - val previousBinding = currentContext?.binding - ?: runCatching { settings.cachedContext()?.binding }.getOrNull() + private suspend fun identityWillChangeOwned(command: Command.IdentityWillChange) { + if (!command.affectsCurrentIdentity) return + capture.closeGate() + runtimePublisher.closeGate() invalidateActiveCapture() - runCatching { settings.clearCachedContext(previousBinding) } if ( - previousBinding != null && - kind in setOf(IdentityTransitionKind.SIGN_OUT, IdentityTransitionKind.SERVER_REMOVE) + command.kind in DESTRUCTIVE_IDENTITY_TRANSITIONS && + command.purgesPersistentIdentity && + command.previousBinding != null ) { - runCatching { settings.purgeBinding(previousBinding) } - } else if (kind in setOf(IdentityTransitionKind.SIGN_OUT, IdentityTransitionKind.SERVER_REMOVE)) { - runCatching { settings.clearCachedContext() } + // The synchronous transition gate already removed evidence and + // settings before identity mutation. Repeat the metadata half now + // that any actor-owned upload has settled, closing the narrow + // response-after-purge window without re-running live capture + // deletion or risking a gate/actor lock inversion. + runCatching { settings.scrubBindingMetadata(command.previousBinding) } + } + // The inline gate is authoritative. Repeat the live-evidence purge after + // actor convergence so a stale queued command can never reopen a capture. + liveEvidenceCleanupPending.set( + runCatching { capture.purgeCurrentEvidence() }.isFailure, + ) + if (command.kind !in DESTRUCTIVE_IDENTITY_TRANSITIONS) { + runCatching { settings.clearCachedContext(command.previousBinding) } } } @@ -388,20 +749,110 @@ class DefaultDiagnosticsCoordinator( } else { uploader.upload(reportId, expectedNoticeVersion) } + if (decision is DiagnosticsUploadDecision.HostedProcessing) { + uploadScheduler.enqueue(reportId) + } refreshOwnedState() return decision } - private suspend fun deleteOwned(reportId: String) { - val report = reports.load(reportId) ?: return - val liveBinding = currentEligibleContext()?.binding - val cachedBinding = if (liveBinding == null) { - trustedCachedContext()?.binding + private suspend fun uploadAutomaticallyOwned(reportId: String): DiagnosticsUploadDecision { + val report = reports.load(reportId) ?: return DiagnosticsUploadDecision.KeptInvalid + val context = currentEligibleContext() ?: return DiagnosticsUploadDecision.KeptUnavailable + val selectedDestination = settings.destinationKind() + if ( + selectedDestination != report.binding.destinationKind || + !report.binding.matches(context) || + report.binding.destinationKind != context.destinationKind + ) { + return DiagnosticsUploadDecision.KeptIdentityChanged + } + val hostedStatusPoll = report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED && + report.state.hostedRemoteShortId != null + val consent = settings.consent(context.binding, context.noticeVersion).mode + if (!hostedStatusPoll && consent != DiagnosticsConsentMode.ALWAYS) { + return DiagnosticsUploadDecision.KeptConsentReviewRequired + } + val decision = if (hostedStatusPoll) { + uploader.upload(reportId) } else { - null + uploader.uploadAutomatically(reportId) } - if (report.binding.binding == (liveBinding ?: cachedBinding)) reports.delete(reportId) refreshOwnedState() + return decision + } + + private suspend fun deleteOwned(reportId: String): Boolean { + val deleted = privacyBarrier.withRevocation { + val report = reports.load(reportId) + val deletionBinding = report?.binding?.binding ?: reports.hostedReadyBinding(reportId) ?: return@withRevocation true + val liveBinding = currentEligibleContext()?.binding + val cachedBinding = if (liveBinding == null) { + trustedCachedContext()?.binding + } else { + null + } + if (deletionBinding != (liveBinding ?: cachedBinding)) return@withRevocation false + try { + reports.stageHostedDeletionAndDelete(reportId) + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // Intent-covered evidence is deliberately hidden from load(), even + // when physical cleanup failed. Never report UI deletion success + // from that absence; the durable intent remains retryable. + false + } + } + if (!deleted) return false + hostedDeletionScheduler.enqueue() + refreshOwnedState() + return true + } + + private suspend fun drainHostedDeletionIntents(): Boolean { + var completedAll = true + reports.hostedDeletionIntents().forEach { reportId -> + val completed = try { + hostedReportDeleter.delete(reportId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + false + } + if (completed) { + try { + reports.completeHostedDeletion(reportId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + completedAll = false + } + } else { + completedAll = false + } + } + return completedAll + } + + private fun scheduleHostedDeletionDrain() { + hostedDeletionDrainRequested.set(true) + if (!hostedDeletionDrainRunning.compareAndSet(false, true)) return + actorScope.launch { + try { + while (hostedDeletionDrainRequested.getAndSet(false)) { + if (!drainHostedDeletionIntents()) hostedDeletionScheduler.enqueue() + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + // The durable intent remains available for the next refresh. + } finally { + hostedDeletionDrainRunning.set(false) + if (hostedDeletionDrainRequested.get()) scheduleHostedDeletionDrain() + } + } } private suspend fun declineOwned(reportId: String) { @@ -420,6 +871,18 @@ class DefaultDiagnosticsCoordinator( private fun currentEligibleContext(): DiagnosticsCaptureContext? = currentContext?.takeIf(DiagnosticsCaptureContext::profileEligible) + private suspend fun liveCaptureContext(): DiagnosticsCaptureContext? { + val current = currentEligibleContext() ?: return null + val live = runCatching { identity.resolveForCapture(requirePersistentCapture = true) }.getOrNull() + ?.takeIf { it.profileEligible && it.status == DiagnosticsAvailabilityStatus.AVAILABLE } + ?: return null + if (live.identityKey != current.identityKey || live.destinationKind != current.destinationKind) { + refreshOwnedState() + return null + } + return live + } + private suspend fun trustedCachedContext(): CachedDiagnosticsContext? = runCatching { settings.cachedContext() }.getOrNull()?.takeIf { cached -> runCatching { identity.matchesCachedIdentity(cached) }.getOrDefault(false) @@ -445,7 +908,12 @@ class DefaultDiagnosticsCoordinator( } private sealed interface Command { - data class IdentityWillChange(val kind: IdentityTransitionKind) : Command + data class IdentityWillChange( + val kind: IdentityTransitionKind, + val previousBinding: DiagnosticsBinding?, + val affectsCurrentIdentity: Boolean, + val purgesPersistentIdentity: Boolean, + ) : Command data object IdentityDidChange : Command data class Refresh(val completion: CompletableDeferred? = null) : Command data class SetConsent( @@ -453,6 +921,10 @@ class DefaultDiagnosticsCoordinator( val expectedNoticeVersion: Int?, val completion: CompletableDeferred, ) : Command + data class SetDestination( + val destinationKind: DiagnosticsDestinationKind, + val completion: CompletableDeferred, + ) : Command data class SetDebugLogging(val enabled: Boolean, val completion: CompletableDeferred) : Command data class CaptureNow(val completion: CompletableDeferred) : Command data class StartTimedCapture(val completion: CompletableDeferred) : Command @@ -463,7 +935,11 @@ class DefaultDiagnosticsCoordinator( val expectedNoticeVersion: Int?, val completion: CompletableDeferred, ) : Command - data class Delete(val reportId: String, val completion: CompletableDeferred) : Command + data class UploadAutomatically( + val reportId: String, + val completion: CompletableDeferred, + ) : Command + data class Delete(val reportId: String, val completion: CompletableDeferred) : Command data class Decline(val reportId: String, val completion: CompletableDeferred) : Command } @@ -477,27 +953,53 @@ class DefaultDiagnosticsCoordinator( private companion object { const val PROMPT_THROTTLE_MS = 24 * 60 * 60 * 1_000L + val DESTRUCTIVE_IDENTITY_TRANSITIONS = setOf( + IdentityTransitionKind.ACCOUNT_REPLACE, + IdentityTransitionKind.SIGN_OUT, + IdentityTransitionKind.SERVER_REMOVE, + ) } } +private data class DiagnosticsPurgeScope( + val localServerId: String?, + val binding: DiagnosticsBinding, +) + +private data class EligibleRefresh( + val consent: DiagnosticsConsentRecord, + val debugLogging: Boolean, + val pendingReports: List, + val history: List, +) + +private data class GuardedValue(val value: T) + +private fun DiagnosticsCaptureContext.toPurgeScope() = DiagnosticsPurgeScope(localServerId, binding) + +private fun CachedDiagnosticsContext.toPurgeScope() = DiagnosticsPurgeScope(localServerId, binding) + private fun DiagnosticsAvailabilityStatus.toUiAvailability(): DiagnosticsAvailabilityUi = when (this) { DiagnosticsAvailabilityStatus.AVAILABLE -> DiagnosticsAvailabilityUi.AVAILABLE DiagnosticsAvailabilityStatus.DISABLED -> DiagnosticsAvailabilityUi.DISABLED DiagnosticsAvailabilityStatus.STORAGE_UNAVAILABLE -> DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE } -private fun PendingReport.summary(retentionDays: Int): DiagnosticsReportSummary = DiagnosticsReportSummary( +private fun PendingReport.summary(@Suppress("UNUSED_PARAMETER") retentionDays: Int): DiagnosticsReportSummary = DiagnosticsReportSummary( id = id, type = manifest.report.type, capturedAt = manifest.report.capturedAt, capturedAtEpochMs = state.capturedAtEpochMs, - expiresAtEpochMs = state.capturedAtEpochMs + retentionDays.coerceAtLeast(1) * MILLIS_PER_DAY, + // This is local pending-evidence expiry, not the collector's post-upload + // retention policy shown in settings. + expiresAtEpochMs = state.capturedAtEpochMs + PENDING_DIAGNOSTICS_RETENTION_DAYS * MILLIS_PER_DAY, evidenceBytes = directory.walkTopDown().filter(File::isFile).sumOf(File::length), destinationServerInstanceId = manifest.destination.serverInstanceId, capturedProfileId = binding.profileId, archiveEntries = manifest.archive.entries, uploadStatus = state.status, uploadErrorCode = state.errorCode, + destinationKind = binding.destinationKind, ) private fun promptThrottleKey(report: PendingReport): String = "prompt:${report.state.fingerprint}" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileDurability.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileDurability.kt new file mode 100644 index 000000000..b6ff7f939 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileDurability.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.common.diagnostics + +import android.system.Os +import android.system.OsConstants +import java.io.File +import java.io.FileDescriptor + +internal fun syncDiagnosticsDirectory(directory: File) { + var descriptor: FileDescriptor? = null + try { + descriptor = Os.open(directory.absolutePath, OsConstants.O_RDONLY, 0) + Os.fsync(checkNotNull(descriptor)) + } finally { + descriptor?.let(Os::close) + } +} + +internal fun renameDiagnosticsFileAtomically(source: File, target: File) { + // Both paths live in the same diagnostics state directory. Os.rename replaces an existing + // target atomically; if it fails, propagate the failure and leave both the prior target and + // the synced temporary file intact. Deleting the target as a fallback could lose the only + // persisted hosted erasure authority at a crash boundary. + Os.rename(source.absolutePath, target.absolutePath) + check(!source.exists() && target.exists()) { "atomic publish did not complete for ${target.name}" } +} + +internal fun deleteDiagnosticsEvidenceStrictly( + target: File, + deleteRecursively: (File) -> Boolean, + directorySync: (File) -> Unit, +) { + if (!target.exists()) return + check(deleteRecursively(target)) { "unable to delete diagnostics evidence ${target.name}" } + check(!target.exists()) { "diagnostics evidence still exists after deletion: ${target.name}" } + target.parentFile?.let(directorySync) + check(!target.exists()) { "diagnostics evidence deletion was not durable: ${target.name}" } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLogger.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLogger.kt index b5ffd7c67..4b14b26a2 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLogger.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLogger.kt @@ -34,6 +34,9 @@ class DiagnosticsFileLogger( private val channelCapacity: Int = DEFAULT_CHANNEL_CAPACITY, private val maxSegments: Int = DEFAULT_MAX_SEGMENTS, private val maxSegmentBytes: Int = DEFAULT_MAX_SEGMENT_BYTES, + private val deleteRecursively: (File) -> Boolean = File::deleteRecursively, + private val listFiles: (File) -> Array? = File::listFiles, + private val directorySync: (File) -> Unit = ::syncDiagnosticsDirectory, ) : DiagnosticsLogSink { private val root = noBackupFilesDir.resolve("client-diagnostics/logs") private val scope = CoroutineScope(SupervisorJob() + writerDispatcher) @@ -43,6 +46,9 @@ class DiagnosticsFileLogger( require(channelCapacity > 0) { "channelCapacity must be positive" } require(maxSegments > 0) { "maxSegments must be positive" } require(maxSegmentBytes > 0) { "maxSegmentBytes must be positive" } + // Construction stays fail-contained so the coordinator can install its identity gate. + // Its first refresh and every new capture retry this cleanup strictly. + runCatching { reconcileStoredEvidence() } } val isActive: Boolean @@ -51,8 +57,8 @@ class DiagnosticsFileLogger( fun start(generation: Long) { require(generation >= 0) { "generation must be non-negative" } check(active.get() == null) { "diagnostics file capture is already active" } + reconcileStoredEvidence() val directory = root.resolve("generation-$generation") - if (directory.exists()) directory.deleteRecursively() check(directory.mkdirs() || directory.isDirectory) { "unable to create diagnostics log directory" } val dropped = AtomicLong(0) @@ -113,12 +119,37 @@ class DiagnosticsFileLogger( val capture = detach(expectedGeneration) capture.channel.close() capture.writer.cancelAndJoin() - capture.directory.deleteRecursively() + deleteDiagnosticsEvidenceStrictly(capture.directory, deleteRecursively, directorySync) } suspend fun purgeStoredEvidence() { - active.get()?.let { capture -> runCatching { cancel(capture.generation) } } - if (root.exists()) check(root.deleteRecursively()) { "unable to purge diagnostics logs" } + active.get()?.let { capture -> cancel(capture.generation) } + deleteDiagnosticsEvidenceStrictly(root, deleteRecursively, directorySync) + } + + /** Strictly removes generations that no active writer owns, including crash leftovers. */ + fun reconcileStoredEvidence() { + if (!root.exists()) return + check(root.isDirectory) { "diagnostics log root is not a directory" } + val activeDirectory = active.get()?.directory?.canonicalFile + val entries = checkNotNull(listFiles(root)) { "unable to enumerate diagnostics log root" } + entries.forEach { entry -> + if (activeDirectory == null || entry.canonicalFile != activeDirectory) { + deleteDiagnosticsEvidenceStrictly(entry, deleteRecursively, directorySync) + } + } + if (activeDirectory == null) { + deleteDiagnosticsEvidenceStrictly(root, deleteRecursively, directorySync) + } + } + + /** Removes the detached generation after its bounded bytes have been published. */ + fun deleteFrozen(frozen: FrozenDiagnosticsLogs) { + val directory = root.resolve("generation-${frozen.generation}") + require( + frozen.files.all { file -> file.parentFile?.canonicalFile == directory.canonicalFile }, + ) { "frozen diagnostics files do not belong to generation ${frozen.generation}" } + deleteDiagnosticsEvidenceStrictly(directory, deleteRecursively, directorySync) } private fun detach(expectedGeneration: Long): ActiveCapture { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsIdentityResolver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsIdentityResolver.kt index 6b799ff6f..a9c0a0c3c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsIdentityResolver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsIdentityResolver.kt @@ -83,6 +83,9 @@ data class DiagnosticsCaptureContext( val retentionDays: Int = 7, val localServerId: String? = null, val credentialFingerprint: String? = null, + /** Source profile is retained only in encrypted/local state for privacy gating. */ + val sourceProfileId: String? = profileId, + val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, ) { val identityKey: DiagnosticsIdentityKey = DiagnosticsIdentityKey( binding = binding, @@ -94,6 +97,14 @@ data class DiagnosticsCaptureContext( interface DiagnosticsIdentityResolver { suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? + /** Live attestation used immediately before starting a user-requested capture. */ + suspend fun resolveForCapture(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + resolve(requirePersistentCapture) + + /** Live account attestation used immediately before starting a transport. */ + suspend fun resolveForUpload(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + resolve(requirePersistentCapture) + /** Local-only attestation used before exposing a cached context while the server is offline. */ suspend fun matchesCachedIdentity(cached: CachedDiagnosticsContext): Boolean = false } @@ -159,6 +170,7 @@ class DefaultDiagnosticsIdentityResolver( !child } + val credentialFingerprint = currentCredentialFingerprint() if (identityTransitions.generation.value != generation) continue val context = DiagnosticsCaptureContext( binding = DiagnosticsBinding(status.serverInstanceId, accountUserId), @@ -172,7 +184,7 @@ class DefaultDiagnosticsIdentityResolver( maxManifestBytes = status.maxManifestBytes, retentionDays = status.retentionDays, localServerId = server.id, - credentialFingerprint = currentCredentialFingerprint(), + credentialFingerprint = credentialFingerprint, ) return context } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentation.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentation.kt index bbc224fd8..e6e850095 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentation.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentation.kt @@ -72,13 +72,21 @@ internal fun safeDiagnosticsNetworkPath(rawPath: String): String { if ('?' in rawPath || '#' in rawPath) return "/api/${segments[1]}/other" val resource = segments[2].lowercase() val tail = segments.drop(3) - val template = API_ROUTE_TEMPLATES[resource] + val known = API_ROUTE_TEMPLATES[resource] + val template = known ?.firstOrNull { candidate -> candidate.size == tail.size && candidate.indices.all { index -> candidate[index] == DYNAMIC_ROUTE_SEGMENT || candidate[index] == tail[index].lowercase() } } - ?: return "/api/${segments[1]}/other" + // An allowlisted resource whose tail matches no template still names the + // resource: it already appears in every other path logged for it, so + // nothing new is disclosed, and a bare "/other" made the largest error + // signal on a tester's device unactionable — 52 404s in eight minutes + // with no way to tell which endpoint produced them. An UNRECOGNISED + // resource stays anonymous, deliberately: that name is not allowlisted + // and could itself be sensitive. + ?: return if (known != null) "/api/${segments[1]}/$resource/other" else "/api/${segments[1]}/other" return (listOf("", "api", segments[1], resource) + template).joinToString("/") } @@ -274,6 +282,24 @@ object DiagnosticsFocusLogger { "action" to PrairieLogAttribute.Text(action), ), ) + + /** + * Content focus entry found nothing to focus, even one frame later. + * + * Every `requestFocus()` on the way into content is wrapped in + * `runCatching`, because a requester whose node has not composed yet throws + * rather than returning false. That made the failure invisible: focus went + * nowhere, no exception surfaced, and the viewer was simply stuck with no + * evidence in any log. Warn level on purpose — the telemetry builds + * instrument logcat at `minLevel = WARNING`, so this becomes a breadcrumb + * instead of vanishing. + */ + fun contentEntryFailed(route: String) = PrairieLog.w( + DiagnosticsLogCategory.FOCUS, + "TvShellFocus", + "content focus entry failed", + mapOf("route" to PrairieLogAttribute.Text(route)), + ) } private val API_VERSION = Regex("v[0-9]+") @@ -336,7 +362,9 @@ private val API_ROUTE_TEMPLATES: Map>> = mapOf( listOf(DYNAMIC_ROUTE_SEGMENT, "files", DYNAMIC_ROUTE_SEGMENT, "read"), listOf(DYNAMIC_ROUTE_SEGMENT, "progress")), "events" to listOf(listOf("ws"), listOf("ws-ticket")), + "favorites" to listOf(emptyList(), listOf(DYNAMIC_ROUTE_SEGMENT)), "health" to listOf(emptyList()), + "history" to listOf(emptyList()), "home" to listOf(listOf("layout"), listOf("sections"), listOf("sections", DYNAMIC_ROUTE_SEGMENT, "items"), listOf("dismissals", "continue_watching", DYNAMIC_ROUTE_SEGMENT), @@ -346,9 +374,12 @@ private val API_ROUTE_TEMPLATES: Map>> = mapOf( "library" to listOf(listOf(DYNAMIC_ROUTE_SEGMENT, "sections"), listOf(DYNAMIC_ROUTE_SEGMENT, "sections", DYNAMIC_ROUTE_SEGMENT, "items"), listOf(DYNAMIC_ROUTE_SEGMENT, "collections")), + "library-playback-prefs" to listOf(emptyList(), listOf(DYNAMIC_ROUTE_SEGMENT)), + "metadata" to listOf(listOf("ai", "status")), "notifications" to listOf(emptyList(), listOf("sync"), listOf("unread-count"), listOf("read-all"), listOf("preferences"), listOf("capability"), listOf(DYNAMIC_ROUTE_SEGMENT), listOf(DYNAMIC_ROUTE_SEGMENT, "read"), listOf("push", "devices")), + "onboarding" to listOf(listOf("flow"), listOf("state"), listOf("progress")), "people" to listOf(listOf(DYNAMIC_ROUTE_SEGMENT)), "playback" to listOf(listOf("start"), listOf("route-events"), listOf("transcode", "start"), listOf(DYNAMIC_ROUTE_SEGMENT), listOf(DYNAMIC_ROUTE_SEGMENT, "progress"), @@ -376,7 +407,9 @@ private val API_ROUTE_TEMPLATES: Map>> = mapOf( "sync" to listOf(listOf("progress")), "user" to listOf(listOf("libraries")), "users" to listOf(listOf(DYNAMIC_ROUTE_SEGMENT), listOf(DYNAMIC_ROUTE_SEGMENT, "avatar.png")), + "watch" to listOf(listOf(DYNAMIC_ROUTE_SEGMENT)), "watched" to listOf(listOf(DYNAMIC_ROUTE_SEGMENT)), + "watchlist" to listOf(emptyList(), listOf(DYNAMIC_ROUTE_SEGMENT)), "watch-together" to listOf(listOf("rooms"), listOf("join"), listOf("rooms", DYNAMIC_ROUTE_SEGMENT), listOf("rooms", DYNAMIC_ROUTE_SEGMENT, "selection"), listOf("rooms", DYNAMIC_ROUTE_SEGMENT, "policy"), listOf("rooms", DYNAMIC_ROUTE_SEGMENT, "suggestions"), diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsManualCapture.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsManualCapture.kt index 9e08860aa..c0bd274cf 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsManualCapture.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsManualCapture.kt @@ -86,8 +86,11 @@ class FileDiagnosticsCaptureController( debugLogging = true, ) } finally { - frozen.files.firstOrNull()?.parentFile?.deleteRecursively() - logBuffer.rotateGeneration() + try { + fileLogger.deleteFrozen(frozen) + } finally { + logBuffer.rotateGeneration() + } } } @@ -154,19 +157,23 @@ class FileDiagnosticsCaptureController( ) } - override suspend fun purge(binding: DiagnosticsBinding) { + override suspend fun purgeCurrentEvidence() { var failure: Throwable? = null suspend fun attempt(block: suspend () -> Unit) { runCatching { block() }.onFailure { error -> if (failure == null) failure = error } } val owned = active.get() - if (owned != null && owned.capture.identityKey.binding == binding) attempt { cancelOwned(owned) } + if (owned != null) attempt { cancelOwned(owned) } attempt { fileLogger.purgeStoredEvidence() } attempt { breadcrumbJournal?.purge() } attempt { logBuffer.clear() } failure?.let { throw it } } + override suspend fun reconcileStoredEvidence() { + fileLogger.reconcileStoredEvidence() + } + private fun saveManual( context: DiagnosticsCaptureContext, capturedAtEpochMs: Long, @@ -186,6 +193,7 @@ class FileDiagnosticsCaptureController( accountUserId = context.binding.accountUserId, profileId = context.profileId, ownershipGeneration = context.ownershipGeneration, + destinationKind = context.destinationKind, ) val manifest = DiagnosticsManifest( schemaVersion = 1, @@ -202,7 +210,11 @@ class FileDiagnosticsCaptureController( destination = DiagnosticsDestination(context.binding.serverInstanceId), consent = DiagnosticsConsent(DiagnosticsConsentMode.MANUAL, context.noticeVersion), deviceSummary = environment.deviceSummary, - playbackSessionIds = playbackSessions.snapshot(), + playbackSessionIds = if (context.destinationKind == DiagnosticsDestinationKind.HOSTED) { + emptyList() + } else { + playbackSessions.snapshot() + }, logSummary = DiagnosticsLogSummaryBuilder.build(logBytes, droppedLines, debugLogging), archive = DiagnosticsArchive( entries = CANONICAL_ORDER.filter { it == MANIFEST_FILE || it in artifacts }, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsModule.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsModule.kt index c36c9c373..486efca91 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsModule.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsModule.kt @@ -6,28 +6,37 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile +import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module -import org.prairieserver.prairie.network.NetworkDiagnosticsObserver +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.model.diagnostics.DiagnosticsDeviceSummary import org.prairieserver.prairie.model.diagnostics.DiagnosticsPlatform +import org.prairieserver.prairie.network.NetworkDiagnosticsObserver +import org.prairieserver.prairie.network.DiagnosticsUploadAuthorization import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.DefaultHostedDiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApi +import org.prairieserver.prairie.network.api.createHostedDiagnosticsClient private val DIAGNOSTICS_DATA_STORE = named("diagnostics-data-store") private val DIAGNOSTICS_SCOPE = named("diagnostics-scope") +private val SELF_HOSTED_DIAGNOSTICS_IDENTITY = named("self-hosted-diagnostics-identity") +private val HOSTED_DIAGNOSTICS_HTTP = named("hosted-diagnostics-http") val diagnosticsModule = module { single { DiagnosticsNetworkLogger } single>(DIAGNOSTICS_DATA_STORE) { PreferenceDataStoreFactory.create( - produceFile = { androidContext().preferencesDataStoreFile("prairie_diagnostics") }, + produceFile = { androidContext().preferencesDataStoreFile("silo_diagnostics") }, ) } single { FilePendingReportStore(androidContext().noBackupFilesDir) } + single { DiagnosticsPrivacyBarrier() } single { LogRing() } single { DiagnosticsPlaybackSessionTracker() } single { @@ -45,7 +54,7 @@ val diagnosticsModule = module { single { ApiDiagnosticsStatusProvider(get()) } single { RepositoryDiagnosticsAccountProvider(get()) } single { RepositoryDiagnosticsProfileProvider(get()) } - single { + single(SELF_HOSTED_DIAGNOSTICS_IDENTITY) { DefaultDiagnosticsIdentityResolver( tokenManager = get(), identityTransitions = get(), @@ -55,27 +64,90 @@ val diagnosticsModule = module { profileProvider = get(), ) } + single(HOSTED_DIAGNOSTICS_HTTP) { createHostedDiagnosticsClient() } + single { DefaultHostedDiagnosticsApi(get(HOSTED_DIAGNOSTICS_HTTP)) } + single { + val settings = get() + object : HostedDiagnosticsCapabilitiesStore { + override suspend fun load() = settings.hostedCapabilities() + override suspend fun save(capabilities: org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities) { + settings.cacheHostedCapabilities(capabilities) + } + } + } + single { HostedDiagnosticsCapabilitiesRepository(get(), get()) } + single { + val settings = get() + object : HostedDiagnosticsBindingOwnerStore { + override suspend fun load(localServerId: String) = settings.hostedBindingOwner(localServerId) + override suspend fun save(localServerId: String, owner: String) { + settings.cacheHostedBindingOwner(localServerId, owner) + } + } + } + single { + EncryptedPreferencesHostedDiagnosticsCredentialStore(get()) + } + single { HostedDiagnosticsInstallationManager(get(), get(), get()) } + single { DefaultHostedDiagnosticsReportDeleter(get(), get()) } + single { + DestinationDiagnosticsIdentityResolver( + destination = { get().destinationKind() }, + hosted = HostedDiagnosticsIdentityResolver( + tokenManager = get(), + identityTransitions = get(), + registry = get(), + accountProvider = get(), + profileProvider = get(), + capabilities = get(), + bindingOwners = get(), + ), + selfHosted = get(SELF_HOSTED_DIAGNOSTICS_IDENTITY), + ) + } single { FileDiagnosticsBundleBuilder() } single { val tokenManager = get() - DiagnosticsRedactionTokenProvider { - listOfNotNull( - tokenManager.getAccessToken(), - tokenManager.getRefreshToken(), - tokenManager.getProfileToken(), - ).filter(String::isNotBlank) + val hostedInstallations = get() + DestinationAwareDiagnosticsRedactionTokenProvider(tokenManager, get()) { + hostedInstallations.credentialsForOutstanding().map { it.installationToken } } } single { + val tokenManager = get() + val settings = get() DefaultDiagnosticsUploader( reports = get(), identity = get(), + identityTransitions = get(), + privacyBarrier = get(), bundleBuilder = get(), api = get(), + hostedApi = get(), + hostedInstallations = get(), + hostedCapabilities = get(), redactionTokens = get(), + selfHostedAuthorization = DiagnosticsSelfHostedAuthorizationProvider { + val scope = tokenManager.snapshotCurrentScope() + ?.takeIf { it.credentialGenerationId == null } + ?: return@DiagnosticsSelfHostedAuthorizationProvider null + val accessToken = tokenManager.getAccessTokenForScope(scope) + ?.takeIf(String::isNotBlank) + ?: return@DiagnosticsSelfHostedAuthorizationProvider null + DiagnosticsUploadAuthorization( + serverId = scope.serverId, + serverUrl = scope.serverUrl, + accessToken = accessToken, + activeProfileId = scope.profileId, + identityGeneration = scope.identityGeneration, + ) + }, sentRecorder = get(), consentProvider = get(), + transportPolicy = DiagnosticsTransportPolicy { binding, noticeVersion, requireAlways -> + settings.permitsUpload(binding, noticeVersion, requireAlways) + }, staleConsentHandler = get(), ) } @@ -83,8 +155,12 @@ val diagnosticsModule = module { single { AndroidDiagnosticsDeviceProbe(androidContext(), get(), get()) } single { DeviceSnapshotCollector(get()) } single { DeviceSnapshotCache() } - single { androidExitReportEnvironment(androidContext(), get()) } + single { androidExitReportEnvironment(androidContext(), get(), get()) } single { FileJvmCrashMarkerSource(androidContext().noBackupFilesDir) } + single { + val markers = get() + DiagnosticsStoredEvidenceReconciler(markers::reconcile) + } single { FrameworkAndroidExitInfoSource(androidContext()) } single { AndroidProcessStateSummaryPublisher(androidContext()) } single { DiagnosticsRunLedger(androidContext().noBackupFilesDir, get()) } @@ -121,7 +197,10 @@ val diagnosticsModule = module { val cache = get() val tokenProvider = get() DiagnosticsIncidentCollector { context, consent -> - val tokens = runCatching { tokenProvider.tokens() }.getOrDefault(emptyList()) + // Exact credentials are part of the hosted redaction boundary. If + // they cannot be read, leave the raw marker for a later refresh + // instead of assembling evidence with a weaker token set. + val tokens = tokenProvider.tokens(context.destinationKind) ExitInfoCollector( source = source, ledger = ledger, @@ -150,19 +229,42 @@ val diagnosticsModule = module { val capture = get() val ledger = get() val markers = get() - DiagnosticsBindingPurger { binding -> + DiagnosticsBindingPurger { binding, includeLiveCapture -> var failure: Throwable? = null suspend fun attempt(block: suspend () -> Unit) { runCatching { block() }.onFailure { error -> if (failure == null) failure = error } } - attempt { capture.purge(binding) } + if (includeLiveCapture) attempt { capture.purgeCurrentEvidence() } attempt { reports.purge(binding) } attempt { ledger.purge(binding) } attempt { markers.purge(binding) } failure?.let { throw it } } } - single { DiagnosticsSettingsStore(get(DIAGNOSTICS_DATA_STORE), get()) } + single { + val reports = get() + val capture = get() + val ledger = get() + val markers = get() + DiagnosticsAllEvidencePurger { includeLiveCapture -> + var failure: Throwable? = null + suspend fun attempt(block: suspend () -> Unit) { + runCatching { block() }.onFailure { error -> if (failure == null) failure = error } + } + if (includeLiveCapture) attempt { capture.purgeCurrentEvidence() } + attempt { reports.purgeAll() } + attempt { ledger.clear() } + attempt { markers.purgeAll() } + failure?.let { throw it } + } + } + single { + DiagnosticsSettingsStore( + dataStore = get(DIAGNOSTICS_DATA_STORE), + bindingPurger = get(), + allEvidencePurger = get(), + ) + } single { val settings = get() DiagnosticsUploadConsentProvider { binding, noticeVersion -> @@ -171,7 +273,9 @@ val diagnosticsModule = module { } single { val settings = get() - DiagnosticsSentRecorder(settings::recordSent) + DiagnosticsSentRecorder { binding, shortId, sentAtEpochMs, state -> + settings.recordSent(binding, shortId, sentAtEpochMs, state) + } } single { SettingsDiagnosticsStaleConsentHandler(get()) @@ -180,19 +284,27 @@ val diagnosticsModule = module { val context = androidContext() DiagnosticsUploadScheduler { reportId -> DiagnosticsUploadWorker.enqueue(context, reportId) } } + single { + val context = androidContext() + HostedDiagnosticsDeletionScheduler { HostedDiagnosticsDeletionWorker.enqueue(context) } + } single(DIAGNOSTICS_SCOPE) { CoroutineScope(SupervisorJob() + Dispatchers.Default) } single { DefaultDiagnosticsCoordinator( scope = get(DIAGNOSTICS_SCOPE), identity = get(), identityTransitions = get(), + privacyBarrier = get(), settings = get(), reports = get(), capture = get(), uploader = get(), uploadScheduler = get(), + hostedDeletionScheduler = get(), + hostedReportDeleter = get(), runtimePublisher = get(), incidentCollector = get(), + storedEvidenceReconciler = get(), ) } } @@ -201,6 +313,7 @@ val diagnosticsModule = module { private fun androidExitReportEnvironment( context: android.content.Context, probe: DiagnosticsDeviceProbe, + buildIdentity: PrairieClientBuildIdentity, ): ExitReportEnvironment { val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val identity = probe.identity() @@ -210,11 +323,15 @@ private fun androidExitReportEnvironment( } else { DiagnosticsPlatform.ANDROID } - val build = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.longVersionCode.toString() - } else { - packageInfo.versionCode.toString() - } + // CI's build counter, the same value the X-Prairie-Client-Build header and the + // v3 playback context carry, so a crash report and an Activity session for + // one install agree on which build they came from. This used to be the + // versionCode, which is the form-factor-doubled release code and therefore + // a different number entirely. silo-apple reports CFBundleVersion on all + // three carriers for the same reason. The manifest requires a non-empty + // string (DiagnosticsValidation), so an unstamped local build keeps the + // literal "0" here rather than collapsing to absent. + val build = buildIdentity.buildNumber return ExitReportEnvironment( appVersion = packageInfo.versionName ?: "unknown", appBuild = build, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPresentationModels.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPresentationModels.kt index fb27c4949..40e026c56 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPresentationModels.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPresentationModels.kt @@ -22,6 +22,7 @@ data class DiagnosticsReportSummary( val archiveEntries: List, val uploadStatus: PendingReportStatus, val uploadErrorCode: String?, + val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, ) data class DiagnosticsPrompt( @@ -57,4 +58,7 @@ data class DiagnosticsUiState( val prompt: DiagnosticsPrompt? = null, val timedCapture: TimedCaptureState = TimedCaptureState(), val sentHistory: List = emptyList(), + val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.HOSTED, + val allowsAutomaticUpload: Boolean = false, + val retentionDays: Int = HOSTED_DIAGNOSTICS_RETENTION_DAYS, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyBarrier.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyBarrier.kt new file mode 100644 index 000000000..1ccd8d4b7 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyBarrier.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.common.diagnostics + +import kotlinx.coroutines.sync.Mutex + +/** + * Orders diagnostics transport against user-visible privacy revocations. + * + * A transport that wins this lease may finish before Turn Off, Delete, or a + * destination/identity change returns. A revocation that wins first commits + * its policy and erasure work before a later transport can revalidate, so no + * new request can begin after the revocation has completed. + */ +class DiagnosticsPrivacyBarrier { + private val mutex = Mutex() + + suspend fun withTransport(block: suspend () -> T): T = withLease(block) + + suspend fun withRevocation(block: suspend () -> T): T = withLease(block) + + private suspend fun withLease(block: suspend () -> T): T { + mutex.lock() + return try { + block() + } finally { + mutex.unlock() + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactor.kt index f78fc4c4a..d7c8563b9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactor.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactor.kt @@ -56,17 +56,22 @@ class DiagnosticsRedactor( host in knownServerHosts -> stableHostToken(host) else -> stableHostToken(host) } - return runCatching { - URI( - scheme, - null, - safeHost, - uri.port, - uri.rawPath.orEmpty(), - null, - null, - ).toASCIIString() - }.getOrElse { "$scheme://$safeHost" } + return buildString { + append(scheme) + append("://") + if (':' in safeHost && !safeHost.startsWith('[')) { + append('[') + append(safeHost) + append(']') + } else { + append(safeHost) + } + if (uri.port >= 0) { + append(':') + append(uri.port) + } + append(sanitizePath(uri.rawPath.orEmpty())) + } } fun sanitizeThrowable( @@ -112,6 +117,18 @@ class DiagnosticsRedactor( private fun isLoopbackHost(host: String): Boolean = host == "localhost" || host == "::1" || IPV4_LOOPBACK_PATTERN.matches(host) + private fun sanitizePath(path: String): String = path + .split('/') + .joinToString("/") { segment -> + if (segment.isPrivateIdentifierPathSegment()) "{id}" else segment + } + + private fun String.isPrivateIdentifierPathSegment(): Boolean = + UUID_PATH_SEGMENT.matches(this) || + NUMERIC_ID_PATH_SEGMENT.matches(this) || + HEX_ID_PATH_SEGMENT.matches(this) || + OPAQUE_ID_PATH_SEGMENT.matches(this) + private fun MatchResult.isStructurallyValidJwt(): Boolean = groupValues[1].decodesToJsonObject() && groupValues[2].decodesToJsonObject() @@ -150,7 +167,13 @@ class DiagnosticsRedactor( val JWT_JSON = Json { isLenient = false } val TRAILING_URL_PUNCTUATION = setOf('.', ',', ';', ':', '!', '?', ')', ']', '}') - val URL_PATTERN = Regex("(?i)\\bhttps?://[^\\s<>\\\"']+") + val URL_PATTERN = Regex("(?i)\\b(?:https?|wss?)://[^\\s<>\\\"']+") + val UUID_PATH_SEGMENT = Regex( + "(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + ) + val NUMERIC_ID_PATH_SEGMENT = Regex("^[0-9]+$") + val HEX_ID_PATH_SEGMENT = Regex("(?i)^[0-9a-f]{16,}$") + val OPAQUE_ID_PATH_SEGMENT = Regex("^[A-Za-z0-9_-]{20,}$") val AUTHORIZATION_PATTERN = Regex( "(?i)\\b(authorization|proxy-authorization)\\s*[:=]\\s*(?:bearer\\s+)?[^\\s,;]+", ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedger.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedger.kt index 9f3b312a3..12eee898d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedger.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedger.kt @@ -22,6 +22,7 @@ data class DiagnosticsRunRecord( val processStartedAtEpochMs: Long, val captureSessionId: String, val ownershipGeneration: Long, + val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, ) /** Bounded local mapping from an opaque process-state token to validated capture identity. */ @@ -30,6 +31,9 @@ class DiagnosticsRunLedger( private val processStateSummaryPublisher: ProcessStateSummaryPublisher = ProcessStateSummaryPublisher { }, private val maxRecords: Int = DEFAULT_MAX_RECORDS, private val tokenFactory: () -> String = { UUID.randomUUID().toString().replace("-", "") }, + private val deleteRecursively: (File) -> Boolean = File::deleteRecursively, + private val directorySync: (File) -> Unit = ::syncDiagnosticsDirectory, + private val atomicRename: (File, File) -> Unit = ::renameDiagnosticsFileAtomically, ) { private val directory = noBackupFilesDir.resolve("client-diagnostics") private val file = directory.resolve("run-ledger.json") @@ -57,6 +61,7 @@ class DiagnosticsRunLedger( processStartedAtEpochMs = processStartedAtEpochMs, captureSessionId = captureSessionId, ownershipGeneration = context.ownershipGeneration, + destinationKind = context.destinationKind, ) mutex.withLock { val records = (listOf(record) + load()) @@ -83,7 +88,9 @@ class DiagnosticsRunLedger( suspend fun clear() { mutex.withLock { - if (file.exists()) check(file.delete()) { "unable to clear diagnostics run ledger" } + listOf(file, temporaryFile()).forEach { evidence -> + deleteDiagnosticsEvidenceStrictly(evidence, deleteRecursively, directorySync) + } } } @@ -105,15 +112,18 @@ class DiagnosticsRunLedger( check(directory.mkdirs() || directory.isDirectory) { "unable to create diagnostics ledger directory" } val encoded = JSON.encodeToString(records.take(maxRecords)).encodeToByteArray() check(encoded.size <= MAX_LEDGER_BYTES) { "diagnostics run ledger exceeds byte limit" } - val temporary = directory.resolve("run-ledger.json.tmp") + val temporary = temporaryFile() FileOutputStream(temporary, false).use { stream -> stream.write(encoded) stream.fd.sync() } - if (file.exists()) check(file.delete()) { "unable to replace diagnostics run ledger" } - check(temporary.renameTo(file)) { "unable to publish diagnostics run ledger" } + atomicRename(temporary, file) + directorySync(directory) + check(file.isFile && !temporary.exists()) { "diagnostics run ledger publish was not durable" } } + private fun temporaryFile(): File = directory.resolve("run-ledger.json.tmp") + private companion object { const val DEFAULT_MAX_RECORDS = 64 const val MAX_LEDGER_BYTES = 256 * 1_024 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRuntime.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRuntime.kt index 7028d804e..96cd4ca3c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRuntime.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRuntime.kt @@ -18,7 +18,7 @@ class DefaultDiagnosticsRuntimePublisher( override fun closeGate() { playbackSessions.close() active.set(null) - CrashCapture.updateSnapshot(CrashRuntimeSnapshot.empty()) + CrashCapture.closeGate() } override suspend fun publish(context: DiagnosticsCaptureContext) { @@ -36,7 +36,7 @@ class DefaultDiagnosticsRuntimePublisher( }.getOrNull() if (snapshot != null) deviceSnapshotCache.update(snapshot) val logs = logBuffer.snapshot() - val tokens = runCatching { redactionTokens.tokens() }.getOrDefault(emptyList()) + val tokens = runCatching { redactionTokens.tokens(context.destinationKind) }.getOrDefault(emptyList()) playbackSessions.commitIfCurrent(playbackScope) { playbackSessionIds -> CrashCapture.updateSnapshot(CrashRuntimeSnapshot( identityKey = context.identityKey, @@ -45,10 +45,15 @@ class DefaultDiagnosticsRuntimePublisher( accountUserId = context.binding.accountUserId, profileId = context.profileId, ownershipGeneration = context.ownershipGeneration, + destinationKind = context.destinationKind, ), captureSessionId = published.captureSessionId, runToken = published.runToken, - playbackSessionIds = playbackSessionIds, + playbackSessionIds = if (context.destinationKind == DiagnosticsDestinationKind.HOSTED) { + emptyList() + } else { + playbackSessionIds + }, deviceSnapshotJson = deviceSnapshotCache.currentBytes()?.decodeToString(), logLines = logs.lines, logDroppedCount = logs.droppedCount, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStore.kt index 07339baf7..bdc004a00 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStore.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities @Serializable data class DiagnosticsBinding( @@ -44,22 +45,41 @@ data class CachedDiagnosticsContext( val maxBundleBytes: Long, val maxManifestBytes: Long, val retentionDays: Int, + val sourceProfileId: String? = profileId, + val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, ) @Serializable data class SentDiagnosticsReport( val shortId: String, val sentAtEpochMs: Long, + val state: String = "accepted", +) + +@Serializable +private data class DiagnosticsBindingIndex( + val byLocalServerId: Map> = emptyMap(), +) + +@Serializable +private data class DiagnosticsErasureIndex( + val bindings: List = emptyList(), ) fun interface DiagnosticsBindingPurger { - suspend fun purge(binding: DiagnosticsBinding) + suspend fun purge(binding: DiagnosticsBinding, includeLiveCapture: Boolean) +} + +fun interface DiagnosticsAllEvidencePurger { + suspend fun purge(includeLiveCapture: Boolean) } class DiagnosticsSettingsStore( private val dataStore: DataStore, private val bindingPurger: DiagnosticsBindingPurger, private val historyLimit: Int = DEFAULT_HISTORY_LIMIT, + private val afterErasureIntentPersisted: suspend (DiagnosticsBinding) -> Unit = {}, + private val allEvidencePurger: DiagnosticsAllEvidencePurger? = null, ) { init { require(historyLimit > 0) { "historyLimit must be positive" } @@ -94,6 +114,10 @@ class DiagnosticsSettingsStore( noticeVersion: Int, ) { require(noticeVersion > 0) { "noticeVersion must be positive" } + repairCorruptErasureIndex() + if (mode != DiagnosticsConsentMode.NEVER) { + retryPendingErasure(binding, includeLiveCapture = true) + } val keys = keys(binding) dataStore.edit { preferences -> preferences[keys.consentMode] = mode.name @@ -101,9 +125,44 @@ class DiagnosticsSettingsStore( if (mode == DiagnosticsConsentMode.NEVER) { preferences[DEBUG_LOGGING_KEY] = false preferences.remove(keys.sentHistory) + val pending = decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) ?: DiagnosticsErasureIndex() + preferences.storeErasureIndex( + pending.copy(bindings = (pending.bindings + binding).distinct()), + ) } } - if (mode == DiagnosticsConsentMode.NEVER) bindingPurger.purge(binding) + if (mode == DiagnosticsConsentMode.NEVER) { + // Test seam models process death in the only meaningful crash + // window: NEVER and its erasure authority are durable, but no + // evidence has been removed yet. + afterErasureIntentPersisted(binding) + retryPendingErasure(binding, includeLiveCapture = true) + } + } + + suspend fun retryPendingErasures(currentBinding: DiagnosticsBinding? = null) { + val pending = pendingErasureBindings() + pending.sortedBy { it != currentBinding }.forEach { binding -> + retryPendingErasure(binding, includeLiveCapture = binding == currentBinding) + } + } + + suspend fun pendingErasureBindings(): List = + decodeErasureIndex(dataStore.data.first()[ERASURE_INDEX_KEY])?.bindings?.distinct() + ?: repairCorruptErasureIndex().let { emptyList() } + + private suspend fun retryPendingErasure( + binding: DiagnosticsBinding, + includeLiveCapture: Boolean, + ) { + if (binding !in pendingErasureBindings()) return + bindingPurger.purge(binding, includeLiveCapture) + dataStore.edit { preferences -> + val pending = decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) ?: DiagnosticsErasureIndex() + preferences.storeErasureIndex( + pending.copy(bindings = pending.bindings.filterNot { it == binding }), + ) + } } suspend fun demoteAlwaysToAsk(binding: DiagnosticsBinding, noticeVersion: Int): Boolean { @@ -126,6 +185,63 @@ class DiagnosticsSettingsStore( suspend fun debugLogging(): Boolean = dataStore.data.first()[DEBUG_LOGGING_KEY] ?: false + /** Hosted collection is the device default; self-hosted remains an explicit compatibility choice. */ + suspend fun destinationKind(): DiagnosticsDestinationKind = + dataStore.data.first()[DESTINATION_KIND_KEY] + ?.let { raw -> DiagnosticsDestinationKind.entries.firstOrNull { it.name == raw } } + ?: DiagnosticsDestinationKind.HOSTED + + /** + * Fail-closed send-time policy check. Callers perform this while holding + * [DiagnosticsPrivacyBarrier], immediately before starting transport. + */ + suspend fun permitsUpload( + binding: PendingReportBinding, + noticeVersion: Int, + requireAlwaysConsent: Boolean, + ): Boolean { + val preferences = dataStore.data.first() + val destination = preferences[DESTINATION_KIND_KEY] + ?.let { raw -> DiagnosticsDestinationKind.entries.firstOrNull { it.name == raw } } + ?: DiagnosticsDestinationKind.HOSTED + if (destination != binding.destinationKind) return false + val erasures = decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) ?: return false + if (binding.binding in erasures.bindings) return false + if (!requireAlwaysConsent) return true + + val keys = keys(binding.binding) + val mode = preferences[keys.consentMode] + ?.let { raw -> DiagnosticsConsentMode.entries.firstOrNull { it.name == raw } } + ?: DiagnosticsConsentMode.ASK + return mode == DiagnosticsConsentMode.ALWAYS && preferences[keys.noticeVersion] == noticeVersion + } + + suspend fun setDestinationKind(destinationKind: DiagnosticsDestinationKind) { + dataStore.edit { preferences -> preferences[DESTINATION_KIND_KEY] = destinationKind.name } + } + + suspend fun hostedCapabilities(): HostedDiagnosticsCapabilities? = + dataStore.data.first()[HOSTED_CAPABILITIES_KEY]?.let { encoded -> + runCatching { JSON.decodeFromString(encoded) }.getOrNull() + } + + suspend fun cacheHostedCapabilities(capabilities: HostedDiagnosticsCapabilities) { + dataStore.edit { preferences -> + preferences[HOSTED_CAPABILITIES_KEY] = JSON.encodeToString(capabilities) + } + } + + suspend fun hostedBindingOwner(localServerId: String): String? { + require(localServerId.isNotBlank()) + return dataStore.data.first()[hostedBindingOwnerKey(localServerId)]?.takeIf(String::isNotBlank) + } + + suspend fun cacheHostedBindingOwner(localServerId: String, owner: String) { + require(localServerId.isNotBlank()) + require(owner.isNotBlank()) + dataStore.edit { preferences -> preferences[hostedBindingOwnerKey(localServerId)] = owner } + } + suspend fun setDebugLogging(enabled: Boolean) { dataStore.edit { preferences -> preferences[DEBUG_LOGGING_KEY] = enabled } } @@ -143,8 +259,19 @@ class DiagnosticsSettingsStore( maxBundleBytes = context.maxBundleBytes, maxManifestBytes = context.maxManifestBytes, retentionDays = context.retentionDays, + sourceProfileId = context.sourceProfileId, + destinationKind = context.destinationKind, ) - dataStore.edit { preferences -> preferences[CACHED_CONTEXT_KEY] = JSON.encodeToString(cached) } + dataStore.edit { preferences -> + preferences[CACHED_CONTEXT_KEY] = JSON.encodeToString(cached) + context.localServerId?.takeIf(String::isNotBlank)?.let { localServerId -> + val index = decodeBindingIndex(preferences[BINDING_INDEX_KEY]) + val bindings = (index.byLocalServerId[localServerId].orEmpty() + context.binding).distinct() + preferences.storeBindingIndex( + index.copy(byLocalServerId = index.byLocalServerId + (localServerId to bindings)), + ) + } + } } suspend fun cachedContext(): CachedDiagnosticsContext? = @@ -161,12 +288,28 @@ class DiagnosticsSettingsStore( } } - suspend fun recordSent(binding: DiagnosticsBinding, shortId: String, sentAtEpochMs: Long) { + suspend fun recordSent( + binding: DiagnosticsBinding, + shortId: String, + sentAtEpochMs: Long, + state: String = "accepted", + ) { require(shortId.isNotBlank()) { "shortId must not be blank" } + require(state.isNotBlank()) { "state must not be blank" } val keys = keys(binding) dataStore.edit { preferences -> + val consentMode = preferences[keys.consentMode] + ?.let { raw -> DiagnosticsConsentMode.entries.firstOrNull { it.name == raw } } + val erasures = decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) ?: return@edit + val erasurePending = binding in erasures.bindings + if (consentMode == DiagnosticsConsentMode.NEVER || erasurePending) { + // A direct WorkManager upload can settle after Turn Off has + // durably won. Never recreate history for an identity whose + // local/remote erasure is pending or whose consent is NEVER. + return@edit + } val existing = decodeHistory(preferences[keys.sentHistory]) - val updated = (listOf(SentDiagnosticsReport(shortId, sentAtEpochMs)) + existing) + val updated = (listOf(SentDiagnosticsReport(shortId, sentAtEpochMs, state)) + existing) .distinctBy(SentDiagnosticsReport::shortId) .sortedByDescending(SentDiagnosticsReport::sentAtEpochMs) .take(historyLimit) @@ -179,7 +322,35 @@ class DiagnosticsSettingsStore( .sortedByDescending(SentDiagnosticsReport::sentAtEpochMs) .take(historyLimit) - suspend fun purgeBinding(binding: DiagnosticsBinding) { + suspend fun purgeBinding( + binding: DiagnosticsBinding, + includeLiveCapture: Boolean = true, + ) { + scrubBindingMetadata(binding) + bindingPurger.purge(binding, includeLiveCapture) + dataStore.edit { preferences -> + val index = decodeBindingIndex(preferences[BINDING_INDEX_KEY]) + preferences.storeBindingIndex( + index.copy( + byLocalServerId = index.byLocalServerId.mapValues { (_, bindings) -> + bindings.filterNot { it == binding } + }.filterValues { bindings -> bindings.isNotEmpty() }, + ), + ) + val pending = decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) ?: DiagnosticsErasureIndex() + preferences.storeErasureIndex( + pending.copy(bindings = pending.bindings.filterNot { it == binding }), + ) + } + } + + /** + * Removes user-visible metadata without invoking the evidence purger. + * The identity gate calls [purgeBinding] synchronously; the coordinator + * actor repeats this metadata-only half after any queued upload settles so + * late network bookkeeping cannot revive history from the old identity. + */ + suspend fun scrubBindingMetadata(binding: DiagnosticsBinding) { val prefix = bindingKey(binding) dataStore.edit { preferences -> preferences.asMap().keys @@ -190,13 +361,115 @@ class DiagnosticsSettingsStore( } if (cached?.binding == binding) preferences.remove(CACHED_CONTEXT_KEY) } - bindingPurger.purge(binding) + } + + suspend fun bindingsForLocalServer(localServerId: String): List { + require(localServerId.isNotBlank()) { "localServerId must not be blank" } + return decodeBindingIndex(dataStore.data.first()[BINDING_INDEX_KEY]) + .byLocalServerId[localServerId] + .orEmpty() + .distinct() + } + + suspend fun purgeLocalServer( + localServerId: String, + fallbackBinding: DiagnosticsBinding? = null, + allowLegacyAllEvidenceFallback: Boolean = true, + ) { + require(localServerId.isNotBlank()) { "localServerId must not be blank" } + dataStore.edit { preferences -> preferences.remove(hostedBindingOwnerKey(localServerId)) } + val persistedIndex = decodeBindingIndex(dataStore.data.first()[BINDING_INDEX_KEY]) + val indexedBindings = persistedIndex.byLocalServerId[localServerId] + if (indexedBindings == null && fallbackBinding == null) { + if (!allowLegacyAllEvidenceFallback) return + val migrationComplete = dataStore.data.first()[BINDING_INDEX_MIGRATION_COMPLETE_KEY] ?: false + if (migrationComplete) { + // Once the legacy evidence inventory has been drained, an + // absent entry is authoritative: this server never collected + // diagnostics under the indexed scheme. Do not erase another + // server's evidence merely because this target is new. + return + } + // Upgrade boundary: older builds retained reports without a + // localServerId -> binding index. The removed inactive server + // cannot be reconstructed from hosted/account hashes, so fail + // closed once by removing all persisted diagnostics evidence. + checkNotNull(allEvidencePurger) { + "legacy diagnostics cleanup requires an all-evidence purger" + }.purge(includeLiveCapture = false) + dataStore.edit { preferences -> + preferences.asMap().keys + .filter { key -> key.name.startsWith("diagnostics.binding.") } + .forEach { key -> preferences.removeUntyped(key) } + preferences.remove(CACHED_CONTEXT_KEY) + preferences.remove(BINDING_INDEX_KEY) + preferences.remove(ERASURE_INDEX_KEY) + preferences[BINDING_INDEX_MIGRATION_COMPLETE_KEY] = true + } + return + } + val bindings = (indexedBindings.orEmpty() + listOfNotNull(fallbackBinding)).distinct() + bindings.forEach { binding -> + purgeBinding(binding, includeLiveCapture = false) + } + dataStore.edit { preferences -> + val index = decodeBindingIndex(preferences[BINDING_INDEX_KEY]) + preferences.storeBindingIndex( + index.copy(byLocalServerId = index.byLocalServerId - localServerId), + ) + } } private fun decodeHistory(raw: String?): List = raw?.let { encoded -> runCatching { JSON.decodeFromString>(encoded) }.getOrNull() } .orEmpty() + private fun decodeBindingIndex(raw: String?): DiagnosticsBindingIndex { + val index = raw?.let { encoded -> + runCatching { JSON.decodeFromString(encoded) }.getOrNull() + } + ?: DiagnosticsBindingIndex() + return index.copy(byLocalServerId = index.byLocalServerId.filterKeys(String::isNotBlank)) + } + + private fun decodeErasureIndex(raw: String?): DiagnosticsErasureIndex? { + if (raw == null) return DiagnosticsErasureIndex() + return runCatching { JSON.decodeFromString(raw) }.getOrNull() + } + + private suspend fun repairCorruptErasureIndex() { + val raw = dataStore.data.first()[ERASURE_INDEX_KEY] ?: return + if (decodeErasureIndex(raw) != null) return + checkNotNull(allEvidencePurger) { + "corrupt diagnostics erasure state requires an all-evidence purger" + }.purge(includeLiveCapture = true) + dataStore.edit { preferences -> + if (decodeErasureIndex(preferences[ERASURE_INDEX_KEY]) == null) { + preferences.remove(ERASURE_INDEX_KEY) + } + } + } + + private fun androidx.datastore.preferences.core.MutablePreferences.storeBindingIndex( + index: DiagnosticsBindingIndex, + ) { + if (index.byLocalServerId.isEmpty()) { + remove(BINDING_INDEX_KEY) + } else { + this[BINDING_INDEX_KEY] = JSON.encodeToString(index) + } + } + + private fun androidx.datastore.preferences.core.MutablePreferences.storeErasureIndex( + index: DiagnosticsErasureIndex, + ) { + if (index.bindings.isEmpty()) { + remove(ERASURE_INDEX_KEY) + } else { + this[ERASURE_INDEX_KEY] = JSON.encodeToString(index) + } + } + private fun keys(binding: DiagnosticsBinding): BindingKeys { val prefix = bindingKey(binding) return BindingKeys( @@ -216,6 +489,13 @@ class DiagnosticsSettingsStore( } } + private fun hostedBindingOwnerKey(localServerId: String): Preferences.Key { + val digest = MessageDigest.getInstance("SHA-256") + .digest(localServerId.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + return stringPreferencesKey("diagnostics.hosted.binding_owner.$digest") + } + private data class BindingKeys( val consentMode: Preferences.Key, val noticeVersion: Preferences.Key, @@ -230,7 +510,13 @@ class DiagnosticsSettingsStore( private companion object { const val DEFAULT_HISTORY_LIMIT = 20 val DEBUG_LOGGING_KEY = booleanPreferencesKey("diagnostics.device.debug_logging") + val DESTINATION_KIND_KEY = stringPreferencesKey("diagnostics.device.destination_kind") + val HOSTED_CAPABILITIES_KEY = stringPreferencesKey("diagnostics.hosted.capabilities") val CACHED_CONTEXT_KEY = stringPreferencesKey("diagnostics.last_context") + val BINDING_INDEX_KEY = stringPreferencesKey("diagnostics.binding_index") + val BINDING_INDEX_MIGRATION_COMPLETE_KEY = + booleanPreferencesKey("diagnostics.binding_index_migration_complete") + val ERASURE_INDEX_KEY = stringPreferencesKey("diagnostics.erasure_pending") val JSON = Json { ignoreUnknownKeys = true } } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploadWorker.kt index 75ee12013..79fdb17e4 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploadWorker.kt @@ -13,23 +13,25 @@ import androidx.work.workDataOf class DiagnosticsUploadWorker( appContext: Context, params: WorkerParameters, - private val uploader: DiagnosticsUploader, private val coordinator: DiagnosticsCoordinator, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { val reportId = inputData.getString(KEY_REPORT_ID)?.takeIf(String::isNotBlank) ?: return Result.failure() - return when (uploader.uploadAutomatically(reportId)) { - DiagnosticsUploadDecision.KeptRetryable -> Result.retry() - is DiagnosticsUploadDecision.Uploaded, + coordinator.start() + coordinator.refresh() + return when (coordinator.uploadAutomatically(reportId)) { + DiagnosticsUploadDecision.KeptRetryable, + DiagnosticsUploadDecision.KeptUnavailable, DiagnosticsUploadDecision.KeptIdentityChanged, + is DiagnosticsUploadDecision.HostedProcessing, + -> Result.retry() + is DiagnosticsUploadDecision.Uploaded, DiagnosticsUploadDecision.KeptTooLarge, DiagnosticsUploadDecision.KeptServerUpdateRequired, - DiagnosticsUploadDecision.KeptUnavailable, DiagnosticsUploadDecision.KeptInvalid, -> Result.success() DiagnosticsUploadDecision.KeptConsentReviewRequired -> { - coordinator.start() coordinator.refresh() Result.success() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploader.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploader.kt index b916c6e45..cb2e01343 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploader.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploader.kt @@ -5,10 +5,23 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus import org.prairieserver.prairie.model.diagnostics.DiagnosticsErrorCode import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResult +import org.prairieserver.prairie.network.DiagnosticsUploadAuthorization +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.DiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApiResult +import org.prairieserver.prairie.network.api.HostedDiagnosticsCreateReportRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsAvailability +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportState +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportStatusResponse sealed interface DiagnosticsUploadDecision { - data class Uploaded(val shortId: String) : DiagnosticsUploadDecision + data class Uploaded( + val shortId: String, + val state: HostedDiagnosticsReportState = HostedDiagnosticsReportState.READY, + ) : DiagnosticsUploadDecision + data class HostedProcessing(val shortId: String) : DiagnosticsUploadDecision data object KeptRetryable : DiagnosticsUploadDecision data object KeptIdentityChanged : DiagnosticsUploadDecision data object KeptTooLarge : DiagnosticsUploadDecision @@ -30,17 +43,29 @@ fun interface DiagnosticsUploader { } fun interface DiagnosticsRedactionTokenProvider { - suspend fun tokens(): List + suspend fun tokens(destinationKind: DiagnosticsDestinationKind): List +} + +fun interface DiagnosticsSelfHostedAuthorizationProvider { + suspend fun current(): DiagnosticsUploadAuthorization? } fun interface DiagnosticsSentRecorder { - suspend fun record(binding: DiagnosticsBinding, shortId: String, sentAtEpochMs: Long) + suspend fun record(binding: DiagnosticsBinding, shortId: String, sentAtEpochMs: Long, state: String) } fun interface DiagnosticsUploadConsentProvider { suspend fun consent(binding: DiagnosticsBinding, noticeVersion: Int): DiagnosticsConsentMode } +fun interface DiagnosticsTransportPolicy { + suspend fun permits( + binding: PendingReportBinding, + noticeVersion: Int, + requireAlwaysConsent: Boolean, + ): Boolean +} + fun interface DiagnosticsStaleConsentHandler { suspend fun demote(binding: DiagnosticsBinding, noticeVersion: Int) } @@ -56,13 +81,20 @@ class SettingsDiagnosticsStaleConsentHandler( class DefaultDiagnosticsUploader( private val reports: PendingReportStore, private val identity: DiagnosticsIdentityResolver, + private val identityTransitions: IdentityTransitionBarrier, + private val privacyBarrier: DiagnosticsPrivacyBarrier = DiagnosticsPrivacyBarrier(), private val bundleBuilder: DiagnosticsBundleBuilder, private val api: DiagnosticsApi, + private val hostedApi: HostedDiagnosticsApi? = null, + private val hostedInstallations: HostedDiagnosticsInstallationManager? = null, + private val hostedCapabilities: HostedDiagnosticsCapabilitiesRepository? = null, private val redactionTokens: DiagnosticsRedactionTokenProvider, + private val selfHostedAuthorization: DiagnosticsSelfHostedAuthorizationProvider, private val sentRecorder: DiagnosticsSentRecorder, private val consentProvider: DiagnosticsUploadConsentProvider = DiagnosticsUploadConsentProvider { _, _ -> DiagnosticsConsentMode.ASK }, + private val transportPolicy: DiagnosticsTransportPolicy = DiagnosticsTransportPolicy { _, _, _ -> true }, private val staleConsentHandler: DiagnosticsStaleConsentHandler = DiagnosticsStaleConsentHandler { _, _ -> }, private val nowMs: () -> Long = System::currentTimeMillis, ) : DiagnosticsUploader { @@ -86,11 +118,46 @@ class DefaultDiagnosticsUploader( requireAlwaysConsent: Boolean, expectedNoticeVersion: Int?, ): DiagnosticsUploadDecision { + // Capture before loading evidence so any identity mutation that races + // this operation invalidates all post-network local bookkeeping. + val operationGeneration = identityTransitions.generation.value val report = reports.load(reportId) ?: return DiagnosticsUploadDecision.KeptInvalid + if ( + report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED && + report.state.hostedRemoteShortId != null + ) { + return try { + pollHostedStatus(report, operationGeneration) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + runCatching { + reports.markHostedProcessing(report.id, checkNotNull(report.state.hostedRemoteShortId)) + } + DiagnosticsUploadDecision.KeptRetryable + } + } val retryDeadline = reports.retryAfterDeadline(report.binding.binding) if (retryDeadline != null && retryDeadline > nowMs()) return DiagnosticsUploadDecision.KeptRetryable - val before = identity.resolve(requirePersistentCapture = true) + if (requireAlwaysConsent && report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED) { + return DiagnosticsUploadDecision.KeptConsentReviewRequired + } + val liveHostedCapabilities = if (report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED) { + when (val result = hostedCapabilities?.refresh()) { + is HostedDiagnosticsApiResult.Success -> result.value + is HostedDiagnosticsApiResult.Failure -> return mapHostedError(report, result) + is HostedDiagnosticsApiResult.NetworkError, null -> { + markRetryable(report.id, "network") + return DiagnosticsUploadDecision.KeptRetryable + } + } + } else { + null + } + val beforeBase = identity.resolveForUpload(requirePersistentCapture = true) ?: return DiagnosticsUploadDecision.KeptUnavailable + val before = beforeBase.withHostedCapabilities(liveHostedCapabilities) + ?: return DiagnosticsUploadDecision.KeptIdentityChanged if (expectedNoticeVersion != null && before.noticeVersion != expectedNoticeVersion) { return DiagnosticsUploadDecision.KeptConsentReviewRequired } @@ -108,25 +175,73 @@ class DefaultDiagnosticsUploader( return consentBefore.rejectedUploadDecision(requireAlwaysConsent) } val framedReport = report.withCurrentConsent(consentBefore, before.noticeVersion) - - val tokens = try { - redactionTokens.tokens() - } catch (error: CancellationException) { - throw error - } catch (_: Throwable) { - markRetryable(report.id, "redaction_tokens_unavailable") - return DiagnosticsUploadDecision.KeptRetryable - } - val bundle = runCatching { bundleBuilder.build(framedReport, tokens) }.getOrElse { - markPermanent(report.id, "invalid_bundle") - return DiagnosticsUploadDecision.KeptInvalid + var hostedEnvelopeMustBePersisted = false + val bundle = if (report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED) { + when (val cached = reports.loadHostedEnvelope(report.id)) { + HostedEnvelopeLoadResult.Corrupt -> { + markPermanent(report.id, "invalid_hosted_envelope") + return DiagnosticsUploadDecision.KeptInvalid + } + is HostedEnvelopeLoadResult.Available -> { + if (report.state.hostedConsentRefreshRequired) { + hostedEnvelopeMustBePersisted = true + runCatching { + bundleBuilder.reframeHosted(cached.bundle, framedReport.manifest.consent) + }.getOrElse { + markPermanent(report.id, "invalid_hosted_envelope") + return DiagnosticsUploadDecision.KeptInvalid + } + } else { + // Once the first create envelope is committed locally, + // every ambiguous retry must replay its exact manifest, + // length and SHA even if tokens or collector policy rotate. + cached.bundle + } + } + HostedEnvelopeLoadResult.Missing -> { + val tokens = try { + redactionTokens.tokens(report.binding.destinationKind) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + markRetryable(report.id, "redaction_tokens_unavailable") + return DiagnosticsUploadDecision.KeptRetryable + } + hostedEnvelopeMustBePersisted = true + runCatching { bundleBuilder.build(framedReport, tokens) }.getOrElse { + markPermanent(report.id, "invalid_bundle") + return DiagnosticsUploadDecision.KeptInvalid + } + } + } + } else { + val tokens = try { + redactionTokens.tokens(report.binding.destinationKind) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + markRetryable(report.id, "redaction_tokens_unavailable") + return DiagnosticsUploadDecision.KeptRetryable + } + runCatching { bundleBuilder.build(framedReport, tokens) }.getOrElse { + markPermanent(report.id, "invalid_bundle") + return DiagnosticsUploadDecision.KeptInvalid + } } - if (bundle.bytes.size.toLong() > before.maxBundleBytes || bundle.manifestBytes.size.toLong() > before.maxManifestBytes) { + val enforceAdvertisedSizeLimits = + report.binding.destinationKind != DiagnosticsDestinationKind.HOSTED || hostedEnvelopeMustBePersisted + if ( + enforceAdvertisedSizeLimits && + (bundle.bytes.size.toLong() > before.maxBundleBytes || + bundle.manifestBytes.size.toLong() > before.maxManifestBytes) + ) { markPermanent(report.id, "too_large") return DiagnosticsUploadDecision.KeptTooLarge } - val after = identity.resolve(requirePersistentCapture = true) + val afterBase = identity.resolveForUpload(requirePersistentCapture = true) + ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val after = afterBase.withHostedCapabilities(liveHostedCapabilities) ?: return DiagnosticsUploadDecision.KeptIdentityChanged if (before.identityKey != after.identityKey || !report.canUploadUnder(after)) { return DiagnosticsUploadDecision.KeptIdentityChanged @@ -147,33 +262,703 @@ class DefaultDiagnosticsUploader( return DiagnosticsUploadDecision.KeptConsentReviewRequired } if ( - bundle.bytes.size.toLong() > after.maxBundleBytes || - bundle.manifestBytes.size.toLong() > after.maxManifestBytes + enforceAdvertisedSizeLimits && + (bundle.bytes.size.toLong() > after.maxBundleBytes || + bundle.manifestBytes.size.toLong() > after.maxManifestBytes) ) { markPermanent(report.id, "too_large") return DiagnosticsUploadDecision.KeptTooLarge } - val result = try { - api.upload(bundle.manifestBytes, bundle.bytes, report.binding.profileId) + if (hostedEnvelopeMustBePersisted) { + try { + // This durable local commit is the send boundary. Never make a + // create request unless the exact sanitized envelope can be + // replayed after process death or a lost response. + reports.saveHostedEnvelope(report.id, bundle) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + markRetryable(report.id, "hosted_envelope_unavailable") + return DiagnosticsUploadDecision.KeptRetryable + } + } + + val exactSelfHostedAuthorization = if ( + report.binding.destinationKind == DiagnosticsDestinationKind.SELF_HOSTED + ) { + val authorization = try { + selfHostedAuthorization.current() + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } ?: return DiagnosticsUploadDecision.KeptUnavailable + if (!authorization.matches(after, operationGeneration)) { + return DiagnosticsUploadDecision.KeptIdentityChanged + } + authorization + } else { + null + } + + val decision = try { + when (report.binding.destinationKind) { + DiagnosticsDestinationKind.HOSTED -> + uploadHosted(report, bundle, after, operationGeneration, requireAlwaysConsent) + DiagnosticsDestinationKind.SELF_HOSTED -> + uploadSelfHosted( + report, + bundle, + after, + checkNotNull(exactSelfHostedAuthorization), + operationGeneration, + requireAlwaysConsent, + ) + } } catch (error: CancellationException) { throw error } catch (_: Throwable) { markRetryable(report.id, "network") return DiagnosticsUploadDecision.KeptRetryable } + return decision + } + + private suspend fun uploadSelfHosted( + report: PendingReport, + bundle: DiagnosticsBundle, + expectedIdentity: DiagnosticsCaptureContext, + authorization: DiagnosticsUploadAuthorization, + operationGeneration: Long, + requireAlwaysConsent: Boolean, + ): DiagnosticsUploadDecision { + val uploadAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !report.canUploadUnder(expectedIdentity) || + !authorization.matches(expectedIdentity, operationGeneration) -> + SelfHostedUploadAttempt.IdentityChanged + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) -> SelfHostedUploadAttempt.Revoked + reports.load(report.id) == null -> SelfHostedUploadAttempt.ReportRemoved + else -> { + // Keep the request bound to the exact identity that approved + // this report. Privacy and identity revocations either happen + // before this lease and prevent the POST, or wait for it. + SelfHostedUploadAttempt.Sent( + api.upload( + bundle.manifestBytes, + bundle.bytes, + report.binding.profileId, + authorization, + ), + ) + } + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val result = when (uploadAttempt) { + SelfHostedUploadAttempt.IdentityChanged -> return DiagnosticsUploadDecision.KeptIdentityChanged + SelfHostedUploadAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + SelfHostedUploadAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is SelfHostedUploadAttempt.Sent -> uploadAttempt.result + } return when (result) { is DiagnosticsUploadResult.Success -> { - reports.delete(report.id) - runCatching { sentRecorder.record(report.binding.binding, result.response.shortId, nowMs()) } - DiagnosticsUploadDecision.Uploaded(result.response.shortId) + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.delete(report.id) + runCatching { + sentRecorder.record(report.binding.binding, result.response.shortId, nowMs(), "ready") + } + true + } + } + } + if (finalized != true) { + DiagnosticsUploadDecision.KeptIdentityChanged + } else { + DiagnosticsUploadDecision.Uploaded(result.response.shortId) + } } is DiagnosticsUploadResult.NetworkError -> { markRetryable(report.id, "network") DiagnosticsUploadDecision.KeptRetryable } - is DiagnosticsUploadResult.Failure -> mapServerError(report, result, after.noticeVersion) + is DiagnosticsUploadResult.Failure -> if (result.code == DiagnosticsErrorCode.UNAUTHORIZED) { + // The leased exact-scope request deliberately suppresses auth + // refresh to avoid re-entering the identity barrier. A normal + // preflight on the next attempt may refresh before send. + markRetryable(report.id, result.code.wire) + DiagnosticsUploadDecision.KeptRetryable + } else { + mapServerError(report, result, expectedIdentity.noticeVersion) + } + } + } + + private suspend fun uploadHosted( + report: PendingReport, + bundle: DiagnosticsBundle, + expectedIdentity: DiagnosticsCaptureContext, + operationGeneration: Long, + requireAlwaysConsent: Boolean, + ): DiagnosticsUploadDecision { + val wireReportId = report.id.toHostedWireReportIdOrNull() ?: run { + markPermanent(report.id, "invalid_report_id") + return DiagnosticsUploadDecision.KeptInvalid + } + val installations = hostedInstallations ?: return DiagnosticsUploadDecision.KeptUnavailable + val credentials = installations.getOrCreate() ?: run { + markRetryable(report.id, "installation_unavailable") + return DiagnosticsUploadDecision.KeptRetryable + } + return uploadHosted( + report, + bundle, + wireReportId, + credentials, + expectedIdentity, + operationGeneration, + requireAlwaysConsent, + ) + } + + private suspend fun uploadHosted( + report: PendingReport, + bundle: DiagnosticsBundle, + wireReportId: String, + credentials: HostedDiagnosticsCredentials, + expectedIdentity: DiagnosticsCaptureContext, + operationGeneration: Long, + requireAlwaysConsent: Boolean, + ): DiagnosticsUploadDecision { + val hostedApi = hostedApi ?: return DiagnosticsUploadDecision.KeptUnavailable + val createAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !report.canUploadUnder(expectedIdentity) -> HostedCreateAttempt.IdentityChanged + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) -> HostedCreateAttempt.Revoked + reports.load(report.id) == null -> HostedCreateAttempt.ReportRemoved + else -> HostedCreateAttempt.Sent( + hostedApi.createReport( + installationToken = credentials.installationToken, + request = HostedDiagnosticsCreateReportRequest( + reportId = wireReportId, + manifest = bundle.manifest, + bundleBytes = bundle.bytes.size.toLong(), + bundleSha256 = bundle.manifest.archive.sha256, + ), + ), + ) + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val createResult = when (createAttempt) { + HostedCreateAttempt.IdentityChanged -> return DiagnosticsUploadDecision.KeptIdentityChanged + HostedCreateAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + HostedCreateAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is HostedCreateAttempt.Sent -> createAttempt.result + } + val created = when (val result = createResult) { + is HostedDiagnosticsApiResult.Success -> result.value + is HostedDiagnosticsApiResult.Failure -> { + if (result.errorCode == "report_conflict") { + return reconcileHostedConflict( + report = report, + wireReportId = wireReportId, + credentials = credentials, + expectedIdentity = expectedIdentity, + operationGeneration = operationGeneration, + requireAlwaysConsent = requireAlwaysConsent, + ) + } + if (result.errorCode == "invalid_installation_token") { + runCatching { hostedInstallations?.recoverIfInvalid(credentials) } + } + return mapHostedError(report, result) + } + is HostedDiagnosticsApiResult.NetworkError -> { + markRetryable(report.id, "network") + return DiagnosticsUploadDecision.KeptRetryable + } + } + if (created.reportId != wireReportId || created.shortId.isBlank() || created.uploadToken.isBlank()) { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + val uploadAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !report.canUploadUnder(expectedIdentity) -> HostedUploadAttempt.IdentityChanged + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) -> HostedUploadAttempt.Revoked + reports.load(report.id) == null -> HostedUploadAttempt.ReportRemoved + else -> { + // Starting the full-bundle PUT is itself a privacy boundary. + // A revocation either prevents it or waits for it to finish. + HostedUploadAttempt.Sent( + hostedApi.uploadBundle( + installationToken = credentials.installationToken, + reportId = wireReportId, + uploadToken = created.uploadToken, + bundle = bundle.bytes, + ), + ) + } + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val uploaded = when (uploadAttempt) { + HostedUploadAttempt.IdentityChanged -> return DiagnosticsUploadDecision.KeptIdentityChanged + HostedUploadAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + HostedUploadAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is HostedUploadAttempt.Sent -> uploadAttempt.result + } + val uploadReceipt = when (uploaded) { + is HostedDiagnosticsApiResult.Success -> uploaded.value + is HostedDiagnosticsApiResult.Failure -> { + if (uploaded.errorCode == "invalid_installation_token") { + runCatching { hostedInstallations?.recoverIfInvalid(credentials) } + } + return mapHostedError(report, uploaded) + } + is HostedDiagnosticsApiResult.NetworkError -> { + markRetryable(report.id, "network") + return DiagnosticsUploadDecision.KeptRetryable + } + } + val uploadShortId = uploadReceipt.shortId?.takeIf(String::isNotBlank) ?: run { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + if ( + uploadReceipt.reportId != wireReportId || + uploadShortId != created.shortId || + uploadReceipt.state !in HOSTED_DURABLY_ACCEPTED_STATES + ) { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + // Persist the remote identity immediately after the first validated + // durable receipt so an eventual rejection can still be deleted from + // the collector before local evidence is removed. + if ( + identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.markHostedProcessing(report.id, uploadShortId) + true + } + } + } != true + ) { + return DiagnosticsUploadDecision.KeptIdentityChanged + } + + val statusAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) -> HostedStatusAttempt.Revoked + reports.load(report.id) == null -> HostedStatusAttempt.ReportRemoved + else -> HostedStatusAttempt.Sent( + hostedApi.reportStatus(credentials.installationToken, wireReportId), + ) + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val statusResult = when (statusAttempt) { + HostedStatusAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + HostedStatusAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is HostedStatusAttempt.Sent -> statusAttempt.result + } + val state = when (val status = statusResult) { + is HostedDiagnosticsApiResult.Success -> { + if ( + status.value.reportId != wireReportId || + status.value.shortId?.takeIf(String::isNotBlank) != uploadShortId + ) { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + when (status.value.state) { + HostedDiagnosticsReportState.REJECTED, + HostedDiagnosticsReportState.DELETING, + HostedDiagnosticsReportState.DELETED, + -> { + markPermanent(report.id, status.value.errorCode ?: status.value.state.wireValue) + return DiagnosticsUploadDecision.KeptInvalid + } + in HOSTED_DURABLY_ACCEPTED_STATES -> status.value.state + else -> { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + } + } + // Only a validated durable-acceptance receipt permits this fallback. + is HostedDiagnosticsApiResult.Failure, + is HostedDiagnosticsApiResult.NetworkError, + -> uploadReceipt.state + } + if (state == HostedDiagnosticsReportState.READY) { + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.recordHostedReadyAndDelete(report.id, report.binding, uploadShortId) + runCatching { + sentRecorder.record(report.binding.binding, uploadShortId, nowMs(), state.wireValue) + } + true + } + } + } + return if (finalized != true) { + DiagnosticsUploadDecision.KeptIdentityChanged + } else { + DiagnosticsUploadDecision.Uploaded(uploadShortId, state) + } + } + return DiagnosticsUploadDecision.HostedProcessing(uploadShortId) + } + + private suspend fun pollHostedStatus( + report: PendingReport, + operationGeneration: Long, + ): DiagnosticsUploadDecision { + val expectedShortId = report.state.hostedRemoteShortId ?: return DiagnosticsUploadDecision.KeptRetryable + val wireReportId = report.id.toHostedWireReportIdOrNull() ?: run { + markPermanent(report.id, "invalid_report_id") + return DiagnosticsUploadDecision.KeptInvalid + } + val credentials = hostedInstallations?.credentialsForOutstanding()?.firstOrNull() ?: run { + markRetryable(report.id, "installation_unavailable") + return DiagnosticsUploadDecision.KeptRetryable + } + val api = hostedApi ?: return DiagnosticsUploadDecision.KeptUnavailable + val statusAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !transportPolicy.permits( + report.binding, + report.manifest.consent.noticeVersion, + false, + ) -> HostedStatusAttempt.Revoked + reports.load(report.id) == null -> HostedStatusAttempt.ReportRemoved + else -> HostedStatusAttempt.Sent( + reportHostedStatusWithFallback(api, credentials, wireReportId), + ) + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val result = when (statusAttempt) { + HostedStatusAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + HostedStatusAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is HostedStatusAttempt.Sent -> statusAttempt.result + } + return when (result) { + is HostedDiagnosticsApiResult.NetworkError -> { + reports.markHostedProcessing(report.id, expectedShortId) + DiagnosticsUploadDecision.KeptRetryable + } + is HostedDiagnosticsApiResult.Failure -> { + if (result.errorCode == "invalid_installation_token") { + runCatching { hostedInstallations?.recoverIfInvalid(credentials) } + } + reports.markHostedProcessing(report.id, expectedShortId) + DiagnosticsUploadDecision.KeptRetryable + } + is HostedDiagnosticsApiResult.Success -> { + val status = result.value + if ( + status.reportId != wireReportId || + status.shortId?.takeIf(String::isNotBlank) != expectedShortId + ) { + markPermanent(report.id, "invalid_response") + return DiagnosticsUploadDecision.KeptInvalid + } + when (status.state) { + HostedDiagnosticsReportState.READY -> { + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + report.manifest.consent.noticeVersion, + false, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.recordHostedReadyAndDelete(report.id, report.binding, expectedShortId) + runCatching { + sentRecorder.record(report.binding.binding, expectedShortId, nowMs(), "ready") + } + true + } + } + } + if (finalized != true) { + DiagnosticsUploadDecision.KeptIdentityChanged + } else { + DiagnosticsUploadDecision.Uploaded(expectedShortId) + } + } + HostedDiagnosticsReportState.PROCESSING -> { + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + report.manifest.consent.noticeVersion, + false, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.markHostedProcessing(report.id, expectedShortId) + true + } + } + } + if (finalized != true) { + DiagnosticsUploadDecision.KeptIdentityChanged + } else { + DiagnosticsUploadDecision.HostedProcessing(expectedShortId) + } + } + HostedDiagnosticsReportState.REJECTED, + HostedDiagnosticsReportState.DELETING, + HostedDiagnosticsReportState.DELETED, + -> { + // Keep the last local evidence copy. The collector may + // have removed its unvalidated raw object already. + markPermanent(report.id, status.errorCode ?: status.state.wireValue) + DiagnosticsUploadDecision.KeptInvalid + } + else -> { + markPermanent(report.id, "invalid_response") + DiagnosticsUploadDecision.KeptInvalid + } + } + } + } + } + + private suspend fun reconcileHostedConflict( + report: PendingReport, + wireReportId: String, + credentials: HostedDiagnosticsCredentials, + expectedIdentity: DiagnosticsCaptureContext, + operationGeneration: Long, + requireAlwaysConsent: Boolean, + ): DiagnosticsUploadDecision { + val api = hostedApi ?: return DiagnosticsUploadDecision.KeptUnavailable + val statusAttempt = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + when { + !report.canUploadUnder(expectedIdentity) -> HostedStatusAttempt.Revoked + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) -> HostedStatusAttempt.Revoked + reports.load(report.id) == null -> HostedStatusAttempt.ReportRemoved + else -> HostedStatusAttempt.Sent( + reportHostedStatusWithFallback(api, credentials, wireReportId), + ) + } + } + } ?: return DiagnosticsUploadDecision.KeptIdentityChanged + val result = when (statusAttempt) { + HostedStatusAttempt.Revoked -> return DiagnosticsUploadDecision.KeptConsentReviewRequired + HostedStatusAttempt.ReportRemoved -> return DiagnosticsUploadDecision.KeptInvalid + is HostedStatusAttempt.Sent -> statusAttempt.result + } + if (result is HostedDiagnosticsApiResult.Failure && result.errorCode == "invalid_installation_token") { + runCatching { hostedInstallations?.recoverIfInvalid(credentials) } + } + if (result !is HostedDiagnosticsApiResult.Success) { + markRetryable(report.id, "report_conflict") + return DiagnosticsUploadDecision.KeptRetryable + } + val status = result.value + val shortId = status.shortId?.takeIf(String::isNotBlank) + if (status.reportId != wireReportId || shortId == null) { + markRetryable(report.id, "report_conflict") + return DiagnosticsUploadDecision.KeptRetryable + } + return when (status.state) { + HostedDiagnosticsReportState.PROCESSING -> { + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.markHostedProcessing(report.id, shortId) + true + } + } + } + if (finalized == true) { + DiagnosticsUploadDecision.HostedProcessing(shortId) + } else { + DiagnosticsUploadDecision.KeptIdentityChanged + } + } + HostedDiagnosticsReportState.READY -> { + val finalized = identityTransitions.withCurrentGeneration(operationGeneration) { + privacyBarrier.withTransport { + if ( + !transportPolicy.permits( + report.binding, + expectedIdentity.noticeVersion, + requireAlwaysConsent, + ) || reports.load(report.id) == null + ) { + false + } else { + reports.recordHostedReadyAndDelete(report.id, report.binding, shortId) + runCatching { + sentRecorder.record( + report.binding.binding, + shortId, + nowMs(), + status.state.wireValue, + ) + } + true + } + } + } + if (finalized == true) { + DiagnosticsUploadDecision.Uploaded(shortId, status.state) + } else { + DiagnosticsUploadDecision.KeptIdentityChanged + } + } + HostedDiagnosticsReportState.REJECTED, + HostedDiagnosticsReportState.DELETING, + HostedDiagnosticsReportState.DELETED, + -> { + markPermanent(report.id, status.errorCode ?: status.state.wireValue) + DiagnosticsUploadDecision.KeptInvalid + } + HostedDiagnosticsReportState.RECEIVING, + HostedDiagnosticsReportState.UPLOADED, + -> { + markRetryable(report.id, "report_conflict") + DiagnosticsUploadDecision.KeptRetryable + } + } + } + + private suspend fun reportHostedStatusWithFallback( + api: HostedDiagnosticsApi, + preferred: HostedDiagnosticsCredentials, + wireReportId: String, + ): HostedDiagnosticsApiResult { + var lastResult: HostedDiagnosticsApiResult? = null + hostedInstallations?.credentialsForOutstanding(preferred).orEmpty().forEach { credentials -> + val result = api.reportStatus(credentials.installationToken, wireReportId) + if (result is HostedDiagnosticsApiResult.Success) return result + lastResult = result + } + return checkNotNull(lastResult) { "hosted status requires installation credentials" } + } + + private suspend fun mapHostedError( + report: PendingReport, + error: HostedDiagnosticsApiResult.Failure, + ): DiagnosticsUploadDecision { + val code = error.errorCode.ifBlank { "unknown" } + if (code == "stale_consent") { + return try { + reports.markHostedConsentRefreshRequired(report.id) + staleConsentHandler.demote(report.binding.binding, report.manifest.consent.noticeVersion) + markRetryable(report.id, code) + DiagnosticsUploadDecision.KeptConsentReviewRequired + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + markRetryable(report.id, code) + DiagnosticsUploadDecision.KeptRetryable + } + } + val decision = when { + code in HOSTED_TOO_LARGE_ERRORS -> DiagnosticsUploadDecision.KeptTooLarge + code == "unsupported_schema" -> DiagnosticsUploadDecision.KeptServerUpdateRequired + code == "disabled" || code == "storage_unavailable" -> DiagnosticsUploadDecision.KeptUnavailable + code in HOSTED_PERMANENT_ERRORS -> DiagnosticsUploadDecision.KeptInvalid + code in HOSTED_RETRYABLE_ERRORS || + (code == "invalid_response" && error.httpStatus == 202) || + error.httpStatus == 429 || error.httpStatus >= 500 -> { + DiagnosticsUploadDecision.KeptRetryable + } + else -> DiagnosticsUploadDecision.KeptInvalid + } + if (decision == DiagnosticsUploadDecision.KeptRetryable) { + error.retryAfterSeconds?.coerceIn(0, MAX_RETRY_AFTER_SECONDS)?.let { seconds -> + reports.setRetryAfterDeadlineForReport( + report.id, + report.binding.binding, + nowMs() + seconds * 1_000L, + ) + } + } + when (decision) { + DiagnosticsUploadDecision.KeptRetryable, + DiagnosticsUploadDecision.KeptUnavailable, + -> markRetryable(report.id, code) + else -> markPermanent(report.id, code) } + return decision } private suspend fun mapServerError( @@ -224,7 +1009,11 @@ class DefaultDiagnosticsUploader( } if (decision == DiagnosticsUploadDecision.KeptRetryable) { error.retryAfterSeconds?.coerceIn(0, MAX_RETRY_AFTER_SECONDS)?.let { seconds -> - reports.setRetryAfterDeadline(report.binding.binding, nowMs() + seconds * 1_000L) + reports.setRetryAfterDeadlineForReport( + report.id, + report.binding.binding, + nowMs() + seconds * 1_000L, + ) } } val code = error.code.wire @@ -273,14 +1062,115 @@ class DefaultDiagnosticsUploader( private companion object { const val MAX_RETRY_AFTER_SECONDS = 7L * 24 * 60 * 60 + val HOSTED_RETRYABLE_ERRORS = setOf( + "busy", + "quota_exceeded", + "rate_limited", + "internal_error", + "invalid_upload_token", + "upload_cancelled", + "invalid_installation_token", + ) + val HOSTED_TOO_LARGE_ERRORS = setOf( + "bundle_too_large", + "manifest_too_large", + "compression_ratio_exceeded", + ) + val HOSTED_DURABLY_ACCEPTED_STATES = setOf( + HostedDiagnosticsReportState.PROCESSING, + HostedDiagnosticsReportState.READY, + ) + val HOSTED_PERMANENT_ERRORS = setOf( + "invalid_request", + "unexpected_field", + "invalid_report_id", + "invalid_bundle_size", + "invalid_bundle_sha256", + "invalid_manifest", + "hosted_consent_required", + "privacy_field_rejected", + "privacy_value_rejected", + "privacy_artifact_rejected", + "wrong_destination", + "archive_metadata_mismatch", + "upload_attempt_limit_exceeded", + "unsupported_media_type", + "size_mismatch", + ) + } + + private sealed interface HostedCreateAttempt { + data object IdentityChanged : HostedCreateAttempt + data object Revoked : HostedCreateAttempt + data object ReportRemoved : HostedCreateAttempt + data class Sent( + val result: HostedDiagnosticsApiResult, + ) : HostedCreateAttempt + } + + private sealed interface SelfHostedUploadAttempt { + data object IdentityChanged : SelfHostedUploadAttempt + data object Revoked : SelfHostedUploadAttempt + data object ReportRemoved : SelfHostedUploadAttempt + data class Sent(val result: DiagnosticsUploadResult) : SelfHostedUploadAttempt + } + + private sealed interface HostedUploadAttempt { + data object IdentityChanged : HostedUploadAttempt + data object Revoked : HostedUploadAttempt + data object ReportRemoved : HostedUploadAttempt + data class Sent( + val result: HostedDiagnosticsApiResult, + ) : HostedUploadAttempt + } + + private sealed interface HostedStatusAttempt { + data object Revoked : HostedStatusAttempt + data object ReportRemoved : HostedStatusAttempt + data class Sent( + val result: HostedDiagnosticsApiResult, + ) : HostedStatusAttempt } } +private fun DiagnosticsUploadAuthorization.matches( + context: DiagnosticsCaptureContext, + expectedGeneration: Long, +): Boolean = + identityGeneration == expectedGeneration && + context.ownershipGeneration == expectedGeneration && + context.localServerId?.let { it == serverId } == true && + activeProfileId == context.profileId + private fun PendingReport.canUploadUnder(context: DiagnosticsCaptureContext): Boolean = context.profileEligible && + binding.destinationKind == context.destinationKind && binding.matches(context) && manifest.destination.serverInstanceId == context.binding.serverInstanceId +private fun DiagnosticsCaptureContext.withHostedCapabilities( + capabilities: HostedDiagnosticsCapabilities?, +): DiagnosticsCaptureContext? { + if (capabilities == null) return this + if ( + destinationKind != DiagnosticsDestinationKind.HOSTED || + capabilities.collectorId != HOSTED_DIAGNOSTICS_COLLECTOR_ID || + binding.serverInstanceId != capabilities.collectorId + ) return null + return copy( + noticeVersion = capabilities.consentNoticeVersion, + status = when (capabilities.status) { + HostedDiagnosticsAvailability.AVAILABLE -> DiagnosticsAvailabilityStatus.AVAILABLE + HostedDiagnosticsAvailability.DISABLED -> DiagnosticsAvailabilityStatus.DISABLED + HostedDiagnosticsAvailability.STORAGE_UNAVAILABLE -> DiagnosticsAvailabilityStatus.STORAGE_UNAVAILABLE + }, + acceptedSchemaVersions = capabilities.acceptedSchemaVersions.toSet(), + maxBundleBytes = capabilities.maxBundleBytes, + maxManifestBytes = capabilities.maxManifestBytes, + retentionDays = capabilities.retentionDays, + ) +} + private fun PendingReport.canUploadWithConsent( mode: DiagnosticsConsentMode, requireAlways: Boolean, @@ -295,20 +1185,41 @@ private fun DiagnosticsConsentMode.rejectedUploadDecision(requireAlways: Boolean DiagnosticsUploadDecision.KeptUnavailable } -private fun PendingReport.withCurrentConsent( +internal fun PendingReport.withCurrentConsent( mode: DiagnosticsConsentMode, noticeVersion: Int, ): PendingReport { + val hosted = binding.destinationKind == DiagnosticsDestinationKind.HOSTED val manifestMode = if (manifest.report.type == DiagnosticsReportType.MANUAL) { org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.MANUAL - } else if (mode == DiagnosticsConsentMode.ALWAYS) { + } else if (!hosted && mode == DiagnosticsConsentMode.ALWAYS) { org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.ALWAYS } else { org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.PROMPT } return copy( manifest = manifest.copy( + report = if (hosted) manifest.report.copy(profileId = null) else manifest.report, consent = org.prairieserver.prairie.model.diagnostics.DiagnosticsConsent(manifestMode, noticeVersion), + playbackSessionIds = if (hosted) emptyList() else manifest.playbackSessionIds, ), ) } + +internal fun String.toHostedWireReportIdOrNull(): String? { + val local = lowercase() + if (!LOCAL_REPORT_ID.matches(local)) return null + return buildString(36) { + append(local, 0, 8) + append('-') + append(local, 8, 12) + append('-') + append(local, 12, 16) + append('-') + append(local, 16, 20) + append('-') + append(local, 20, 32) + } +} + +private val LOCAL_REPORT_ID = Regex("[0-9a-f]{32}") diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsViewModel.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsViewModel.kt index 7f0f6466e..653c0380a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsViewModel.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsViewModel.kt @@ -24,6 +24,10 @@ class DiagnosticsViewModel( viewModelScope.launch { coordinator.setConsent(mode) } } + fun setDestination(destinationKind: DiagnosticsDestinationKind) { + viewModelScope.launch { coordinator.setDestination(destinationKind) } + } + fun setDebugLogging(enabled: Boolean) { viewModelScope.launch { coordinator.setDebugLogging(enabled) } } @@ -54,6 +58,7 @@ class DiagnosticsViewModel( val decision = coordinator.upload(reportId, expectedNoticeVersion = prompt.noticeVersion) when (decision) { is DiagnosticsUploadDecision.Uploaded, + is DiagnosticsUploadDecision.HostedProcessing, DiagnosticsUploadDecision.KeptInvalid, DiagnosticsUploadDecision.KeptTooLarge, DiagnosticsUploadDecision.KeptServerUpdateRequired, @@ -79,8 +84,7 @@ class DiagnosticsViewModel( fun delete(reportId: String, onDeleted: () -> Unit = {}) { viewModelScope.launch { - coordinator.delete(reportId) - onDeleted() + if (coordinator.delete(reportId)) onDeleted() } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollector.kt index 91cec3aaf..72d63c8a3 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollector.kt @@ -4,8 +4,11 @@ import android.app.ActivityManager import android.app.ApplicationExitInfo import android.content.Context import android.os.Build +import android.system.Os +import android.system.OsConstants import java.io.ByteArrayOutputStream import java.io.File +import java.io.FileDescriptor import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.security.MessageDigest @@ -116,40 +119,190 @@ interface JvmCrashMarkerSource { } } -class FileJvmCrashMarkerSource(noBackupFilesDir: File) : JvmCrashMarkerSource { +class FileJvmCrashMarkerSource internal constructor( + noBackupFilesDir: File, + private val nowMs: () -> Long = System::currentTimeMillis, + private val fileGate: JvmCrashMarkerFileGate, + private val deleteFile: (File) -> Boolean, + private val syncDirectory: (File) -> Unit, + private val listFiles: (File) -> Array?, +) : JvmCrashMarkerSource { private val directory = noBackupFilesDir.resolve("client-diagnostics/crash-markers") - override fun records(): List { - directory.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }.forEach(File::delete) - val files = directory.listFiles().orEmpty() - .filter { it.isFile && MARKER_NAME.matches(it.name) && it.length() in 1..CrashMarkerRenderer.MAX_MARKER_BYTES.toLong() } - .sortedBy(File::lastModified) - files.dropLast(MAX_MARKERS).forEach(File::delete) - return files.takeLast(MAX_MARKERS) - .mapNotNull { file -> - runCatching { JSON.decodeFromString(file.readText()) } - .getOrNull() - ?.takeIf { marker -> marker.schemaVersion == 1 && marker.occurredAtEpochMs >= 0 } - ?.copy(sourceFileName = file.name) - } - .sortedBy(JvmCrashMarkerRecord::occurredAtEpochMs) + constructor(noBackupFilesDir: File) : this( + noBackupFilesDir = noBackupFilesDir, + nowMs = System::currentTimeMillis, + fileGate = JVM_CRASH_MARKER_FILE_GATE, + deleteFile = File::delete, + syncDirectory = ::syncJvmCrashMarkerDirectory, + listFiles = File::listFiles, + ) + + /** + * Enforces the raw-marker retention boundary without turning a marker into a report. This is + * called before network or identity resolution on every coordinator refresh, including while + * offline, ineligible, or opted out. + */ + fun reconcile() { + fileGate.withLock { reconciledRecordsLocked() } } + override fun records(): List = + fileGate.withLock { reconciledRecordsLocked() } + override fun delete(marker: JvmCrashMarkerRecord) { - marker.sourceFileName - ?.takeIf(MARKER_NAME::matches) - ?.let(directory::resolve) - ?.takeIf(File::exists) - ?.delete() + fileGate.withLock { + val sourceFileName = marker.sourceFileName + ?.takeIf(MARKER_NAME::matches) + ?: return@withLock + val file = checkNotNull(listFiles(directory)) { + "unable to enumerate JVM crash markers" + }.firstOrNull { entry -> entry.name == sourceFileName } ?: return@withLock + deleteStrict(file) + syncAndVerify(files = listOf(file)) + } + } + + override fun purge(binding: DiagnosticsBinding) { + fileGate.withLock { + if (!directory.exists()) return@withLock + check(!isSymbolicLink(directory)) { "JVM crash marker path is a symbolic link" } + check(directory.isDirectory) { "JVM crash marker path is not a directory" } + val removed = checkNotNull(listFiles(directory)) { + "unable to enumerate JVM crash markers" + } + .filter { file -> + when { + !isBoundedMarkerFile(file) -> true + else -> decodeMarker(file)?.binding?.binding?.let { it == binding } ?: true + } + } + removed.forEach(::deleteStrict) + syncAndVerify(removed) + } } + fun purgeAll() { + fileGate.withLock { + if (!directory.exists()) return@withLock + check(!isSymbolicLink(directory)) { "JVM crash marker path is a symbolic link" } + check(directory.isDirectory) { "JVM crash marker path is not a directory" } + val removed = checkNotNull(listFiles(directory)) { + "unable to enumerate JVM crash markers" + }.toList() + removed.forEach(::deleteStrict) + syncAndVerify(removed) + } + } + + private fun isBoundedMarkerFile(file: File): Boolean = + file.isFile && + !isSymbolicLink(file) && + MARKER_NAME.matches(file.name) && + file.length() in 1..CrashMarkerRenderer.MAX_MARKER_BYTES.toLong() + + private fun isSymbolicLink(file: File): Boolean = + File(checkNotNull(file.parentFile).canonicalFile, file.name).let { canonicalParentEntry -> + canonicalParentEntry.absoluteFile != canonicalParentEntry.canonicalFile + } + + private fun reconciledRecordsLocked(): List { + if (!directory.exists()) return emptyList() + check(!isSymbolicLink(directory)) { "JVM crash marker path is a symbolic link" } + check(directory.isDirectory) { "JVM crash marker path is not a directory" } + val files = checkNotNull(listFiles(directory)) { + "unable to enumerate JVM crash markers" + }.toList() + val now = nowMs() + check(now >= 0) { "JVM crash marker clock must be non-negative" } + val invalid = mutableListOf() + val decoded = buildList { + files.forEach { file -> + if (!isBoundedMarkerFile(file)) { + invalid += file + return@forEach + } + val marker = decodeMarker(file) + if (marker == null || !isWithinRetention(marker.occurredAtEpochMs, now)) { + invalid += file + } else { + add(file to marker) + } + } + } + val retained = decoded + .sortedWith(compareBy>( + { (_, marker) -> marker.occurredAtEpochMs }, + { (file, _) -> file.name }, + )) + .takeLast(MAX_MARKERS) + val retainedFiles = retained.mapTo(mutableSetOf()) { (file, _) -> file } + val removed = invalid + decoded.map(Pair::first) + .filterNot(retainedFiles::contains) + removed.forEach(::deleteStrict) + syncAndVerify(removed) + return retained.map(Pair::second) + } + + private fun isWithinRetention(occurredAtEpochMs: Long, nowEpochMs: Long): Boolean { + val oldestAllowed = (nowEpochMs - RETENTION_MS).coerceAtLeast(0) + val newestAllowed = if (nowEpochMs > Long.MAX_VALUE - MAX_FUTURE_SKEW_MS) { + Long.MAX_VALUE + } else { + nowEpochMs + MAX_FUTURE_SKEW_MS + } + return occurredAtEpochMs in oldestAllowed..newestAllowed + } + + private fun decodeMarker(file: File): JvmCrashMarkerRecord? = + runCatching { JSON.decodeFromString(file.readText()) } + .getOrNull() + ?.takeIf { marker -> + marker.schemaVersion == 1 && + marker.occurredAtEpochMs >= 0 && + marker.occurredAtEpochMs == markerTimestamp(file.name) + } + ?.copy(sourceFileName = file.name) + + private fun markerTimestamp(fileName: String): Long? = + MARKER_NAME.matchEntire(fileName)?.groupValues?.get(1)?.toLongOrNull() + + private fun deleteStrict(file: File) { + check(deleteFile(file)) { "unable to delete JVM crash marker ${file.name}" } + check(!directoryEntryExists(file.name)) { "JVM crash marker still exists after deletion: ${file.name}" } + } + + private fun syncAndVerify(files: List) { + if (files.isEmpty()) return + syncDirectory(directory) + check(files.none { file -> directoryEntryExists(file.name) }) { + "JVM crash marker deletion was not durable" + } + } + + private fun directoryEntryExists(name: String): Boolean = + checkNotNull(listFiles(directory)) { "unable to verify JVM crash marker deletion" } + .any { entry -> entry.name == name } + private companion object { const val MAX_MARKERS = 3 - val MARKER_NAME = Regex("^jvm-[0-9]+-[0-9]+\\.json$") + const val RETENTION_MS = PENDING_DIAGNOSTICS_RETENTION_DAYS * 24L * 60 * 60 * 1_000 + const val MAX_FUTURE_SKEW_MS = 5L * 60 * 1_000 + val MARKER_NAME = Regex("^jvm-([0-9]+)-[0-9]+\\.json$") val JSON = Json { ignoreUnknownKeys = true; explicitNulls = false } } } +private fun syncJvmCrashMarkerDirectory(directory: File) { + var descriptor: FileDescriptor? = null + try { + descriptor = Os.open(directory.absolutePath, OsConstants.O_RDONLY, 0) + Os.fsync(checkNotNull(descriptor)) + } finally { + descriptor?.let(Os::close) + } +} + data class ExitReportEnvironment( val appVersion: String, val appBuild: String, @@ -180,13 +333,16 @@ class ExitInfoCollector( val trace = runCatching { record.trace(MAX_TRACE_BYTES) }.getOrNull() exits += CollectedExit(record, trace, run) } - val markerRecords = runCatching(markers::records).getOrDefault(emptyList()) + val markerRecords = markers.records() val saved = mutableListOf() markerRecords.forEach { marker -> - val runToken = marker.runToken ?: return@forEach - val run = ledger.find(runToken) ?: return@forEach - if (!run.profileEligible || !marker.matches(run)) return@forEach + val runToken = marker.runToken + val run = runToken?.let { ledger.find(it) } + if (run == null || !run.profileEligible || !marker.matches(run)) { + markers.delete(marker) + return@forEach + } val matchingExit = exits.firstOrNull { exit -> exit.record.reason == AndroidExitReason.JVM_CRASH && exit.run.token == runToken && @@ -194,12 +350,12 @@ class ExitInfoCollector( } val fingerprint = matchingExit?.let(::exitFingerprint) ?: markerFingerprint(marker) if (reports.hasSeenFingerprint(fingerprint)) { - runCatching { markers.delete(marker) } + markers.delete(marker) return@forEach } runCatching { saveMarker(marker, run, fingerprint) }.getOrNull()?.let { report -> saved += report - runCatching { markers.delete(marker) } + markers.delete(marker) } } @@ -380,7 +536,11 @@ class ExitInfoCollector( appBuild = environment.appBuild.take(64), platform = environment.platform, osVersion = environment.osVersion.take(128), - profileId = profileId?.take(128), + profileId = if (binding.destinationKind == DiagnosticsDestinationKind.HOSTED) { + null + } else { + profileId?.take(128) + }, ), destination = DiagnosticsDestination(binding.serverInstanceId), consent = DiagnosticsConsent(consentMode(), noticeVersion().coerceAtLeast(1)), @@ -394,7 +554,11 @@ class ExitInfoCollector( occurredAt = rfc3339(capturedAtEpochMs), ), deviceSummary = environment.deviceSummary, - playbackSessionIds = playbackSessionIds.take(20).map { it.take(128) }, + playbackSessionIds = if (binding.destinationKind == DiagnosticsDestinationKind.HOSTED) { + emptyList() + } else { + playbackSessionIds.take(20).map { it.take(128) } + }, logSummary = logSummary, archive = DiagnosticsArchive( entries = CANONICAL_ARCHIVE_ORDER.filter { it == "manifest.json" || it in artifacts }, @@ -422,6 +586,7 @@ class ExitInfoCollector( accountUserId = binding.accountUserId, profileId = profileId, ownershipGeneration = ownershipGeneration, + destinationKind = destinationKind, ) private fun DiagnosticsRunRecord.identityKey() = DiagnosticsIdentityKey( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnostics.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnostics.kt new file mode 100644 index 000000000..5349fc391 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnostics.kt @@ -0,0 +1,489 @@ +package org.prairieserver.prairie.common.diagnostics + +import android.content.SharedPreferences +import android.util.Base64 +import java.net.URI +import java.security.MessageDigest +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus +import org.prairieserver.prairie.model.diagnostics.DiagnosticsPlatform +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.HostedDiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApiResult +import org.prairieserver.prairie.network.api.HostedDiagnosticsAvailability +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationResponse + +enum class DiagnosticsDestinationKind( + val allowsAutomaticUpload: Boolean, + val defaultRetentionDays: Int, +) { + HOSTED(allowsAutomaticUpload = false, defaultRetentionDays = 30), + SELF_HOSTED(allowsAutomaticUpload = true, defaultRetentionDays = 7), +} + +const val HOSTED_DIAGNOSTICS_COLLECTOR_ID = "silo-public-diagnostics-v1" +const val HOSTED_DIAGNOSTICS_RETENTION_DAYS = 30 + +interface HostedDiagnosticsCapabilitiesStore { + suspend fun load(): HostedDiagnosticsCapabilities? + suspend fun save(capabilities: HostedDiagnosticsCapabilities) +} + +interface HostedDiagnosticsBindingOwnerStore { + suspend fun load(localServerId: String): String? + suspend fun save(localServerId: String, owner: String) +} + +class HostedDiagnosticsCapabilitiesRepository( + private val store: HostedDiagnosticsCapabilitiesStore, + private val api: HostedDiagnosticsApi, +) { + suspend fun local(): HostedDiagnosticsCapabilities = + store.load()?.takeIf { it.isUsable() } ?: conservativeDefaults() + + suspend fun refresh(): HostedDiagnosticsApiResult = + when (val result = api.capabilities()) { + is HostedDiagnosticsApiResult.Success -> { + if (!result.value.isUsable()) { + HostedDiagnosticsApiResult.Failure(502, "invalid_capabilities", "Invalid collector capabilities") + } else { + runCatching { store.save(result.value) } + result + } + } + is HostedDiagnosticsApiResult.Failure -> result + is HostedDiagnosticsApiResult.NetworkError -> result + } + + private fun conservativeDefaults() = HostedDiagnosticsCapabilities( + status = HostedDiagnosticsAvailability.AVAILABLE, + collectorId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + acceptedSchemaVersions = listOf(1), + maxBundleBytes = 10L * 1_024 * 1_024, + maxManifestBytes = 64L * 1_024, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + consentNoticeVersion = 1, + ) + + private fun HostedDiagnosticsCapabilities.isUsable(): Boolean = + collectorId == HOSTED_DIAGNOSTICS_COLLECTOR_ID && + 1 in acceptedSchemaVersions && + maxBundleBytes > 0 && + maxManifestBytes > 0 && + retentionDays == HOSTED_DIAGNOSTICS_RETENTION_DAYS && + consentNoticeVersion > 0 +} + +data class HostedDiagnosticsCredentials( + val installationId: String, + val installationToken: String, +) { + init { + require(installationId.isNotBlank()) + require(installationToken.isNotBlank()) + } +} + +interface HostedDiagnosticsCredentialStore { + suspend fun load(): HostedDiagnosticsCredentials? + suspend fun save(credentials: HostedDiagnosticsCredentials) + suspend fun loadFallbacks(): List = emptyList() + suspend fun saveFallback(credentials: HostedDiagnosticsCredentials) = Unit + suspend fun clear() +} + +/** The injected preferences instance is the app's Android Keystore-backed encrypted store. */ +class EncryptedPreferencesHostedDiagnosticsCredentialStore( + private val encryptedPreferences: SharedPreferences, +) : HostedDiagnosticsCredentialStore { + override suspend fun load(): HostedDiagnosticsCredentials? = synchronized(encryptedPreferences) { + val id = encryptedPreferences.getString(INSTALLATION_ID_KEY, null)?.takeIf(String::isNotBlank) + val token = encryptedPreferences.getString(INSTALLATION_TOKEN_KEY, null)?.takeIf(String::isNotBlank) + if (id == null || token == null) null else HostedDiagnosticsCredentials(id, token) + } + + override suspend fun save(credentials: HostedDiagnosticsCredentials) { + check( + encryptedPreferences.edit() + .putString(INSTALLATION_ID_KEY, credentials.installationId) + .putString(INSTALLATION_TOKEN_KEY, credentials.installationToken) + .commit(), + ) { "unable to persist hosted diagnostics credentials" } + } + + override suspend fun loadFallbacks(): List = + synchronized(encryptedPreferences) { + val id = encryptedPreferences.getString(FALLBACK_INSTALLATION_ID_KEY, null) + ?.takeIf(String::isNotBlank) + val token = encryptedPreferences.getString(FALLBACK_INSTALLATION_TOKEN_KEY, null) + ?.takeIf(String::isNotBlank) + if (id == null || token == null) emptyList() else listOf(HostedDiagnosticsCredentials(id, token)) + } + + override suspend fun saveFallback(credentials: HostedDiagnosticsCredentials) { + check( + encryptedPreferences.edit() + .putString(FALLBACK_INSTALLATION_ID_KEY, credentials.installationId) + .putString(FALLBACK_INSTALLATION_TOKEN_KEY, credentials.installationToken) + .commit(), + ) { "unable to persist fallback hosted diagnostics credentials" } + } + + override suspend fun clear() { + check( + encryptedPreferences.edit() + .remove(INSTALLATION_ID_KEY) + .remove(INSTALLATION_TOKEN_KEY) + .commit(), + ) { "unable to clear hosted diagnostics credentials" } + } + + internal companion object { + const val INSTALLATION_ID_KEY = "diagnostics.hosted.installation_id" + const val INSTALLATION_TOKEN_KEY = "diagnostics.hosted.installation_token" + const val FALLBACK_INSTALLATION_ID_KEY = "diagnostics.hosted.fallback_installation_id" + const val FALLBACK_INSTALLATION_TOKEN_KEY = "diagnostics.hosted.fallback_installation_token" + } +} + +class HostedDiagnosticsInstallationManager( + private val store: HostedDiagnosticsCredentialStore, + private val api: HostedDiagnosticsApi, + private val environment: ExitReportEnvironment, + private val appId: String = "org.prairieserver.prairie", +) { + private val mutex = Mutex() + + suspend fun current(): HostedDiagnosticsCredentials? = store.load() + + suspend fun credentialsForOutstanding( + preferred: HostedDiagnosticsCredentials? = null, + ): List = mutex.withLock { + (listOfNotNull(preferred, store.load()) + store.loadFallbacks()).distinct() + } + + suspend fun getOrCreate(): HostedDiagnosticsCredentials? = mutex.withLock { + store.load()?.let { return@withLock it } + createAndPersist() + } + + suspend fun recoverIfInvalid(rejected: HostedDiagnosticsCredentials): HostedDiagnosticsCredentials? = + mutex.withLock { + val current = store.load() + if (current != null && current != rejected) return@withLock current + if (current == rejected) { + store.saveFallback(rejected) + store.clear() + } + createAndPersist() + } + + private suspend fun createAndPersist(): HostedDiagnosticsCredentials? { + val request = HostedDiagnosticsInstallationRequest( + platform = environment.platform.wireValue(), + appId = appId, + appVersion = environment.appVersion, + appBuild = environment.appBuild, + ) + val created = when (val result = api.createInstallation(request)) { + is HostedDiagnosticsApiResult.Success -> result.value + is HostedDiagnosticsApiResult.Failure, + is HostedDiagnosticsApiResult.NetworkError, + -> return null + } + val credentials = created.toCredentialsOrNull() ?: return null + return try { + store.save(credentials) + credentials + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + } + + private fun HostedDiagnosticsInstallationResponse.toCredentialsOrNull(): HostedDiagnosticsCredentials? = + runCatching { HostedDiagnosticsCredentials(installationId, installationToken) }.getOrNull() +} + +fun interface HostedDiagnosticsReportDeleter { + suspend fun delete(reportId: String): Boolean + + data object None : HostedDiagnosticsReportDeleter { + override suspend fun delete(reportId: String): Boolean = false + } +} + +class DefaultHostedDiagnosticsReportDeleter( + private val api: HostedDiagnosticsApi, + private val installations: HostedDiagnosticsInstallationManager, +) : HostedDiagnosticsReportDeleter { + override suspend fun delete(reportId: String): Boolean { + val wireReportId = reportId.toHostedWireReportIdOrNull() ?: return false + val credentials = installations.credentialsForOutstanding() + for (candidate in credentials) { + when (api.deleteReport(candidate.installationToken, wireReportId)) { + is HostedDiagnosticsApiResult.Success -> return true + is HostedDiagnosticsApiResult.Failure, + is HostedDiagnosticsApiResult.NetworkError, + -> Unit + } + } + return false + } +} + +class DestinationAwareDiagnosticsRedactionTokenProvider( + private val tokenManager: TokenManager, + private val serverRegistry: ServerRegistry? = null, + private val hostedInstallationTokens: suspend () -> List, +) : DiagnosticsRedactionTokenProvider { + override suspend fun tokens(destinationKind: DiagnosticsDestinationKind): List = buildList { + add(tokenManager.getAccessToken()) + add(tokenManager.getRefreshToken()) + add(tokenManager.getProfileToken()) + addAll(hostedInstallationTokens()) + if (destinationKind == DiagnosticsDestinationKind.HOSTED) { + val serverUrls = buildList { + add(tokenManager.getServerUrl()) + serverRegistry?.entries?.value?.forEach { entry -> add(entry.url) } + }.filterNotNull().filter(String::isNotBlank) + serverUrls.forEach { url -> + add(url) + add(runCatching { URI(url).host }.getOrNull()) + } + add(tokenManager.getCurrentServerId()) + add(tokenManager.getProfileId()) + } + }.filterNotNull().filter(String::isNotBlank).distinct() +} + +class HostedDiagnosticsIdentityResolver( + private val tokenManager: TokenManager, + private val identityTransitions: IdentityTransitionBarrier, + private val registry: ServerRegistry, + private val accountProvider: DiagnosticsAccountProvider, + private val profileProvider: DiagnosticsProfileProvider, + private val capabilities: HostedDiagnosticsCapabilitiesRepository, + private val bindingOwners: HostedDiagnosticsBindingOwnerStore, + private val maxAttempts: Int = 3, +) : DiagnosticsIdentityResolver { + init { + require(maxAttempts > 0) + } + + override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + val localCapabilities = capabilities.local() + return resolveWith(requirePersistentCapture, localCapabilities, requireLiveAccount = false) + } + + override suspend fun resolveForCapture(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + val liveCapabilities = when (val result = capabilities.refresh()) { + is HostedDiagnosticsApiResult.Success -> result.value + is HostedDiagnosticsApiResult.Failure, + is HostedDiagnosticsApiResult.NetworkError, + -> return null + } + return resolveWith(requirePersistentCapture, liveCapabilities, requireLiveAccount = false) + } + + override suspend fun resolveForUpload(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + resolveWith(requirePersistentCapture, capabilities.local(), requireLiveAccount = true) + + private suspend fun resolveWith( + requirePersistentCapture: Boolean, + resolvedCapabilities: HostedDiagnosticsCapabilities, + requireLiveAccount: Boolean, + ): DiagnosticsCaptureContext? { + if (requirePersistentCapture && tokenManager.hasTemporaryScope()) return null + for (attempt in 0 until maxAttempts) { + val generation = identityTransitions.generation.value + val source = registry.activeEntry.value?.takeIf { it.id.isNotBlank() && it.url.isNotBlank() } + if (source == null) { + if (identityTransitions.generation.value != generation) continue + return null + } + if (tokenManager.getCurrentServerId() != source.id) { + if (identityTransitions.generation.value != generation) continue + return null + } + if (tokenManager.getServerUrl().trimEnd('/') != source.url.trimEnd('/')) { + if (identityTransitions.generation.value != generation) continue + return null + } + if (tokenManager.getAccessToken().isNullOrBlank()) { + if (identityTransitions.generation.value != generation) continue + return null + } + + val sourceProfileId = tokenManager.getProfileId() + val profileEligible = if (sourceProfileId == null) { + true + } else { + val child = profileProvider.isChild(sourceProfileId) + if (child == null) { + if (identityTransitions.generation.value != generation) continue + return null + } + !child + } + val localBindingOwner = currentLocalBindingOwner(source.id, requireLiveAccount) ?: return null + if (identityTransitions.generation.value != generation) continue + return resolvedCapabilities.toCaptureContext( + sourceServerId = source.id, + sourceProfileId = sourceProfileId, + profileEligible = profileEligible, + generation = generation, + credentialFingerprint = localBindingOwner, + localBindingOwner = localBindingOwner, + ) + } + return null + } + + override suspend fun matchesCachedIdentity(cached: CachedDiagnosticsContext): Boolean { + if (cached.destinationKind != DiagnosticsDestinationKind.HOSTED || tokenManager.hasTemporaryScope()) return false + val sourceServerId = cached.localServerId?.takeIf(String::isNotBlank) ?: return false + val owner = cached.credentialFingerprint?.takeIf(String::isNotBlank) ?: return false + val source = registry.activeEntry.value ?: return false + return source.id == sourceServerId && + tokenManager.getCurrentServerId() == sourceServerId && + tokenManager.getServerUrl().trimEnd('/') == source.url.trimEnd('/') && + !tokenManager.getAccessToken().isNullOrBlank() && + tokenManager.getProfileId() == cached.sourceProfileId && + currentLocalBindingOwner( + sourceServerId, + requireLiveAccount = false, + allowLiveAccountLookup = false, + )?.let { current -> + MessageDigest.isEqual(current.encodeToByteArray(), owner.encodeToByteArray()) + } == true + } + + private fun HostedDiagnosticsCapabilities.toCaptureContext( + sourceServerId: String, + sourceProfileId: String?, + profileEligible: Boolean, + generation: Long, + credentialFingerprint: String?, + localBindingOwner: String, + ): DiagnosticsCaptureContext? { + if ( + collectorId.isBlank() || consentNoticeVersion <= 0 || retentionDays <= 0 || + maxBundleBytes <= 0 || maxManifestBytes <= 0 + ) return null + return DiagnosticsCaptureContext( + // The one-way owner is local sidecar metadata only and is never included + // in the manifest/envelope. It preserves existing cross-account isolation. + binding = DiagnosticsBinding(collectorId, localBindingOwner), + profileId = null, + profileEligible = profileEligible, + noticeVersion = consentNoticeVersion, + status = status.toDiagnosticsStatus(), + ownershipGeneration = generation, + acceptedSchemaVersions = acceptedSchemaVersions.toSet(), + maxBundleBytes = maxBundleBytes, + maxManifestBytes = maxManifestBytes, + retentionDays = retentionDays, + localServerId = sourceServerId, + credentialFingerprint = credentialFingerprint, + sourceProfileId = sourceProfileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + } + + private suspend fun currentLocalBindingOwner( + sourceServerId: String, + requireLiveAccount: Boolean, + allowLiveAccountLookup: Boolean = true, + ): String? { + val accessToken = tokenManager.getAccessToken()?.takeIf(String::isNotBlank) ?: return null + val liveAccountId = if (allowLiveAccountLookup) { + try { + accountProvider.accountUserId()?.takeIf(String::isNotBlank) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + } else { + null + } + if (requireLiveAccount && liveAccountId == null) return null + val tokenAccountId = accessToken.jwtUserIdOrNull() + val owner = (liveAccountId ?: tokenAccountId) + ?.let { userId -> hostedBindingOwner(sourceServerId, userId) } + ?: bindingOwners.load(sourceServerId)?.takeIf(String::isNotBlank) + ?: return null + if (liveAccountId != null || tokenAccountId != null) { + bindingOwners.save(sourceServerId, owner) + } + return owner + } + + private fun hostedBindingOwner(sourceServerId: String, accountUserId: String): String = + "hosted-" + "$sourceServerId|user:$accountUserId".sha256Hex().take(32) + + private fun String.jwtUserIdOrNull(): String? = runCatching { + val segments = split('.') + if (segments.size != 3) return null + val payload = Base64.decode(segments[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + Json.parseToJsonElement(payload.decodeToString()).jsonObject["user_id"] + ?.jsonPrimitive + ?.content + ?.takeIf(String::isNotBlank) + }.getOrNull() + + private fun String.sha256Hex(): String = MessageDigest.getInstance("SHA-256") + .digest(encodeToByteArray()) + .joinToString("") { byte -> (byte.toInt() and 0xff).toString(16).padStart(2, '0') } + +} + +class DestinationDiagnosticsIdentityResolver( + private val destination: suspend () -> DiagnosticsDestinationKind, + private val hosted: DiagnosticsIdentityResolver, + private val selfHosted: DiagnosticsIdentityResolver, +) : DiagnosticsIdentityResolver { + override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + selected().resolve(requirePersistentCapture) + + override suspend fun resolveForCapture(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + selected().resolveForCapture(requirePersistentCapture) + + override suspend fun resolveForUpload(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = + selected().resolveForUpload(requirePersistentCapture) + + override suspend fun matchesCachedIdentity(cached: CachedDiagnosticsContext): Boolean = + when (cached.destinationKind) { + DiagnosticsDestinationKind.HOSTED -> hosted.matchesCachedIdentity(cached) + DiagnosticsDestinationKind.SELF_HOSTED -> selfHosted.matchesCachedIdentity(cached) + } + + private suspend fun selected(): DiagnosticsIdentityResolver = when (destination()) { + DiagnosticsDestinationKind.HOSTED -> hosted + DiagnosticsDestinationKind.SELF_HOSTED -> selfHosted + } +} + +private fun HostedDiagnosticsAvailability.toDiagnosticsStatus(): DiagnosticsAvailabilityStatus = when (this) { + HostedDiagnosticsAvailability.AVAILABLE -> DiagnosticsAvailabilityStatus.AVAILABLE + HostedDiagnosticsAvailability.DISABLED -> DiagnosticsAvailabilityStatus.DISABLED + HostedDiagnosticsAvailability.STORAGE_UNAVAILABLE -> DiagnosticsAvailabilityStatus.STORAGE_UNAVAILABLE +} + +private fun DiagnosticsPlatform.wireValue(): String = when (this) { + DiagnosticsPlatform.ANDROID -> "android" + DiagnosticsPlatform.ANDROID_TV -> "android-tv" + DiagnosticsPlatform.IOS -> "ios" + DiagnosticsPlatform.TVOS -> "tvos" +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsDeletionWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsDeletionWorker.kt new file mode 100644 index 000000000..54f760389 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsDeletionWorker.kt @@ -0,0 +1,72 @@ +package org.prairieserver.prairie.common.diagnostics + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException + +class HostedDiagnosticsDeletionWorker( + appContext: Context, + params: WorkerParameters, + private val reports: PendingReportStore, + private val deleter: HostedDiagnosticsReportDeleter, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + var completedAll = true + val reportIds = try { + reports.hostedDeletionIntents() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + return Result.retry() + } + reportIds.forEach { reportId -> + val deleted = try { + deleter.delete(reportId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + false + } + if (deleted) { + try { + reports.completeHostedDeletion(reportId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + completedAll = false + } + } else { + completedAll = false + } + } + return if (completedAll) Result.success() else Result.retry() + } + + companion object { + private const val UNIQUE_WORK = "hosted-diagnostics-deletion" + + fun enqueue(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + UNIQUE_WORK, + ExistingWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStore.kt index 67cc9b0e4..d84c10887 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStore.kt @@ -1,11 +1,9 @@ package org.prairieserver.prairie.common.diagnostics -import android.system.Os -import android.system.OsConstants import java.io.File -import java.io.FileDescriptor import java.io.FileOutputStream import java.security.MessageDigest +import java.util.Locale import java.util.UUID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -13,6 +11,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.prairieserver.prairie.model.diagnostics.DiagnosticsManifest import org.prairieserver.prairie.model.diagnostics.decodeDiagnosticsManifest +import org.prairieserver.prairie.model.diagnostics.validate @Serializable data class PendingReportBinding( @@ -20,6 +19,7 @@ data class PendingReportBinding( @SerialName("account_user_id") val accountUserId: String, @SerialName("profile_id") val profileId: String? = null, @SerialName("ownership_generation") val ownershipGeneration: Long, + @SerialName("destination_kind") val destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, ) { val binding: DiagnosticsBinding get() = DiagnosticsBinding(serverInstanceId, accountUserId) @@ -32,7 +32,7 @@ data class PendingReportBinding( } @Serializable -enum class PendingReportStatus { PENDING, RETRYABLE, PERMANENT_FAILURE } +enum class PendingReportStatus { PENDING, PROCESSING, RETRYABLE, PERMANENT_FAILURE } @Serializable data class PendingReportState( @@ -42,6 +42,9 @@ data class PendingReportState( @SerialName("attempt_count") val attemptCount: Int = 0, @SerialName("error_code") val errorCode: String? = null, @SerialName("updated_at_epoch_ms") val updatedAtEpochMs: Long, + @SerialName("hosted_envelope_generation") val hostedEnvelopeGeneration: String? = null, + @SerialName("hosted_consent_refresh_required") val hostedConsentRefreshRequired: Boolean = false, + @SerialName("hosted_remote_short_id") val hostedRemoteShortId: String? = null, ) data class PendingReportCapture( @@ -60,21 +63,46 @@ data class PendingReport( val state: PendingReportState, ) +data class HostedReadyReport( + val id: String, + val binding: DiagnosticsBinding, + val shortId: String, + val readyAtEpochMs: Long, +) + class PendingReportRejectedException(message: String) : IllegalStateException(message) +sealed interface HostedEnvelopeLoadResult { + data object Missing : HostedEnvelopeLoadResult + data class Available(val bundle: DiagnosticsBundle) : HostedEnvelopeLoadResult + data object Corrupt : HostedEnvelopeLoadResult +} + interface PendingReportStore { fun save(capture: PendingReportCapture): PendingReport fun list(binding: DiagnosticsBinding): List fun load(id: String): PendingReport? fun delete(id: String) + fun stageHostedDeletionAndDelete(id: String) fun purge(binding: DiagnosticsBinding) + fun purgeAll() + fun recordHostedReadyAndDelete(id: String, binding: PendingReportBinding, shortId: String? = null) + fun hostedReadyBinding(id: String): DiagnosticsBinding? + fun hostedReadyReports(): List + fun hostedDeletionIntents(): List + fun completeHostedDeletion(id: String) fun markState(id: String, status: PendingReportStatus, errorCode: String? = null) fun hasSeenFingerprint(fingerprint: String): Boolean fun markThrottled(key: String, atEpochMs: Long) fun isThrottled(key: String, windowMs: Long): Boolean fun retryAfterDeadline(binding: DiagnosticsBinding): Long? fun setRetryAfterDeadline(binding: DiagnosticsBinding, deadlineEpochMs: Long) + fun setRetryAfterDeadlineForReport(id: String, binding: DiagnosticsBinding, deadlineEpochMs: Long) fun clearRetryAfterDeadline(binding: DiagnosticsBinding) + fun loadHostedEnvelope(id: String): HostedEnvelopeLoadResult + fun saveHostedEnvelope(id: String, bundle: DiagnosticsBundle) + fun markHostedConsentRefreshRequired(id: String) + fun markHostedProcessing(id: String, shortId: String) } class FilePendingReportStore( @@ -83,19 +111,43 @@ class FilePendingReportStore( private val maxReportsPerBinding: Int = DEFAULT_MAX_REPORTS, private val retentionMs: Long = DEFAULT_RETENTION_MS, private val idFactory: () -> String = { UUID.randomUUID().toString().replace("-", "") }, + private val deleteRecursively: (File) -> Boolean = File::deleteRecursively, + private val listFiles: (File) -> Array? = File::listFiles, + private val directorySync: (File) -> Unit = ::syncDiagnosticsDirectory, + private val atomicRename: (File, File) -> Unit = ::renameDiagnosticsFileAtomically, ) : PendingReportStore { private val root = noBackupFilesDir.resolve("client-diagnostics/pending") private val indexFile = noBackupFilesDir.resolve("client-diagnostics/pending-index.json") + private val hostedDeletionIntentsFile = + noBackupFilesDir.resolve("client-diagnostics/hosted-deletion-intents.json") + private val hostedReadyReceiptsFile = + noBackupFilesDir.resolve("client-diagnostics/hosted-ready-receipts.json") private val lock = Any() init { require(maxReportsPerBinding > 0) require(retentionMs > 0) + // A process can stop after publishing either a READY receipt or a + // hosted erasure intent but before removing the corresponding report + // directory. Finish that local half before serving any data. + synchronized(lock) { + // Construction must remain fail-contained so DiagnosticsStartup can + // still install the synchronous identity gate. Every operation that + // can expose/create/purge evidence retries this cleanup strictly. + runCatching { reconcileUnpublishedEvidenceLocked() } + runCatching { reconcileHostedReadyReceiptsLocked() } + runCatching { reconcileHostedDeletionIntentsLocked() } + } } override fun save(capture: PendingReportCapture): PendingReport = synchronized(lock) { validateCapture(capture) - if (capture.capturedAtEpochMs < nowMs() - retentionMs) { + val now = nowMs() + check(now >= 0) { "diagnostics clock must be non-negative" } + if ( + capture.capturedAtEpochMs < retentionCutoff(now, retentionMs) || + capture.capturedAtEpochMs > futureBoundary(now) + ) { throw PendingReportRejectedException("capture is outside the retention window") } pruneLocked() @@ -139,7 +191,9 @@ class FilePendingReportStore( fingerprints = currentIndex.fingerprints + (capture.fingerprint to capture.capturedAtEpochMs), ) writeIndex(index) - if (sameBinding.size >= maxReportsPerBinding) oldest?.let { deleteDirectory(it.directory) } + if (sameBinding.size >= maxReportsPerBinding) { + oldest?.let(::recordAutomaticHandoffAndDeleteLocked) + } loadLocked(id) ?: error("published pending report failed validation") } catch (error: Throwable) { runCatching { staging.deleteRecursively() } @@ -164,11 +218,35 @@ class FilePendingReportStore( } override fun delete(id: String) = synchronized(lock) { - if (ID_PATTERN.matches(id)) deleteDirectory(root.resolve(id)) + if (!ID_PATTERN.matches(id)) return@synchronized + reconcileUnpublishedEvidenceLocked() + loadReportDirectoryLocked(id)?.let { report -> stageHostedDeletionsLocked(listOf(report)) } + deleteDirectory(root.resolve(id)) + } + + override fun stageHostedDeletionAndDelete(id: String) = synchronized(lock) { + if (!ID_PATTERN.matches(id)) return@synchronized + pruneHostedReadyReceiptsLocked() + reconcileUnpublishedEvidenceLocked() + val report = loadReportDirectoryLocked(id) + val receiptId = id.takeIf { it in readHostedReadyReceiptsLocked() } + if (report == null && receiptId == null) return@synchronized + stageHostedDeletionsLocked(listOfNotNull(report), setOfNotNull(receiptId)) + deleteDirectory(root.resolve(id)) } override fun purge(binding: DiagnosticsBinding) = synchronized(lock) { - val removed = reportsLocked().filter { it.binding.binding == binding } + pruneHostedReadyReceiptsLocked() + // A crash can leave raw artifacts in an unpublished staging directory, + // and a partially published/corrupt report cannot be attributed safely. + // Destructive identity boundaries remove that evidence conservatively + // and fail the transition if enumeration or deletion is not verifiable. + reconcileUnpublishedEvidenceLocked() + val removed = reportDirectoriesLocked().filter { it.binding.binding == binding } + val receiptIds = readHostedReadyReceiptsLocked() + .filterValues { it.binding == binding } + .keys + stageHostedDeletionsLocked(removed, receiptIds) removed.forEach { deleteDirectory(it.directory) } val removedFingerprints = removed.mapTo(hashSetOf()) { it.state.fingerprint } val index = readIndex() @@ -180,6 +258,63 @@ class FilePendingReportStore( ) } + override fun purgeAll() = synchronized(lock) { + pruneHostedReadyReceiptsLocked() + reconcileUnpublishedEvidenceLocked() + val reports = reportDirectoriesLocked() + val receiptIds = readHostedReadyReceiptsLocked().keys + stageHostedDeletionsLocked(reports, receiptIds) + reports.forEach { report -> deleteDirectory(report.directory) } + writeIndex(PendingIndex()) + } + + override fun recordHostedReadyAndDelete( + id: String, + binding: PendingReportBinding, + shortId: String?, + ) = synchronized(lock) { + require(ID_PATTERN.matches(id)) { "invalid hosted report id" } + require(shortId == null || shortId.isNotBlank()) { "invalid hosted short id" } + require(binding.destinationKind == DiagnosticsDestinationKind.HOSTED) + require(binding.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID) + pruneHostedReadyReceiptsLocked() + loadReportDirectoryLocked(id)?.let { report -> + require(report.binding == binding) { "hosted READY binding changed" } + } + recordHostedHandoffReceiptLocked(id, binding.binding, shortId) + deleteDirectory(root.resolve(id)) + } + + override fun hostedReadyBinding(id: String): DiagnosticsBinding? = synchronized(lock) { + if (!ID_PATTERN.matches(id)) return@synchronized null + pruneHostedReadyReceiptsLocked() + readHostedReadyReceiptsLocked()[id]?.binding + } + + override fun hostedReadyReports(): List = synchronized(lock) { + pruneHostedReadyReceiptsLocked() + val deleting = readHostedDeletionIntentsLocked().keys + readHostedReadyReceiptsLocked().mapNotNull { (id, receipt) -> + receipt.shortId?.takeIf { it.isNotBlank() && id !in deleting }?.let { shortId -> + HostedReadyReport(id, receipt.binding, shortId, receipt.readyAtEpochMs) + } + } + } + + override fun hostedDeletionIntents(): List = synchronized(lock) { + reconcileHostedDeletionIntentsLocked() + } + + override fun completeHostedDeletion(id: String) = synchronized(lock) { + if (!ID_PATTERN.matches(id)) return@synchronized + val intents = readHostedDeletionIntentsLocked() + if (id !in intents) return@synchronized + check(!root.resolve(id).exists()) { "hosted report evidence still exists" } + val receipts = readHostedReadyReceiptsLocked() + if (id in receipts) writeHostedReadyReceiptsLocked(receipts - id) + writeHostedDeletionIntentsLocked(intents - id) + } + override fun markState(id: String, status: PendingReportStatus, errorCode: String?) = synchronized(lock) { val report = loadLocked(id) ?: return@synchronized val updated = report.state.copy( @@ -213,6 +348,21 @@ class FilePendingReportStore( } override fun setRetryAfterDeadline(binding: DiagnosticsBinding, deadlineEpochMs: Long) = synchronized(lock) { + setRetryAfterDeadlineLocked(binding, deadlineEpochMs) + } + + override fun setRetryAfterDeadlineForReport( + id: String, + binding: DiagnosticsBinding, + deadlineEpochMs: Long, + ) = synchronized(lock) { + if (!ID_PATTERN.matches(id)) return@synchronized + val report = loadLocked(id) ?: return@synchronized + if (report.binding.binding != binding) return@synchronized + setRetryAfterDeadlineLocked(binding, deadlineEpochMs) + } + + private fun setRetryAfterDeadlineLocked(binding: DiagnosticsBinding, deadlineEpochMs: Long) { val index = readIndex().pruned(nowMs(), retentionMs) val scopeKey = binding.scopeKey() val deadline = maxOf(index.retryAfter[scopeKey] ?: 0L, deadlineEpochMs) @@ -224,6 +374,148 @@ class FilePendingReportStore( writeIndex(index.copy(retryAfter = index.retryAfter - binding.scopeKey())) } + override fun loadHostedEnvelope(id: String): HostedEnvelopeLoadResult = synchronized(lock) { + val report = loadLocked(id) ?: return@synchronized HostedEnvelopeLoadResult.Corrupt + val generation = report.state.hostedEnvelopeGeneration + if (generation != null) { + if (!ID_PATTERN.matches(generation)) return@synchronized HostedEnvelopeLoadResult.Corrupt + val directory = report.directory.resolve("$HOSTED_ENVELOPE_PREFIX$generation") + return@synchronized readHostedEnvelope(report, directory) + ?.let(HostedEnvelopeLoadResult::Available) + ?: HostedEnvelopeLoadResult.Corrupt + } + + report.directory.listFiles().orEmpty() + .filter { it.name.startsWith(HOSTED_ENVELOPE_STAGING_PREFIX) } + .forEach(File::deleteRecursively) + val recoverable = report.directory.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith(HOSTED_ENVELOPE_PREFIX) } + .sortedByDescending(File::lastModified) + for (directory in recoverable) { + val recoveredGeneration = directory.name.removePrefix(HOSTED_ENVELOPE_PREFIX) + if (!ID_PATTERN.matches(recoveredGeneration)) continue + val bundle = readHostedEnvelope(report, directory) ?: continue + val state = report.state.copy(hostedEnvelopeGeneration = recoveredGeneration) + writeAtomic(report.directory.resolve(STATE_FILE), JSON.encodeToString(state).encodeToByteArray()) + return@synchronized HostedEnvelopeLoadResult.Available(bundle) + } + if (recoverable.isNotEmpty()) { + HostedEnvelopeLoadResult.Corrupt + } else { + HostedEnvelopeLoadResult.Missing + } + } + + override fun saveHostedEnvelope(id: String, bundle: DiagnosticsBundle) = synchronized(lock) { + val report = checkNotNull(loadLocked(id)) { "pending report is unavailable" } + validateHostedEnvelope(report, bundle) + val generation = UUID.randomUUID().toString().replace("-", "").lowercase(Locale.ROOT) + val staging = report.directory.resolve("$HOSTED_ENVELOPE_STAGING_PREFIX$generation") + val published = report.directory.resolve("$HOSTED_ENVELOPE_PREFIX$generation") + check(!staging.exists() && !published.exists()) { "hosted envelope generation collision" } + try { + check(staging.mkdirs()) { "unable to create hosted envelope staging directory" } + writeSynced(staging.resolve(HOSTED_MANIFEST_FILE), bundle.manifestBytes) + writeSynced(staging.resolve(HOSTED_BUNDLE_FILE), bundle.bytes) + val entryDirectories = linkedSetOf() + bundle.manifest.archive.entries.forEach { path -> + val bytes = checkNotNull(bundle.sanitizedEntries[path]) { + "missing sanitized hosted member: $path" + } + val target = staging.resolve(HOSTED_ENTRIES_DIRECTORY).resolve(path) + val parent = checkNotNull(target.parentFile) + check(parent.mkdirs() || parent.isDirectory) + var directory: File? = parent + while (directory != null && directory != staging) { + entryDirectories += directory + directory = directory.parentFile + } + writeSynced(target, bytes) + } + entryDirectories + .sortedByDescending { directory -> directory.relativeTo(staging).invariantSeparatorsPath.count { it == '/' } } + .forEach(::syncDirectory) + syncDirectory(staging) + atomicRename(staging, published) + syncDirectory(report.directory) + val state = report.state.copy( + hostedEnvelopeGeneration = generation, + hostedConsentRefreshRequired = false, + updatedAtEpochMs = nowMs(), + ) + writeAtomic(report.directory.resolve(STATE_FILE), JSON.encodeToString(state).encodeToByteArray()) + report.directory.listFiles().orEmpty() + .filter { + it.isDirectory && + it.name.startsWith(HOSTED_ENVELOPE_PREFIX) && + it.name != published.name + } + .forEach(File::deleteRecursively) + } catch (error: Throwable) { + runCatching { staging.deleteRecursively() } + throw error + } + } + + override fun markHostedConsentRefreshRequired(id: String) = synchronized(lock) { + val report = loadLocked(id) ?: return@synchronized + val updated = report.state.copy( + hostedConsentRefreshRequired = true, + updatedAtEpochMs = nowMs(), + ) + writeAtomic(report.directory.resolve(STATE_FILE), JSON.encodeToString(updated).encodeToByteArray()) + } + + override fun markHostedProcessing(id: String, shortId: String) = synchronized(lock) { + require(shortId.isNotBlank()) + val report = loadLocked(id) ?: return@synchronized + val updated = report.state.copy( + status = PendingReportStatus.PROCESSING, + errorCode = null, + hostedRemoteShortId = shortId, + updatedAtEpochMs = nowMs(), + ) + writeAtomic(report.directory.resolve(STATE_FILE), JSON.encodeToString(updated).encodeToByteArray()) + } + + private fun readHostedEnvelope(report: PendingReport, directory: File): DiagnosticsBundle? = runCatching { + require(directory.isDirectory) + val manifestBytes = directory.resolve(HOSTED_MANIFEST_FILE).readBytes() + val manifest = decodeDiagnosticsManifest(manifestBytes.decodeToString()).also(DiagnosticsManifest::validate) + val bundleBytes = directory.resolve(HOSTED_BUNDLE_FILE).readBytes() + val entriesRoot = directory.resolve(HOSTED_ENTRIES_DIRECTORY) + val entries = manifest.archive.entries.associateWith { path -> + val entry = entriesRoot.resolve(path) + require(entry.isFile && entry.isWithinDirectory(entriesRoot)) + entry.readBytes() + } + val bundle = DiagnosticsBundle(manifest, manifestBytes, bundleBytes, entries) + validateHostedEnvelope(report, bundle) + bundle + }.getOrNull() + + private fun validateHostedEnvelope(report: PendingReport, bundle: DiagnosticsBundle) { + bundle.manifest.validate() + require(report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED) + require(bundle.manifest.destination.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID) + require(bundle.manifest.report.profileId == null) + require(bundle.manifest.playbackSessionIds.isEmpty()) + require("crash/tombstone.pb" !in bundle.manifest.archive.entries) + require(bundle.manifest.archive.entries == bundle.sanitizedEntries.keys.toList()) + require(bundle.manifest.archive.bytes == bundle.bytes.size.toLong()) + require(bundle.manifest.archive.sha256 == sha256Hex(bundle.bytes)) + require(bundle.sanitizedEntries[MANIFEST_FILE] != null) + require(bundle.sanitizedEntries[DEVICE_FILE] != null) + val reconstructed = FileDiagnosticsBundleBuilder().reframeHosted(bundle, bundle.manifest.consent) + require(reconstructed.manifestBytes.contentEquals(bundle.manifestBytes)) + require(reconstructed.bytes.contentEquals(bundle.bytes)) + } + + private fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256") + .digest(bytes) + .joinToString(separator = "") { byte -> "%02x".format(Locale.ROOT, byte.toInt() and 0xff) } + private fun validateCapture(capture: PendingReportCapture) { require(capture.binding.serverInstanceId == capture.manifest.destination.serverInstanceId) require(capture.binding.profileId == capture.manifest.report.profileId) @@ -240,26 +532,130 @@ class FilePendingReportStore( } private fun pruneLocked() { + pruneHostedReadyReceiptsLocked() if (!root.exists()) { val index = readIndex() val pruned = index.pruned(nowMs(), retentionMs) if (pruned != index) writeIndex(pruned) return } - root.listFiles().orEmpty().filter { it.name.startsWith(".staging-") }.forEach(File::deleteRecursively) - val cutoff = nowMs() - retentionMs - reportsLocked().filter { it.state.capturedAtEpochMs < cutoff }.forEach { deleteDirectory(it.directory) } + reconcileUnpublishedEvidenceLocked() + val now = nowMs() + check(now >= 0) { "diagnostics clock must be non-negative" } + val cutoff = retentionCutoff(now, retentionMs) + val futureBoundary = futureBoundary(now) + val expired = reportsLocked().filter { report -> + report.state.capturedAtEpochMs < cutoff || + report.state.capturedAtEpochMs > futureBoundary + } + expired.forEach(::recordAutomaticHandoffAndDeleteLocked) val index = readIndex() val pruned = index.pruned(nowMs(), retentionMs) if (pruned != index) writeIndex(pruned) } private fun reportsLocked(): List = - root.listFiles().orEmpty() + strictRootEntriesLocked() .filter { it.isDirectory && ID_PATTERN.matches(it.name) } .mapNotNull { loadLocked(it.name) } private fun loadLocked(id: String): PendingReport? { + if (id in hostedDeletionIntentIdsLocked() || id in hostedReadyReceiptIdsLocked()) { + // Never let evidence covered by a READY receipt or durable erasure + // request reappear or reach an uploader, even if physical cleanup + // must be retried after an interrupted deletion. + runCatching { deleteDirectory(root.resolve(id)) } + return null + } + return loadReportDirectoryLocked(id) + } + + private fun reportDirectoriesLocked(): List = + strictRootEntriesLocked() + .filter { it.isDirectory && ID_PATTERN.matches(it.name) } + .mapNotNull { loadReportDirectoryLocked(it.name) } + + /** + * Removes evidence that can never be exposed as a valid pending report. + * A valid hosted binding is enough to preserve its UUID in the handoff + * ledger even when another member is corrupt. + */ + private fun reconcileUnpublishedEvidenceLocked() { + val entries = strictRootEntriesLocked() + entries.filter { it.name.startsWith(".staging-") }.forEach(::deleteDirectory) + + val malformed = entries.filter { entry -> + entry.isDirectory && + ID_PATTERN.matches(entry.name) && + loadReportDirectoryLocked(entry.name) == null + } + val recoveredBindings = malformed.associateWith { directory -> + runCatching { + JSON.decodeFromString(directory.resolve(BINDING_FILE).readText()) + }.getOrNull() + } + val hostedBindings = recoveredBindings.mapNotNull { (directory, binding) -> + binding?.takeIf { + it.destinationKind == DiagnosticsDestinationKind.HOSTED && + it.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID + }?.binding?.let { recovered -> + directory.name to recovered + } + }.toMap() + hostedBindings.forEach { (id, binding) -> recordHostedHandoffReceiptLocked(id, binding) } + + // If the binding member itself is missing or corrupt, the UUID may + // still name bytes accepted by the public collector. Only a decoded + // self-hosted binding proves no hosted erasure authority is needed. + // Preserve unknown UUIDs as preemptive tombstones before raw cleanup. + val unknownDestinationIds = recoveredBindings + .filterValues { binding -> binding?.destinationKind != DiagnosticsDestinationKind.SELF_HOSTED } + .keys + .map(File::getName) + .toSet() - hostedBindings.keys + stageHostedDeletionIdsLocked(unknownDestinationIds) + malformed.forEach(::deleteDirectory) + } + + private fun recordAutomaticHandoffAndDeleteLocked(report: PendingReport) { + if ( + report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED && + report.binding.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID + ) { + recordHostedHandoffReceiptLocked( + report.id, + report.binding.binding, + ) + } + deleteDirectory(report.directory) + } + + private fun recordHostedHandoffReceiptLocked( + id: String, + binding: DiagnosticsBinding, + shortId: String? = null, + ) { + if (id in readHostedDeletionIntentsLocked()) return + val receipts = readHostedReadyReceiptsLocked().toMutableMap() + receipts[id] = HostedReadyReceipt( + binding = binding, + destinationKind = DiagnosticsDestinationKind.HOSTED, + readyAtEpochMs = checkedNow(), + shortId = shortId, + ) + // Publish UUID + ownership before raw evidence is removed. This is + // also used for expiry/quota eviction where a lost response means the + // collector may already have durable bytes. + writeHostedReadyReceiptsLocked(receipts) + } + + private fun strictRootEntriesLocked(): List { + if (!root.exists()) return emptyList() + check(root.isDirectory) { "pending diagnostics root is not a directory" } + return checkNotNull(listFiles(root)) { "unable to enumerate pending diagnostics root" }.toList() + } + + private fun loadReportDirectoryLocked(id: String): PendingReport? { val directory = root.resolve(id) if (!directory.isDirectory) return null return runCatching { @@ -294,30 +690,19 @@ class FilePendingReportStore( private fun deleteDirectory(directory: File) { if (directory.exists()) { - check(directory.deleteRecursively()) { "unable to delete ${directory.name}" } + check(deleteRecursively(directory)) { "unable to delete ${directory.name}" } + check(!directory.exists()) { "diagnostics evidence still exists after deletion: ${directory.name}" } directory.parentFile?.let(::syncDirectory) + check(!directory.exists()) { "diagnostics evidence deletion was not durable: ${directory.name}" } } } - private fun atomicRename(source: File, target: File) { - val renamedByOs = runCatching { - Os.rename(source.absolutePath, target.absolutePath) - !source.exists() && target.exists() - }.getOrDefault(false) - if (!renamedByOs) { - if (target.exists()) check(target.delete()) { "unable to replace ${target.name}" } - check(source.renameTo(target)) { "unable to atomically publish ${target.name}" } - } - check(!source.exists() && target.exists()) { "atomic publish did not complete for ${target.name}" } + private fun syncDirectory(directory: File) { + directorySync(directory) } - private fun syncDirectory(directory: File) { - var descriptor: FileDescriptor? = null - runCatching { - descriptor = Os.open(directory.absolutePath, OsConstants.O_RDONLY, 0) - Os.fsync(checkNotNull(descriptor)) - } - runCatching { descriptor?.let(Os::close) } + private fun checkedNow(): Long = nowMs().also { now -> + check(now >= 0) { "diagnostics clock must be non-negative" } } private fun readIndex(): PendingIndex { @@ -332,6 +717,147 @@ class FilePendingReportStore( writeAtomic(indexFile, bytes) } + private fun stageHostedDeletionsLocked( + reports: List, + readyReceiptIds: Set = emptySet(), + ) { + val reportIds = reports.asSequence() + .filter { report -> + report.binding.destinationKind == DiagnosticsDestinationKind.HOSTED && + report.binding.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID + } + .map(PendingReport::id) + .toSet() + readyReceiptIds + stageHostedDeletionIdsLocked(reportIds) + } + + private fun stageHostedDeletionIdsLocked(reportIds: Set) { + if (reportIds.isEmpty()) return + val intents = readHostedDeletionIntentsLocked().toMutableMap() + val stagedAt = checkedNow() + reportIds.forEach { reportId -> intents[reportId] = stagedAt } + // Publish the UUID-only erasure intent before deleting any evidence. + // If persistence fails, the caller keeps every report intact. + writeHostedDeletionIntentsLocked(intents) + } + + private fun reconcileHostedDeletionIntentsLocked(): List { + val reportIds = readHostedDeletionIntentsLocked().keys.sorted() + return reportIds.filter { reportId -> + val directory = root.resolve(reportId) + if (!directory.exists()) { + true + } else { + runCatching { + deleteDirectory(directory) + true + }.getOrDefault(false) + } + } + } + + private fun hostedDeletionIntentIdsLocked(): Set = + readHostedDeletionIntentsLocked().keys + + private fun reconcileHostedReadyReceiptsLocked() { + pruneHostedReadyReceiptsLocked() + readHostedReadyReceiptsLocked().keys.forEach { reportId -> + runCatching { deleteDirectory(root.resolve(reportId)) } + } + } + + private fun pruneHostedReadyReceiptsLocked() { + val receipts = readHostedReadyReceiptsLocked() + if (receipts.isEmpty()) return + val now = nowMs() + check(now >= 0) { "diagnostics clock must be non-negative" } + val cutoff = retentionCutoff(now, HOSTED_READY_RECEIPT_RETENTION_MS) + val futureBoundary = futureBoundary(now) + val expiredIds = receipts + .filterValues { receipt -> + receipt.readyAtEpochMs < cutoff || receipt.readyAtEpochMs > futureBoundary + } + .keys + // Never silently discard deletion authority based only on the client + // wall clock. A clock jump could otherwise age a fresh collector + // report past the local 37-day window. Expiry transitions the receipt + // into a durable erasure intent; a 204 completion clears both. + stageHostedDeletionIdsLocked(expiredIds) + } + + private fun hostedReadyReceiptIdsLocked(): Set = + readHostedReadyReceiptsLocked().keys + + private fun readHostedReadyReceiptsLocked(): Map { + if (!hostedReadyReceiptsFile.isFile) return emptyMap() + require(hostedReadyReceiptsFile.length() <= MAX_READY_RECEIPTS_BYTES) { + "hosted READY receipt state exceeds its size limit" + } + val receipts = JSON.decodeFromString>(hostedReadyReceiptsFile.readText()) + require( + receipts.all { (id, receipt) -> + ID_PATTERN.matches(id) && + receipt.destinationKind == DiagnosticsDestinationKind.HOSTED && + receipt.binding.serverInstanceId == HOSTED_DIAGNOSTICS_COLLECTOR_ID && + receipt.readyAtEpochMs >= 0 && + (receipt.shortId == null || receipt.shortId.isNotBlank()) + }, + ) { "invalid hosted READY receipt state" } + return receipts + } + + private fun writeHostedReadyReceiptsLocked(receipts: Map) { + val parent = checkNotNull(hostedReadyReceiptsFile.parentFile) + check(parent.mkdirs() || parent.isDirectory) { "unable to create diagnostics state directory" } + if (receipts.isEmpty()) { + if (hostedReadyReceiptsFile.exists()) { + check(hostedReadyReceiptsFile.delete()) { "unable to clear hosted READY receipts" } + syncDirectory(parent) + } + return + } + require(receipts.keys.all(ID_PATTERN::matches)) { "invalid hosted READY receipt" } + val bytes = JSON.encodeToString>(receipts.toSortedMap()).encodeToByteArray() + require(bytes.size <= MAX_READY_RECEIPTS_BYTES) { "too many hosted READY receipts" } + writeAtomic(hostedReadyReceiptsFile, bytes) + } + + private fun readHostedDeletionIntentsLocked(): Map { + if (!hostedDeletionIntentsFile.isFile) return emptyMap() + require(hostedDeletionIntentsFile.length() <= MAX_DELETION_INTENTS_BYTES) { + "hosted deletion intent state exceeds its size limit" + } + val intents = JSON.decodeFromString>(hostedDeletionIntentsFile.readText()) + require(intents.all { (id, stagedAt) -> ID_PATTERN.matches(id) && stagedAt >= 0 }) { + "invalid hosted deletion intent state" + } + return intents + } + + private fun writeHostedDeletionIntentsLocked(intents: Map) { + val parent = checkNotNull(hostedDeletionIntentsFile.parentFile) + check(parent.mkdirs() || parent.isDirectory) { "unable to create diagnostics state directory" } + if (intents.isEmpty()) { + if (hostedDeletionIntentsFile.exists()) { + check(hostedDeletionIntentsFile.delete()) { "unable to clear hosted deletion intents" } + syncDirectory(parent) + } + return + } + require(intents.keys.all(ID_PATTERN::matches)) { "invalid hosted deletion intent" } + val bytes = JSON.encodeToString>(intents.toSortedMap()).encodeToByteArray() + require(bytes.size <= MAX_DELETION_INTENTS_BYTES) { "too many hosted deletion intents" } + writeAtomic(hostedDeletionIntentsFile, bytes) + } + + @Serializable + private data class HostedReadyReceipt( + val binding: DiagnosticsBinding, + @SerialName("destination_kind") val destinationKind: DiagnosticsDestinationKind, + @SerialName("ready_at_epoch_ms") val readyAtEpochMs: Long, + @SerialName("short_id") val shortId: String? = null, + ) + @Serializable private data class PendingIndex( val fingerprints: Map = emptyMap(), @@ -339,10 +865,12 @@ class FilePendingReportStore( @SerialName("retry_after") val retryAfter: Map = emptyMap(), ) { fun pruned(now: Long, retention: Long): PendingIndex { - val cutoff = now - retention + check(now >= 0) { "diagnostics clock must be non-negative" } + val cutoff = retentionCutoff(now, retention) + val futureBoundary = futureBoundary(now) return copy( - fingerprints = fingerprints.filterValues { it >= cutoff }, - throttles = throttles.filterValues { it >= cutoff }, + fingerprints = fingerprints.filterValues { it in cutoff..futureBoundary }, + throttles = throttles.filterValues { it in cutoff..futureBoundary }, retryAfter = retryAfter.filterValues { it > now }, ) } @@ -350,13 +878,22 @@ class FilePendingReportStore( private companion object { const val DEFAULT_MAX_REPORTS = 3 - const val DEFAULT_RETENTION_MS = 7L * 24 * 60 * 60 * 1_000 + const val DEFAULT_RETENTION_MS = PENDING_DIAGNOSTICS_RETENTION_DAYS * 24L * 60 * 60 * 1_000 const val MAX_CAPTURE_BYTES = 20L * 1_024 * 1_024 const val MAX_INDEX_BYTES = 256 * 1_024 + const val MAX_DELETION_INTENTS_BYTES = 256 * 1_024 + const val MAX_READY_RECEIPTS_BYTES = 256 * 1_024 + const val HOSTED_READY_RECEIPT_RETENTION_MS = + (HOSTED_DIAGNOSTICS_RETENTION_DAYS + PENDING_DIAGNOSTICS_RETENTION_DAYS) * 24L * 60 * 60 * 1_000 const val BINDING_FILE = "binding.json" const val MANIFEST_FILE = "manifest.json" const val STATE_FILE = "state.json" const val DEVICE_FILE = "device.json" + const val HOSTED_MANIFEST_FILE = "manifest.json" + const val HOSTED_BUNDLE_FILE = "bundle.tar.gz" + const val HOSTED_ENTRIES_DIRECTORY = "entries" + const val HOSTED_ENVELOPE_PREFIX = ".hosted-envelope-" + const val HOSTED_ENVELOPE_STAGING_PREFIX = ".hosted-envelope-staging-" val ID_PATTERN = Regex("^[0-9a-f]{32}$") val ALLOWED_ARTIFACTS = setOf( "device.json", @@ -371,9 +908,28 @@ class FilePendingReportStore( } } +const val PENDING_DIAGNOSTICS_RETENTION_DAYS = 7 +private const val MAX_DIAGNOSTICS_FUTURE_SKEW_MS = 5L * 60 * 1_000 + +private fun retentionCutoff(now: Long, retention: Long): Long = + if (now < retention) 0 else now - retention + +private fun futureBoundary(now: Long): Long = + if (now > Long.MAX_VALUE - MAX_DIAGNOSTICS_FUTURE_SKEW_MS) { + Long.MAX_VALUE + } else { + now + MAX_DIAGNOSTICS_FUTURE_SKEW_MS + } + private fun DiagnosticsBinding.scopeKey(): String = MessageDigest.getInstance("SHA-256") .digest("$serverInstanceId\u0000$accountUserId".encodeToByteArray()) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } private fun File.resolveSibling(name: String): File = checkNotNull(parentFile).resolve(name) + +private fun File.isWithinDirectory(directory: File): Boolean { + val rootPath = directory.canonicalFile.path + val candidatePath = canonicalFile.path + return candidatePath == rootPath || candidatePath.startsWith(rootPath + File.separator) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLog.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLog.kt index 8004a362c..ad533a3a1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLog.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLog.kt @@ -160,19 +160,36 @@ internal class DiagnosticsLogRenderer( "seek_last_ms" to AttributeKind.INTEGER, "seek_total_ms" to AttributeKind.INTEGER, "seek_max_ms" to AttributeKind.INTEGER, + "session_id" to AttributeKind.STRING, + "play_method" to AttributeKind.STRING, + "reason" to AttributeKind.STRING, + "position_ms" to AttributeKind.INTEGER, ), DiagnosticsLogCategory.FOCUS to mapOf( "target" to AttributeKind.STRING, "action" to AttributeKind.STRING, + // Registered because contentEntryFailed() reports it. An + // unregistered attribute THROWS while strictAttributeRegistry + // is on (debug builds), so the warning added to make silent + // focus failures visible crashed the app the moment it fired. + "route" to AttributeKind.STRING, ), DiagnosticsLogCategory.NETWORK to mapOf( "method" to AttributeKind.STRING, "path" to AttributeKind.STRING, "status" to AttributeKind.INTEGER, "duration_ms" to AttributeKind.INTEGER, + "outcome" to AttributeKind.STRING, + "error_code" to AttributeKind.STRING, + "attempt" to AttributeKind.INTEGER, ), DiagnosticsLogCategory.LIFECYCLE to mapOf( "state" to AttributeKind.STRING, + "phase" to AttributeKind.STRING, + "duration_ms" to AttributeKind.INTEGER, + "outcome" to AttributeKind.STRING, + "reason" to AttributeKind.STRING, + "launch_type" to AttributeKind.STRING, "route" to AttributeKind.STRING, "frame_count" to AttributeKind.INTEGER, "slow_frame_count" to AttributeKind.INTEGER, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt index df7059a48..e87201901 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt @@ -20,7 +20,9 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.workDataOf import org.prairieserver.prairie.model.download.DownloadStatus +import org.prairieserver.prairie.model.download.DownloadRecord import org.prairieserver.prairie.repository.DownloadsRepository +import org.prairieserver.prairie.network.PrairieAuthUnavailableException import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeoutConfig import io.ktor.client.plugins.timeout @@ -41,6 +43,16 @@ import java.io.IOException import java.net.URLDecoder import java.util.concurrent.TimeUnit +internal fun DownloadRecord.withWorkerStatus( + status: String, + bytesSent: Long? = null, + fileSize: Long? = null, +): DownloadRecord = copy( + status = status, + bytesSent = bytesSent ?: this.bytesSent, + fileSize = fileSize ?: this.fileSize, +) + /** * Streams `GET /api/v1/downloads/{id}/file` to the local * `/downloads////` @@ -314,7 +326,13 @@ class DownloadWorker( Result.retry() } } catch (e: Throwable) { - failPermanently(e, downloadId, serverId, profileId, fileId, activeUri) + if (downloadAuthFailureIsRetriable(e)) { + Log.i(TAG, "doWork auth unavailable id=$downloadId") + DiagnosticsDownloadLogger.event("download auth unavailable") + Result.retry() + } else { + failPermanently(e, downloadId, serverId, profileId, fileId, activeUri) + } } } finally { lifetimeLease.close() @@ -338,7 +356,13 @@ class DownloadWorker( // Best-effort: publish failed state into the repo + sidecar. val record = if (uiPushAllowed(serverId, profileId)) repository.recordForFile(fileId) else null if (record != null) { - repository.upsertLocal(record.copy(status = DownloadStatus.Failed.wire)) + repository.upsertLocal( + record.withWorkerStatus( + status = DownloadStatus.Failed.wire, + bytesSent = 0, + fileSize = 0, + ), + ) } updateSidecarStatus( serverId, profileId, fileId, @@ -361,8 +385,8 @@ class DownloadWorker( profileId: String, fileId: Int, status: String, - bytesSent: Long, - fileSize: Long, + bytesSent: Long? = null, + fileSize: Long? = null, localUri: String? = null, fileName: String? = null, // null = keep existing; "" = clear (download finished/failed); else set. @@ -373,10 +397,10 @@ class DownloadWorker( metadataStore.writeSidecar( serverId, profileId, existing.copy( - record = existing.record.copy( + record = existing.record.withWorkerStatus( status = status, - bytesSent = if (bytesSent > 0) bytesSent else existing.record.bytesSent, - fileSize = if (fileSize > 0) fileSize else existing.record.fileSize, + bytesSent = bytesSent, + fileSize = fileSize, ), localUri = localUri ?: existing.localUri, fileName = fileName?.takeIf { it.isNotBlank() } ?: existing.fileName, @@ -406,8 +430,6 @@ class DownloadWorker( updateSidecarStatus( serverId, profileId, fileId, status = DownloadStatus.Downloading.wire, - bytesSent = 0, - fileSize = 0, localUri = localUri, resumeValidator = validator?.takeIf { it.isNotBlank() } ?: "", ) @@ -441,7 +463,7 @@ class DownloadWorker( companion object { private const val TAG = "DownloadWorker" - const val NOTIFICATION_CHANNEL_ID = "prairie_downloads" + const val NOTIFICATION_CHANNEL_ID = "silo_downloads" const val KEY_DOWNLOAD_ID = "download_id" const val KEY_FILE_ID = "file_id" const val KEY_SERVER_ID = "server_id" @@ -661,3 +683,20 @@ private fun String.decodeRfc5987(): String { val encoded = substringAfter("''", missingDelimiterValue = this) return runCatching { URLDecoder.decode(encoded, Charsets.UTF_8.name()) }.getOrDefault(encoded) } + +/** + * A request the auth plugin refused to send because there were no usable + * credentials — the session was repudiated mid-download, or the token for this + * scope is gone. + * + * Retriable, matching how a 401 response is already classified: the user can + * sign back in and a part-downloaded file of several gigabytes is worth + * keeping. Treating it as permanent deletes that partial and paints the + * download Failed. + * + * Deliberately narrow rather than `is IllegalStateException`, which + * [PrairieAuthUnavailableException] extends — widening it that far would also make + * a genuine 404 look retriable. + */ +internal fun downloadAuthFailureIsRetriable(e: Throwable): Boolean = + e is PrairieAuthUnavailableException diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt index c067c79e8..c0e46fbdf 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/AndroidDeviceMetadataProvider.kt @@ -7,9 +7,20 @@ import org.prairieserver.prairie.network.PrairieDeviceMetadata import org.prairieserver.prairie.network.DeviceMetadataProvider import java.util.UUID +/** + * @param buildIdentity the app module's build number and release channel. It + * is passed in because `android-shared` cannot see either app's + * `BuildConfig`, and because the installed `versionCode` is the + * form-factor-doubled release code rather than CI's build counter. An + * unstamped build reports its build as absent rather than as build zero: the + * server treats the build as an opaque string, so a placeholder here would + * surface verbatim as "(0)" in admin Activity, and the channel already says + * `dev`. + */ class AndroidDeviceMetadataProvider( private val context: Context, private val platform: String, + private val buildIdentity: PrairieClientBuildIdentity, ) : DeviceMetadataProvider { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val cachedClientName: String by lazy { clientNameFor(platform) } @@ -30,6 +41,8 @@ class AndroidDeviceMetadataProvider( platform = platform, clientName = cachedClientName, clientVersion = cachedClientVersion, + clientBuild = buildIdentity.reportedBuildNumber, + clientChannel = buildIdentity.reportedChannel, ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt new file mode 100644 index 000000000..472cca09b --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.common.network + +/** Gradle's `siloBuildNumber` default, i.e. a build CI did not stamp. */ +private const val UNSET_BUILD_NUMBER = "0" + +/** + * Normalizes an app module's `BuildConfig.BUILD_NUMBER` for reporting to the + * server, on any carrier (the `X-Prairie-Client-Build` header, the v3 client + * playback context, the Cast request). + * + * A build CI never stamped carries the Gradle default `"0"`, which must be + * reported as *absent* rather than as build zero: the server treats the build + * as an opaque string, so a placeholder would surface verbatim as "(0)" in + * admin Activity. Nothing is lost — the channel already says `dev`. + */ +fun normalizedClientBuildNumber(raw: String?): String? = + raw?.trim()?.takeIf { it.isNotEmpty() && it != UNSET_BUILD_NUMBER } + +/** + * The About-row label for a version and its build: `"1.0.0 (5)"`, or the bare + * version when the build is unstamped. One helper so the phone and TV Settings + * screens can't drift, and so both match the form Play, TestFlight and the + * server's own diagnostics page render. + */ +fun clientVersionLabel(version: String, rawBuildNumber: String?): String = + normalizedClientBuildNumber(rawBuildNumber) + ?.let { build -> "$version ($build)" } + ?: version + +/** + * The two build facts only an app module's `BuildConfig` knows, resolved once + * per process and handed to every `android-shared` collaborator that reports + * client identity: the metadata provider behind the `X-Prairie-Client-*` headers, + * the playback capability detector behind the v3 client context, and the + * diagnostics exit-report environment. + * + * It exists so those three agree. Deriving the channel independently — say, + * from `ApplicationInfo.FLAG_DEBUGGABLE` — makes a `debuggable true` release + * build report `dev` down one path and `release` down another for the same + * install, and there is no way at all to recover [buildNumber] from the + * installed package: `versionCode` is the form-factor-doubled release code + * (`base*2` phone, `base*2+1` TV), not CI's counter. + * + * @param buildNumber the app module's `BuildConfig.BUILD_NUMBER` — CI's + * per-marketing-version build counter. The Gradle default `"0"` means "not + * built by CI"; prefer [reportedBuildNumber] on any path where absence is + * representable. + * @param channel the app module's `BuildConfig.RELEASE_CHANNEL` — how the build + * was distributed. Play's own track vocabulary where the build came from a + * track ("internal" / "alpha" / "beta" / "production"), otherwise "sideload" + * for an APK installed outside Play or "dev" for a local debug build. Opaque + * to the server, which stores it as reported. + */ +data class PrairieClientBuildIdentity( + val buildNumber: String, + val channel: String, +) { + /** [buildNumber] with the unstamped placeholder collapsed to null. */ + val reportedBuildNumber: String? = normalizedClientBuildNumber(buildNumber) + + /** [channel] with blank input collapsed to null, for optional carriers. */ + val reportedChannel: String? = channel.trim().takeIf { it.isNotBlank() } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/CardOverlays.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/CardOverlays.kt index 86ffb0307..87f92e41d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/CardOverlays.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/CardOverlays.kt @@ -1,12 +1,13 @@ package org.prairieserver.prairie.common.overlays import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @@ -52,6 +53,8 @@ enum class CardOverlayVariant { * CardOverlays(data = data, prefs = prefs, variant = CardOverlayVariant.Poster) * } * ``` + * + * @param scale optical multiplier for wide/hero cards; posters measure their actual width. */ @Composable fun CardOverlays( @@ -62,8 +65,21 @@ fun CardOverlays( scale: Float = 1f, forceOpaqueBackground: Boolean = false, ) { - val preset = OverlayPresetStyles.style(prefs.preset).scaled(scale) - Box(modifier = modifier.fillMaxSize()) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + // The web Home carousel's 185-unit overlay layer is the cross-platform + // visual reference. Reading the actual logical width also covers + // adaptive grids, phone density choices, TV rails, and fill-width cards. + val resolvedScale = if (variant == CardOverlayVariant.Poster) { + maxWidth.value + .takeIf { it.isFinite() && it > 0f } + ?.div(185f) + ?: scale + } else { + scale + } + val preset = remember(prefs.preset, resolvedScale) { + OverlayPresetStyles.style(prefs.preset).scaled(resolvedScale) + } for (position in OverlayPosition.entries) { CornerStack( position = position, @@ -71,7 +87,7 @@ fun CardOverlays( prefs = prefs, preset = preset, variant = variant, - scale = scale, + scale = resolvedScale, forceOpaqueBackground = forceOpaqueBackground, ) } @@ -88,8 +104,12 @@ private fun androidx.compose.foundation.layout.BoxScope.CornerStack( scale: Float, forceOpaqueBackground: Boolean, ) { - val badges = OverlayRegistry.enabled(position, prefs) - .mapNotNull { OverlayBadgeRenderState.resolve(it, data, prefs, preset) } + // Resolved once per (item, prefs, preset): this runs for four corners of + // every card on every card composition, and rails recompose a lot. + val badges = remember(position, data, prefs, preset) { + OverlayRegistry.enabled(position, prefs) + .mapNotNull { OverlayBadgeRenderState.resolve(it, data, prefs, preset) } + } if (badges.isEmpty()) return Column( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayBadge.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayBadge.kt index f709dfa9a..a5ab9bced 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayBadge.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayBadge.kt @@ -108,14 +108,14 @@ internal fun OverlayBadge( box = box.background(paintedBackground, shape) } if (border != Color.Unspecified) { - box = box.border(1.dp, border, shape) + box = box.border(1.dp * preset.scale, border, shape) } box = box .padding(horizontal = preset.horizontalPadding, vertical = preset.verticalPadding) Row( modifier = box, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp * preset.scale), verticalAlignment = Alignment.CenterVertically, ) { val iconId = state.iconId @@ -138,8 +138,14 @@ internal fun OverlayBadge( renderedIcon = true } } + // A brand token (HDR10, ATMOS, …) already spells its text as the + // mark itself; when the label says the same thing, showing both + // reads "HDR10 HDR10". Mirrors web's `labelRedundantWithIcon`. + val labelRedundantWithIcon = renderedIcon && + iconId != null && + overlayBrandToken(iconId)?.equals(state.label.trim(), ignoreCase = true) == true // Apple: render text unless icon-only AND an icon was shown. - if (!state.iconOnly || !renderedIcon) { + if ((!state.iconOnly || !renderedIcon) && !labelRedundantWithIcon) { BadgeText(text = state.label, preset = preset, color = foreground) } } @@ -170,7 +176,11 @@ private fun BadgeText( letterSpacing = preset.letterSpacing, textAlign = TextAlign.Center, shadow = if (preset.textShadow) { - Shadow(color = Color.Black.copy(alpha = 0.85f), offset = Offset(0f, 1f), blurRadius = 1f) + Shadow( + color = Color.Black.copy(alpha = 0.85f), + offset = Offset(0f, preset.scale), + blurRadius = preset.scale, + ) } else { null }, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayPresetStyle.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayPresetStyle.kt index c495ab644..cbfe8873f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayPresetStyle.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/overlays/OverlayPresetStyle.kt @@ -38,6 +38,8 @@ internal data class OverlayPresetStyle( val background: (accent: Color) -> Color, val foreground: (accent: Color) -> Color, val border: (accent: Color) -> Color, + /** The actual card-width multiplier applied to fixed renderer details. */ + val scale: Float = 1f, ) { sealed interface CornerStyle { /** Fully rounded capsule (clamped at half the height). */ @@ -66,6 +68,7 @@ internal data class OverlayPresetStyle( }, iconSize = iconSize * safeScale, gap = gap * safeScale, + scale = safeScale, ) } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt index ca97cee36..bdfcbf612 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/CompanionPairingCoordinator.kt @@ -18,6 +18,7 @@ import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.pairing.PairingMessage @@ -122,6 +123,7 @@ class RegistryCompanionPairingServerStore( class RepositoryCompanionDeviceLoginApprover( private val repository: DeviceLoginRepository, + private val identityTransitions: IdentityTransitionBarrier, ) : CompanionDeviceLoginApprover { override suspend fun lookup( server: CompanionPairingServer, @@ -143,6 +145,12 @@ class RepositoryCompanionDeviceLoginApprover( serverUrl = url, profileId = null, profileToken = null, + // This inactive-server scope cannot carry the token manager's live + // persistent credential epoch. Pin its request to the current identity + // generation instead so a late refresh cannot overwrite a same-server + // account replacement. + identityGeneration = identityTransitions.generation.value, + isIdentityGenerationStamped = true, ) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt index 18f8753ae..ec7bc68fa 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingAuthPort.kt @@ -52,24 +52,20 @@ class RegistryPairingAuthPort( val previousServerId = serverRegistry.activeServerId.value val serverId = serverRegistry.addOrUpdate(serverUrl, fetchedName = serverName) try { - // Prepare the token slot before publishing the registry switch. - // The HTTP client follows ServerRegistry, so observers can - // never see the approved server active without credentials. - serverRegistry.setProfileId(serverId, null) - tokenManager.switchActiveServer(serverId) - tokenManager.setProfileId(null) - tokenManager.setProfileToken(null) - tokenManager.saveTokens( + // Same-server approval is still an A -> B account boundary. + // The token manager activates the registry and replaces the + // complete profile/token identity inside one destructive gate. + tokenManager.replaceAccountSession( + serverId = serverId, accessToken = accessToken, refreshToken = refreshToken, expiresIn = expiresIn, ) - serverRegistry.switchTo(serverId) } catch (error: Throwable) { - if (previousServerId != null) { + if (previousServerId != null && serverRegistry.activeServerId.value != previousServerId) { serverRegistry.switchTo(previousServerId) tokenManager.switchActiveServer(previousServerId) - } else { + } else if (previousServerId == null) { serverRegistry.remove(serverId) } throw error diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt index 519949684..ec6154ac7 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiver.kt @@ -22,8 +22,14 @@ import kotlinx.coroutines.launch data class PairingDeviceIdentity( val name: String, val deviceId: String, - /** "Android TV" — passed to device-login start as the platform. */ - val platform: String = "Android TV", + /** + * Passed to device-login start as `device_platform`. Same spelling as the + * X-Prairie-Device-Platform header and the two other TV login entry points + * (TvLoginViewModel, RemotePlaybackIdentityManager), so a TV signed in over + * LAN companion pairing is classified as a TV rather than falling into the + * web frontend's mobile bucket. + */ + val platform: String = "android-tv", ) /** diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicy.kt new file mode 100644 index 000000000..85a0b56bc --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicy.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.common.player + +import androidx.annotation.Dimension +import androidx.media3.ui.SubtitleView +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset + +internal sealed interface AndroidSubtitleTextSize { + data class Fractional(val fraction: Float) : AndroidSubtitleTextSize + data class FixedSp(val sp: Float) : AndroidSubtitleTextSize +} + +internal fun androidSubtitleTextSize( + presentation: AndroidSubtitlePresentation, + preset: SubtitleFontSizePreset, +): AndroidSubtitleTextSize = when (presentation) { + AndroidSubtitlePresentation.Phone -> AndroidSubtitleTextSize.Fractional( + when (preset) { + SubtitleFontSizePreset.Small -> 22.5f + SubtitleFontSizePreset.Medium -> 29.25f + SubtitleFontSizePreset.Large -> 36f + SubtitleFontSizePreset.XLarge -> 45f + SubtitleFontSizePreset.XXLarge -> 54f + } / 720f, + ) + AndroidSubtitlePresentation.Television -> AndroidSubtitleTextSize.FixedSp( + when (preset) { + SubtitleFontSizePreset.Small -> 18f + SubtitleFontSizePreset.Medium -> 22f + SubtitleFontSizePreset.Large -> 26f + SubtitleFontSizePreset.XLarge -> 32f + SubtitleFontSizePreset.XXLarge -> 40f + }, + ) +} + +/** Applies a policy result through the corresponding Media3 subtitle-size API. */ +internal fun applyAndroidSubtitleTextSize( + subtitleView: SubtitleView, + textSize: AndroidSubtitleTextSize, +) { + when (textSize) { + is AndroidSubtitleTextSize.Fractional -> subtitleView.setFractionalTextSize( + textSize.fraction, + /* fractionalRelativeToTextSize = */ false, + ) + is AndroidSubtitleTextSize.FixedSp -> subtitleView.setFixedTextSize( + Dimension.SP, + textSize.sp, + ) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt index 3a37a9ce8..c8ff1996d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudioCapabilityManager.kt @@ -5,7 +5,6 @@ import android.media.AudioFormat import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.AudioTrack -import android.media.Spatializer import android.hardware.display.DisplayManager import android.os.Handler import android.os.Looper @@ -32,6 +31,13 @@ data class AudioDiagnosticsSnapshot( val capabilities: AudioPassthroughCapabilities, ) +/** One atomically published planning view of the active audio route. */ +data class AudioPlaybackRouteSnapshot( + val sinkType: String, + val routeGeneration: Long, + val capabilities: AudioPassthroughCapabilities, +) + /** * Tracks the current [AudioCapabilities] of the active audio sink (built-in * speaker, HDMI receiver, Bluetooth, USB DAC) and exposes them as an @@ -64,11 +70,30 @@ class AudioCapabilityManager( private val generationCounter = AtomicLong(0) private val _outputRouteGeneration = MutableStateFlow(0L) val outputRouteGeneration: StateFlow = _outputRouteGeneration.asStateFlow() + @Volatile + private var playbackRouteSnapshot = AudioPlaybackRouteSnapshot( + sinkType = "unknown", + routeGeneration = 0L, + capabilities = AudioPassthroughCapabilities(), + ) + private var routeSnapshotInitialized = false private fun publishCapabilities(next: AudioPassthroughCapabilities) { - if (_capabilities.value == next) return + val changed = _capabilities.value != next + if (!changed && routeSnapshotInitialized) return + val generation = if (changed) { + generationCounter.incrementAndGet() + } else { + _outputRouteGeneration.value + } + playbackRouteSnapshot = AudioPlaybackRouteSnapshot( + sinkType = currentSinkType(), + routeGeneration = generation, + capabilities = next.immutableCopy(), + ) + routeSnapshotInitialized = true _capabilities.value = next - _outputRouteGeneration.value = generationCounter.incrementAndGet() + _outputRouteGeneration.value = generation Log.i( TAG, "Audio output capabilities updated: " + @@ -78,7 +103,14 @@ class AudioCapabilityManager( } private fun bumpOutputRouteGeneration() { - _outputRouteGeneration.value = generationCounter.incrementAndGet() + val generation = generationCounter.incrementAndGet() + playbackRouteSnapshot = AudioPlaybackRouteSnapshot( + sinkType = currentSinkType(), + routeGeneration = generation, + capabilities = _capabilities.value.immutableCopy(), + ) + routeSnapshotInitialized = true + _outputRouteGeneration.value = generation } private var lastDisplayHdr = DisplayHdrProbe.probe(appContext) @@ -106,24 +138,22 @@ class AudioCapabilityManager( // Spatializer (Android 12+ / API 31+). The head-tracking + enabled state // flips independently of the audio route (e.g. plugging in BT head-tracked // earbuds on the same device), so we subscribe and re-emit. - private val spatializer: Spatializer? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + /** + * All Spatializer access is confined to [SpatializerBridge], held here as + * `Any?` so no Spatializer type appears in this class's fields, signatures + * or method bodies. That isolation is the point: ART resolves a method's + * referenced classes when the method runs, before any version branch inside + * it is evaluated, so a reference sitting in an untaken `if` still throws + * NoClassDefFoundError on a device without the class — and a runCatching + * around it does not help, because the failure happens outside the try. + */ + private val spatializerBridge: Any? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { runCatching { - audioManager.spatializer - }.getOrNull() - } else null - - private val spatializerListener: Any? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2 && spatializer != null) { - object : Spatializer.OnSpatializerStateChangedListener { - override fun onSpatializerEnabledChanged(sp: Spatializer, enabled: Boolean) { + SpatializerBridge(audioManager) { enabled -> publishCapabilities(_capabilities.value.copy(spatializerEnabled = enabled)) } - override fun onSpatializerAvailableChanged(sp: Spatializer, available: Boolean) { - // Available but disabled == user has turned spatialization off — - // ride the enabledChanged callback above instead. - } - } + }.getOrNull() } else null init { @@ -134,15 +164,8 @@ class AudioCapabilityManager( publishCapabilities(mapCapabilities(initialCapabilities)) (appContext.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager) ?.registerDisplayListener(displayListener, Handler(Looper.getMainLooper())) - val sp = spatializer - val spl = spatializerListener - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2 && sp != null && spl != null) { - runCatching { - sp.addOnSpatializerStateChangedListener( - { it.run() }, - spl as Spatializer.OnSpatializerStateChangedListener, - ) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + runCatching { (spatializerBridge as? SpatializerBridge)?.subscribe() } } } @@ -152,7 +175,10 @@ class AudioCapabilityManager( } val codecs = supportedEncodings.map(EncodingSupport::codec) - val spatializerEnabled = spatializer?.isEnabled ?: false + val spatializerEnabled = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + runCatching { (spatializerBridge as? SpatializerBridge)?.isEnabled() }.getOrNull() ?: false + } else false // Media3's aggregate maxChannelCount is not enough for route planning: // an AVR can accept eight-channel TrueHD but only six-channel AC3, for @@ -185,22 +211,31 @@ class AudioCapabilityManager( return sinkType(devices) } + /** Planning callers consume this single value, never separate route flows. */ + fun playbackRouteSnapshot(): AudioPlaybackRouteSnapshot = playbackRouteSnapshot + /** Privacy-safe immutable route evidence. Raw device names and addresses never leave this class. */ fun diagnosticsSnapshot(): AudioDiagnosticsSnapshot { val devices = currentOutputDevices() + val planning = playbackRouteSnapshot return AudioDiagnosticsSnapshot( - sinkType = sinkType(devices), + sinkType = planning.sinkType, routeHashes = devices.map(::routeHash).distinct().sorted(), - routeGeneration = outputRouteGeneration.value, - capabilities = capabilities.value.copy( - passthroughCodecs = capabilities.value.passthroughCodecs.toList(), - entries = capabilities.value.entries.map { entry -> - entry.copy(channelCounts = entry.channelCounts.toList(), layouts = entry.layouts.toList()) - }, - ), + routeGeneration = planning.routeGeneration, + capabilities = planning.capabilities, ) } + private fun AudioPassthroughCapabilities.immutableCopy(): AudioPassthroughCapabilities = copy( + passthroughCodecs = passthroughCodecs.toList(), + entries = entries.map { entry -> + entry.copy( + channelCounts = entry.channelCounts.toList(), + layouts = entry.layouts.toList(), + ) + }, + ) + private fun currentOutputDevices(): List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val attrs = android.media.AudioAttributes.Builder() @@ -216,7 +251,12 @@ class AudioCapabilityManager( devices.map(::sinkCategory).minByOrNull(::sinkPriority) ?: "unknown" private fun routeHash(device: AudioDeviceInfo): String { - val raw = "${device.type}|${device.id}|${device.address}" + // getAddress() is API 28; below that the route is identified by type and + // id alone. Unguarded this threw NoSuchMethodError on Android 7-8.1 + // whenever diagnostics were collected. + val address = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) device.address else "" + val raw = "${device.type}|${device.id}|$address" return MessageDigest.getInstance("SHA-256") .digest(raw.encodeToByteArray()) .take(ROUTE_HASH_BYTES) @@ -274,13 +314,7 @@ class AudioCapabilityManager( .setUsage(android.media.AudioAttributes.USAGE_MEDIA) .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) .build() - val layoutsToProbe = listOf( - AudioLayoutProbe(2, AudioFormat.CHANNEL_OUT_STEREO, listOf("stereo")), - // FFprobe commonly distinguishes 5.1 and 5.1(side), while - // Android exposes one encoded six-channel mask to AudioTrack. - AudioLayoutProbe(6, AudioFormat.CHANNEL_OUT_5POINT1, listOf("5.1", "5.1(side)")), - AudioLayoutProbe(8, AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, listOf("7.1")), - ) + val layoutsToProbe = PASSTHROUGH_LAYOUT_PROBES return encodings.mapNotNull { support -> val channelCounts = sortedSetOf() val layouts = sortedSetOf() @@ -314,12 +348,18 @@ class AudioCapabilityManager( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val support = AudioManager.getDirectPlaybackSupport(format, attributes) support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED != 0 - } else { + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { AudioTrack.isDirectPlaybackSupported(format, attributes) + } else { + // API 29 introduced the query. Below it the platform cannot be + // asked, and runCatching was silently answering "no passthrough" — + // so pre-Q devices were transcoding audio they could have played + // directly. Same answer, but now it is a deliberate one. + false } }.getOrDefault(false) - private data class AudioLayoutProbe( + internal data class AudioLayoutProbe( val channelCount: Int, val channelMask: Int, val layoutNames: List, @@ -338,7 +378,7 @@ class AudioCapabilityManager( val encodingSupport = listOf( EncodingSupport("ac3", AudioFormat.ENCODING_AC3), EncodingSupport("eac3", AudioFormat.ENCODING_E_AC3), - EncodingSupport("eac3_joc", AudioFormat.ENCODING_E_AC3_JOC), + EncodingSupport("eac3_joc", AudioFormat.ENCODING_E_AC3_JOC, Build.VERSION_CODES.P), EncodingSupport("dts", AudioFormat.ENCODING_DTS), EncodingSupport("dts_hd", AudioFormat.ENCODING_DTS_HD, Build.VERSION_CODES.M), EncodingSupport("truehd", AudioFormat.ENCODING_DOLBY_TRUEHD, Build.VERSION_CODES.N_MR1), @@ -346,3 +386,63 @@ class AudioCapabilityManager( ) } } + +/** + * Every reference to [android.media.Spatializer] lives here, and this class is + * only ever loaded on API 32+. + * + * `Spatializer` and `AudioManager.getSpatializer()` were added in API 32 + * (S_V2), not 31 — an Android 12 device therefore has no such class, and + * touching it from a class that loads on every API level takes the whole + * process down at construction time. + */ +@androidx.annotation.RequiresApi(Build.VERSION_CODES.S_V2) +private class SpatializerBridge( + audioManager: AudioManager, + private val onEnabledChanged: (Boolean) -> Unit, +) { + private val spatializer: android.media.Spatializer = audioManager.spatializer + + private val listener = object : android.media.Spatializer.OnSpatializerStateChangedListener { + override fun onSpatializerEnabledChanged(sp: android.media.Spatializer, enabled: Boolean) { + onEnabledChanged(enabled) + } + + override fun onSpatializerAvailableChanged(sp: android.media.Spatializer, available: Boolean) { + // Available but disabled == the user turned spatialisation off — + // ride the enabledChanged callback above instead. + } + } + + fun subscribe() { + spatializer.addOnSpatializerStateChangedListener({ it.run() }, listener) + } + + fun isEnabled(): Boolean = spatializer.isEnabled +} + +/** + * The encoded layouts probed per audio format. Deliberately the single source + * of truth: [PROBED_PASSTHROUGH_CHANNEL_COUNTS] is derived from it, so the + * reader of a passthrough entry can never disagree with the writer about which + * counts were actually asked. A constant that merely *claimed* to match would + * need a runtime check, and a structural invariant is not worth crashing + * capability detection over. + */ +@UnstableApi +internal val PASSTHROUGH_LAYOUT_PROBES: List = listOf( + AudioCapabilityManager.AudioLayoutProbe(2, AudioFormat.CHANNEL_OUT_STEREO, listOf("stereo")), + // FFprobe commonly distinguishes 5.1 and 5.1(side), while Android exposes + // one encoded six-channel mask to AudioTrack. + AudioCapabilityManager.AudioLayoutProbe( + 6, + AudioFormat.CHANNEL_OUT_5POINT1, + listOf("5.1", "5.1(side)"), + ), + AudioCapabilityManager.AudioLayoutProbe( + 8, + AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, + listOf("7.1"), + ), +) + diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt index 49059ad0e..aa1a1cbb1 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt @@ -13,10 +13,16 @@ import org.prairieserver.prairie.common.downloads.DownloadEnqueuer import org.prairieserver.prairie.common.downloads.OfflineMediaResolver import org.prairieserver.prairie.model.audiobook.AudiobookBookmark import org.prairieserver.prairie.model.catalog.VersionChapter -import org.prairieserver.prairie.model.playback.PlayMethod -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse +import org.prairieserver.prairie.model.playback.QUALITY_ORIGINAL_V3 +import org.prairieserver.prairie.model.playback.ClientCodecCapabilities +import org.prairieserver.prairie.model.playback.ClientPlaybackContext +import org.prairieserver.prairie.model.playback.PlaybackTimeline +import org.prairieserver.prairie.model.playback.ProgressPersistenceV3 import org.prairieserver.prairie.model.playback.resolvePlaybackStartPosition import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition +import org.prairieserver.prairie.common.player.seek.PlaybackSeekDecision +import org.prairieserver.prairie.common.player.seek.decideSeek +import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.repository.CatalogRepository @@ -34,7 +40,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlin.math.abs import kotlin.random.Random @@ -81,6 +86,7 @@ data class AudiobookPlayerUiState( class AudiobookPlayerViewModel( private val catalogRepository: CatalogRepository, private val playbackSessionManager: PlaybackSessionManager, + private val playbackSessionLifecycle: PlaybackSessionLifecycle, private val capabilityDetector: PlaybackCapabilityDetector, private val bookmarksStore: AudiobookBookmarksStore, // Track B: durable position via the unified outbox (replaces AudiobookPositionStore @@ -168,6 +174,11 @@ class AudiobookPlayerViewModel( * part loads or on the single-file fallback. */ private var activeTrackIndex: Int? = null + /** Source/player mapping for the protocol-v3 transport mounted in Media3. */ + private var activePlaybackTimeline: PlaybackTimeline? = null + /** Server-declared full runtime for the active effective file; null is unknown. */ + private var activePlaybackSourceDurationSeconds: Double? = null + /** Invalidates an in-flight [loadTrack] when the user seeks again, the book * advances, or the player closes while `/playback/start` is still on the * wire (Apple `loadGeneration`). */ @@ -198,6 +209,7 @@ class AudiobookPlayerViewModel( init { observeAudiobookSettings() + observeMissingPlaybackSessions() if (contentId.isNotBlank()) { loadDetail() loadBookmarks() @@ -205,6 +217,83 @@ class AudiobookPlayerViewModel( } } + private fun observeMissingPlaybackSessions() { + viewModelScope.launch { + playbackSessionLifecycle.missingSessionEvents.collect { renewal -> + val state = _uiState.value + if ( + isClosing || + state.sessionId != renewal.staleSessionId || + renewal.startParams.contentId != contentId || + renewal.startParams.fileId != state.selectedFileId + ) { + return@collect + } + val profileId = profileRepository.getActiveProfileId() ?: return@collect + val generation = ++loadGeneration + val trackIndex = activeTrackIndex + when ( + val playback = startPartSession( + fileId = renewal.startParams.fileId, + profileId = profileId, + startPosition = renewal.positionSeconds, + capabilities = renewal.startParams.capabilities, + clientPlaybackContext = renewal.startParams.clientPlaybackContext, + ) + ) { + is ApiResult.Success -> { + val start = playback.data + if (generation != loadGeneration || isClosing) { + if (start is VideoSessionStartV3.Ready) { + runCatching { + playbackSessionManager.stopSession(start.session.sessionId) + } + } + } else if (start is VideoSessionStartV3.Ready) { + applyStartedSession( + ready = start, + localSeek = renewal.positionSeconds, + globalPosition = state.positionSeconds, + trackIndex = trackIndex, + fileId = renewal.startParams.fileId, + isCurrent = { + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == renewal.staleSessionId + }, + ) + } else { + applyFailedSessionStart( + start.failureMessage(), + expectedSessionId = renewal.staleSessionId, + ) + } + } + is ApiResult.Error -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == renewal.staleSessionId + ) { + applyFailedSessionStart( + playback.message, + expectedSessionId = renewal.staleSessionId, + ) + } + is ApiResult.NetworkError -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == renewal.staleSessionId + ) { + applyFailedSessionStart( + playback.exception.message ?: "Network error", + expectedSessionId = renewal.staleSessionId, + ) + } + } + } + } + } + /** Mirror the persisted skip interval into ui-state, and seed playback * speed from the saved default exactly once. */ private fun observeAudiobookSettings() { @@ -393,12 +482,15 @@ class AudiobookPlayerViewModel( fileId = selectedVersion.fileId, profileId = profileId, startGlobal = startGlobal, + generation = generation, ) return@launch } timeline = builtTimeline activeTrackIndex = null + activePlaybackTimeline = null + activePlaybackSourceDurationSeconds = null loadTrack(atGlobalTime = startGlobal, autoplay = true) } is ApiResult.Error -> loadOfflineOnly(error = r.message) @@ -434,12 +526,13 @@ class AudiobookPlayerViewModel( val localTime = tl.localTimeFor(clamped, track) if (activeTrackIndex == index && _uiState.value.sessionId != null) { - // Same part: keep the stream, seek the engine to the file-local - // offset. pendingSeek is consumed by the Compose layer as a - // controller.seekTo in part-local (stream) space. - _uiState.update { it.copy(positionSeconds = clamped) } - if (autoplay) _uiState.update { it.copy(isPaused = false) } - _pendingSeek.value = localTime + seekActiveSession( + sourceLocalSeconds = localTime, + globalSeconds = clamped, + autoplay = autoplay, + trackIndex = index, + fileId = track.fileId, + ) return } @@ -450,7 +543,8 @@ class AudiobookPlayerViewModel( // position is captured BEFORE that pre-write: retiring the old session // must report where the old part actually was, not the new target // mapped back into it. - val outgoingLocal = sessionLocalPosition(_uiState.value) + val outgoingState = _uiState.value + val outgoingLocal = sessionLocalPosition(outgoingState) pendingTrackLoadLocalStart = localTime _uiState.update { it.copy(positionSeconds = clamped) } if (autoplay) _uiState.update { it.copy(isPaused = false) } @@ -462,50 +556,47 @@ class AudiobookPlayerViewModel( _uiState.update { it.copy(error = "No active profile") } return@launch } - retireActiveSession(outgoingLocal) + retireActiveSession( + finalLocalPosition = outgoingLocal, + finalGlobalPosition = outgoingState.positionSeconds, + finalGlobalDuration = outgoingState.durationSeconds, + ) when (val playback = startPartSession(track.fileId, profileId, localTime)) { is ApiResult.Success -> { + val start = playback.data if (generation != loadGeneration || isClosing) { // Superseded by a newer seek/advance or a close while the // request was in flight — release the session we no // longer need (Apple parity). - runCatching { playbackSessionManager.stopSession(playback.data.sessionId) } + if (start is VideoSessionStartV3.Ready) { + runCatching { playbackSessionManager.stopSession(start.session.sessionId) } + } return@launch } - applyStartedSession( - session = playback.data, - localSeek = localTime, - globalPosition = clamped, - trackIndex = index, - fileId = track.fileId, - generation = generation, - ) + if (start is VideoSessionStartV3.Ready) { + applyStartedSession( + ready = start, + localSeek = localTime, + globalPosition = clamped, + trackIndex = index, + fileId = track.fileId, + isCurrent = { + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == null + }, + ) + } else { + applyFailedSessionStart(start.failureMessage()) + } } is ApiResult.Error -> { if (generation != loadGeneration) return@launch - pendingTrackLoadLocalStart = null - _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = playback.message.ifBlank { "Audiobook playback failed" }, - ) - } + applyFailedSessionStart(playback.message.ifBlank { "Audiobook playback failed" }) } is ApiResult.NetworkError -> { if (generation != loadGeneration) return@launch - pendingTrackLoadLocalStart = null - _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = playback.exception.message ?: "Network error", - ) - } + applyFailedSessionStart(playback.exception.message ?: "Network error") } } } @@ -520,76 +611,80 @@ class AudiobookPlayerViewModel( fileId: Int, profileId: String, startGlobal: Double, + generation: Int, ) { - // Capture the load generation before the session round-trip so a close - // (or a competing load) while the request is in flight invalidates any - // transcode fallback inside applyStartedSession. - val generation = loadGeneration when (val playback = startPartSession(fileId, profileId, startGlobal)) { - is ApiResult.Success -> applyStartedSession( - session = playback.data, - localSeek = startGlobal, - globalPosition = startGlobal, - trackIndex = null, - fileId = fileId, - generation = generation, - ) - is ApiResult.Error -> _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = playback.message.ifBlank { "Audiobook playback failed" }, - ) + is ApiResult.Success -> { + val start = playback.data + if (generation != startGeneration || isClosing) { + if (start is VideoSessionStartV3.Ready) { + runCatching { playbackSessionManager.stopSession(start.session.sessionId) } + } + return + } + if (start is VideoSessionStartV3.Ready) { + applyStartedSession( + ready = start, + localSeek = startGlobal, + globalPosition = startGlobal, + trackIndex = null, + fileId = fileId, + isCurrent = { generation == startGeneration && !isClosing }, + ) + } else { + applyFailedSessionStart(start.failureMessage()) + } } - is ApiResult.NetworkError -> _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = playback.exception.message ?: "Network error", - ) + is ApiResult.Error -> if (generation == startGeneration && !isClosing) { + applyFailedSessionStart(playback.message.ifBlank { "Audiobook playback failed" }) + } + is ApiResult.NetworkError -> if (generation == startGeneration && !isClosing) { + applyFailedSessionStart(playback.exception.message ?: "Network error") } } } /** * Start a per-part playback session for [fileId] at the file-local - * [startPosition] (Apple `startSession(for:localTime:)`). Audiobooks are - * audio-only; their sole "video" stream is an embedded cover-art still - * (mjpeg/png/jpeg). The server's resolver gates DIRECT play on the client - * decoding the file's video codec, so advertise the still-image codecs to - * keep audiobooks on DIRECT instead of a pointless audio-only transcode. - * Scoped here so real video playback (PlayerViewModel) keeps its true - * decoder list. + * [startPosition] (Apple `startSession(for:localTime:)`). + * + * The advertised capabilities are the device's real ones. Audiobooks used to + * be started with still-image codecs (mjpeg/png/jpeg) spliced into + * `codecsVideo`, because a cover-art picture was persisted as a video track + * and the resolver then gated direct play on decoding it. Protocol v3 makes + * that untenable and unnecessary: this client advertises + * `video_evidence: "exact"`, so claiming decoders `MediaCodecList` never + * enumerated would be a false attestation — and the server no longer records + * cover art as a video track, so an audiobook reaches the audio-only planner + * on its own merits. * - * Started with `disableProgressPersistence = true` (Apple sets this on every - * per-part session) so the session never persists the part-local position as - * the book's position. Whole-book resume is driven separately by routing the - * durable sink through the global position (see [savePosition]). Both the - * multi-part part-session start and the single-file fallback - * ([startSingleFileSession]) flow through here, so no audiobook session ever - * persists a part-local position. + * Part-local positions are never persisted as the book's position. That is + * no longer something the client asks for: the server derives it from the + * file's presentation-part count, so a multi-part audiobook session owns no + * resume timeline whether or not the client remembers to opt out. Whole-book + * resume is driven separately by routing the durable sink through the global + * position (see [savePosition]). */ private suspend fun startPartSession( fileId: Int, profileId: String, startPosition: Double, - ): ApiResult { - val capabilities = capabilityDetector.detect().let { caps -> - caps.copy( - codecsVideo = (caps.codecsVideo + AUDIOBOOK_COVER_ART_CODECS) - .distinct(), - ) - } - return playbackSessionManager.startSession( + capabilities: ClientCodecCapabilities? = null, + clientPlaybackContext: ClientPlaybackContext? = null, + ): ApiResult { + val resolvedCapabilities = capabilities ?: capabilityDetector.detect() + val resolvedContext = clientPlaybackContext + ?: capabilityDetector.detectPlaybackContext(capabilities = resolvedCapabilities) + return playbackSessionManager.startVideoSessionV3( fileId = fileId, profileId = profileId, - capabilities = capabilities, + capabilities = resolvedCapabilities, + clientPlaybackContext = resolvedContext, + audioTrackIndex = null, + subtitleTrackIndex = null, + qualityPreference = QUALITY_ORIGINAL_V3, startPosition = startPosition, - disableProgressPersistence = true, + progressPersistence = ProgressPersistenceV3.CLIENT, ) } @@ -599,124 +694,188 @@ class AudiobookPlayerViewModel( * (the outgoing part's file-local position, captured by the caller BEFORE * it pre-writes the target position into ui-state), then stop it. */ - private suspend fun retireActiveSession(finalLocalPosition: Double) { + private suspend fun retireActiveSession( + finalLocalPosition: Double, + finalGlobalPosition: Double, + finalGlobalDuration: Double, + ) { val sessionId = _uiState.value.sessionId ?: return + playbackSessionLifecycle.reportPosition( + positionSec = finalLocalPosition, + durationSec = activePartDurationSeconds(), + isPaused = true, + expectedSessionId = sessionId, + persistencePositionSec = finalGlobalPosition, + persistenceDurationSec = finalGlobalDuration, + ) _uiState.update { it.copy(sessionId = null) } - runCatching { - playbackSessionManager.reportProgress(sessionId, finalLocalPosition, isPaused = true) - } - runCatching { playbackSessionManager.stopSession(sessionId) } + playbackSessionLifecycle.stop(expectedSessionId = sessionId) + activePlaybackTimeline = null + activePlaybackSourceDurationSeconds = null } /** - * Apply a started session to UI state, honoring the server's `play_method`. - * Mirrors the video player's - * [org.prairieserver.prairie.android.ui.screens.player.PlayerViewModel] handling: - * a DIRECT session streams [PlaybackSessionResponse.streamUrl] as-is, while - * REMUX / TRANSCODE require an explicit transcode start whose HLS manifest - * URL is what Media3 must actually load. Without this branch the raw - * `stream_url` for a transcode session 404s until a job is started. + * Apply a started v3 session to UI state. * - * Audiobooks have no video resolution, so the transcode resolution is left - * empty — the server keeps audio-only delivery. + * There is no play-method branch any more. A v3 plan's `stream.url` is the + * URL to load whatever the delivery turned out to be — the server has + * already started whatever it needed to serve it — so the old + * DIRECT-vs-REMUX/TRANSCODE split, which had to fire a second + * transcode-start round-trip before the stream URL resolved, collapses into + * a single assignment. * * [localSeek] is the file-local offset the engine seeks to (fed to the * Compose layer via [resumePositionSeconds]); [globalPosition] is the - * whole-book position shown in the UI; [trackIndex] becomes [activeTrackIndex] - * (null on the single-file fallback). [generation] is the caller's captured - * [loadGeneration]: the transcode fallback awaits a second round-trip, so a - * newer load / close during that await must not have its state clobbered by - * this (now stale) one. + * whole-book position shown in the UI; [trackIndex] becomes + * [activeTrackIndex] (null on the single-file fallback). + * + * The player start position comes from the plan rather than from + * [localSeek]: the two differ when the server anchors the stream somewhere + * other than the requested offset, and the plan is the authority on where + * the delivered stream actually begins. */ private suspend fun applyStartedSession( - session: PlaybackSessionResponse, + ready: VideoSessionStartV3.Ready, localSeek: Double, globalPosition: Double, trackIndex: Int?, fileId: Int, - generation: Int, - ) { - // Server stream URLs are relative (e.g. /playback/stream/...). The - // Compose layer hands them straight to Media3, so they must be - // absolute here or OkHttp fails the open with "Malformed URL". - val serverUrl = playbackSessionManager.getServerUrl() - val resolvedLocalSeek = localSeek.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 - activeTrackIndex = trackIndex - // The Compose layer applies resumePositionSeconds as the *stream* start - // position, so it is file-local. For a multi-part load, hold engine-time - // mapping suppressed until the stream settles near this value. - pendingTrackLoadLocalStart = if (trackIndex != null) resolvedLocalSeek else null - _resumePosition.value = resolvedLocalSeek.takeIf { it > 0.0 } - if (session.playMethod == PlayMethod.TRANSCODE || session.playMethod == PlayMethod.REMUX) { - val mode = if (session.playMethod == PlayMethod.REMUX) { - PlaybackSessionManager.TranscodeMode.REMUX - } else { - PlaybackSessionManager.TranscodeMode.FULL - } - when (val r = playbackSessionManager.startTranscodeFallback( - session = session, - seekSeconds = resolvedLocalSeek, - resolution = "", - mode = mode, - )) { - is ApiResult.Success -> { - if (generation != loadGeneration || isClosing) { - // Superseded by a newer load or a close while the - // transcode start was on the wire — release the fresh - // session instead of clobbering the newer load's state. - runCatching { playbackSessionManager.stopSession(r.data.sessionId) } - return - } - _uiState.update { - it.copy( - streamUrl = resolvePlaybackStreamUrl(serverUrl, r.data.streamUrl), - sessionId = r.data.sessionId, - selectedFileId = fileId, - positionSeconds = globalPosition, - error = null, - ) - } - } - is ApiResult.Error -> { - if (generation != loadGeneration || isClosing) return - pendingTrackLoadLocalStart = null - _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = r.message.ifBlank { "Audiobook transcode failed" }, - ) - } - } - is ApiResult.NetworkError -> { - if (generation != loadGeneration || isClosing) return - pendingTrackLoadLocalStart = null - _uiState.update { - it.copy( - streamUrl = null, - sessionId = null, - isPlaying = false, - isPaused = true, - error = r.exception.message ?: "Network error", - ) - } - } - } - } else { + isCurrent: () -> Boolean, + ): Boolean { + var lifecycleOwnsSession = false + var published = false + try { + // Server stream URLs are relative (e.g. /playback/stream/...). The + // Compose layer hands them straight to Media3, so they must be + // absolute here or OkHttp fails the open with "Malformed URL". + val serverUrl = playbackSessionManager.getServerUrl() + val requestedSeek = localSeek.takeIf { it.isFinite() && it >= 0.0 } ?: 0.0 + val resolvedLocalSeek = ready.plan.timeline.playerStartSeconds + .takeIf { it.isFinite() && it >= 0.0 } + ?: requestedSeek + val playbackTimeline = ready.plan.timeline.toPlaybackTimeline() + lifecycleOwnsSession = playbackSessionLifecycle.adoptActiveSessionIfCurrent( + params = StartParams( + contentId = contentId, + fileId = fileId, + capabilities = ready.capabilities, + qualityPreference = QUALITY_ORIGINAL_V3, + startPosition = ready.plan.timeline.sourceStartSeconds + .takeIf { it.isFinite() && it >= 0.0 } + ?: requestedSeek, + clientPlaybackContext = ready.clientPlaybackContext, + ), + session = ready.session, + isCurrent = isCurrent, + ) + if (!lifecycleOwnsSession) return false + + // Adoption can wait behind a concurrent teardown. Check the transaction + // again before publishing; the main-thread state writes below do not + // suspend, so a close cannot interleave after this gate. + if (!isCurrent()) return false + + activeTrackIndex = trackIndex + activePlaybackTimeline = playbackTimeline + activePlaybackSourceDurationSeconds = ready.plan.source.durationSeconds + ?.takeIf { it.isFinite() && it >= 0.0 } + val sourceStart = playbackTimeline.sourcePositionForPlayer(resolvedLocalSeek) + ?: ready.plan.timeline.sourceStartSeconds.coerceAtLeast(0.0) + val partDuration = ready.session.durationSeconds ?: 0.0 + playbackSessionLifecycle.reportPosition( + positionSec = sourceStart, + durationSec = partDuration, + isPaused = _uiState.value.isPaused, + expectedSessionId = ready.session.sessionId, + persistencePositionSec = globalPosition, + persistenceDurationSec = _uiState.value.durationSeconds, + ) + // The Compose layer applies resumePositionSeconds as the *stream* start + // position, so it is file-local. For a multi-part load, hold engine-time + // mapping suppressed until the stream settles near this value. + pendingTrackLoadLocalStart = if (trackIndex != null) resolvedLocalSeek else null + _resumePosition.value = resolvedLocalSeek.takeIf { it > 0.0 } _uiState.update { it.copy( - streamUrl = resolvePlaybackStreamUrl(serverUrl, session.streamUrl), - sessionId = session.sessionId, + streamUrl = resolvePlaybackStreamUrl(serverUrl, ready.plan.stream.url), + sessionId = ready.session.sessionId, selectedFileId = fileId, positionSeconds = globalPosition, error = null, ) } + published = true + return true + } finally { + if (!published) { + withContext(NonCancellable) { + if (lifecycleOwnsSession) { + playbackSessionLifecycle.stop(expectedSessionId = ready.session.sessionId) + } else { + playbackSessionManager.abandonActiveVideoPlanIfCurrent( + sessionId = ready.session.sessionId, + planId = ready.plan.planId, + ) + } + } + } + } + } + + /** Releases a committed replan that lost ownership before UI adoption. */ + private suspend fun abandonUnpublishedSession(ready: VideoSessionStartV3.Ready) { + withContext(NonCancellable) { + playbackSessionManager.abandonActiveVideoPlanIfCurrent( + sessionId = ready.session.sessionId, + planId = ready.plan.planId, + ) } } + /** + * Report a v3 start that produced no playable plan. A terminal result + * carries the server's own reason; a protocol-version rejection means this + * build is talking to a server that predates the contract it speaks. + */ + private fun applyFailedSessionStart( + failureMessage: String, + expectedSessionId: String? = _uiState.value.sessionId, + ) { + val state = _uiState.value + if (expectedSessionId != null && state.sessionId != expectedSessionId) return + if (expectedSessionId != null) { + playbackSessionLifecycle.reportPosition( + positionSec = sessionLocalPosition(state), + durationSec = activePartDurationSeconds(), + isPaused = true, + expectedSessionId = expectedSessionId, + persistencePositionSec = state.positionSeconds, + persistenceDurationSec = state.durationSeconds, + ) + playbackSessionLifecycle.stopAsync(expectedSessionId = expectedSessionId) + } + pendingTrackLoadLocalStart = null + activePlaybackTimeline = null + activePlaybackSourceDurationSeconds = null + _uiState.update { + it.copy( + streamUrl = null, + sessionId = null, + isPlaying = false, + isPaused = true, + error = failureMessage, + ) + } + } + + private fun VideoSessionStartV3.failureMessage(): String = when (this) { + is VideoSessionStartV3.Ready -> "" + is VideoSessionStartV3.Terminal -> + message.ifBlank { "Audiobook playback failed" } + VideoSessionStartV3.ServerUpgradeRequired -> + "This server does not support the playback protocol this app speaks." + } + /** * On the current part ending, cross into the next part (Apple * `advanceAfterTrackEnd`): the next part starts just past the current one's @@ -725,7 +884,7 @@ class AudiobookPlayerViewModel( */ private fun advanceAfterTrackEnd(active: AudioPlaybackTrack) { val tl = timeline ?: return - val nextStart = active.startOffsetSeconds + active.durationSeconds + TRACK_END_EPPRAIRIEN + val nextStart = active.startOffsetSeconds + active.durationSeconds + TRACK_END_EPSILON val total = _uiState.value.durationSeconds if (tl.trackIndexAt(nextStart) != active.index && nextStart < total) { loadTrack(atGlobalTime = nextStart, autoplay = true) @@ -843,12 +1002,25 @@ class AudiobookPlayerViewModel( } } + val mappedSourceLocal = activePlaybackTimeline?.sourcePositionForPlayer(seconds) + val sourceLocal = mappedSourceLocal ?: seconds val global = if (tl != null && active != null) { - tl.globalTimeFor(seconds, active) + tl.globalTimeFor(sourceLocal, active) } else { - seconds + sourceLocal + } + val updated = _uiState.value.copy(positionSeconds = global) + _uiState.value = updated + updated.sessionId?.let { sessionId -> + playbackSessionLifecycle.reportPosition( + positionSec = sourceLocal, + durationSec = activePartDurationSeconds(), + isPaused = updated.isPaused, + expectedSessionId = sessionId, + persistencePositionSec = global, + persistenceDurationSec = updated.durationSeconds, + ) } - _uiState.update { it.copy(positionSeconds = global) } // End-of-part: once the engine plays (near) the end of a non-final part, // cross into the next part. Guarded to while actually playing so a pause @@ -857,8 +1029,8 @@ class AudiobookPlayerViewModel( // advances on a discrete end EVENT instead), so a zero/near-zero // duration would trip the advance on every tick. if (tl != null && active != null && !tl.isSingle && !_uiState.value.isPaused) { - if (active.durationSeconds > TRACK_END_EPPRAIRIEN && - seconds >= active.durationSeconds - TRACK_END_EPPRAIRIEN + if (active.durationSeconds > TRACK_END_EPSILON && + sourceLocal >= active.durationSeconds - TRACK_END_EPSILON ) { advanceAfterTrackEnd(active) } @@ -879,6 +1051,83 @@ class AudiobookPlayerViewModel( _uiState.update { it.copy(isPaused = isPaused) } } + /** Route Media3 failures through the same protocol-v3 replan transaction. */ + fun onPlayerError(error: androidx.media3.common.PlaybackException) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + val fileId = state.selectedFileId ?: return + val globalPosition = state.positionSeconds + val sourcePosition = sessionLocalPosition(state) + val trackIndex = activeTrackIndex + val generation = ++loadGeneration + viewModelScope.launch { + when ( + val result = playbackSessionManager.replanActiveVideoSession( + classification = error.audiobookFailureClassification(), + message = error.message, + positionSeconds = sourcePosition, + audioTrackIndex = null, + subtitleTrackIndex = null, + decoderName = error.cause?.javaClass?.simpleName, + diagnostics = mapOf("surface" to "audiobook"), + ) + ) { + is ApiResult.Success -> { + val replacement = result.data + if ( + generation != loadGeneration || + isClosing || + _uiState.value.sessionId != sessionId + ) { + if (replacement is VideoSessionStartV3.Ready) { + abandonUnpublishedSession(replacement) + } + return@launch + } + if (replacement is VideoSessionStartV3.Ready) { + applyStartedSession( + ready = replacement, + localSeek = sourcePosition, + globalPosition = globalPosition, + trackIndex = trackIndex, + fileId = fileId, + isCurrent = { + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + }, + ) + } else { + applyFailedSessionStart( + replacement.failureMessage(), + expectedSessionId = sessionId, + ) + } + } + is ApiResult.Error -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + ) { + applyFailedSessionStart( + result.message.ifBlank { "Audiobook playback failed" }, + expectedSessionId = sessionId, + ) + } + is ApiResult.NetworkError -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + ) { + applyFailedSessionStart( + result.exception.message ?: "Network error", + expectedSessionId = sessionId, + ) + } + } + } + } + fun togglePlay() { _uiState.update { it.copy(isPaused = !it.isPaused) } } @@ -903,13 +1152,114 @@ class AudiobookPlayerViewModel( fun seekTo(seconds: Double) { val tl = timeline if (tl == null) { - _pendingSeek.value = seconds + val target = seconds .coerceIn(0.0, _uiState.value.durationSeconds.coerceAtLeast(0.0)) + if (_uiState.value.sessionId != null && activePlaybackTimeline != null) { + seekActiveSession( + sourceLocalSeconds = target, + globalSeconds = target, + autoplay = !_uiState.value.isPaused, + trackIndex = null, + fileId = _uiState.value.selectedFileId ?: return, + ) + } else { + _pendingSeek.value = target + } return } loadTrack(atGlobalTime = seconds, autoplay = !_uiState.value.isPaused) } + private fun seekActiveSession( + sourceLocalSeconds: Double, + globalSeconds: Double, + autoplay: Boolean, + trackIndex: Int?, + fileId: Int, + ) { + val playbackTimeline = activePlaybackTimeline + if (playbackTimeline == null) { + _uiState.update { + it.copy(positionSeconds = globalSeconds, isPaused = if (autoplay) false else it.isPaused) + } + _pendingSeek.value = sourceLocalSeconds + return + } + when (val decision = playbackTimeline.decideSeek(sourceLocalSeconds)) { + is PlaybackSeekDecision.NativeSeek -> { + _uiState.update { + it.copy(positionSeconds = globalSeconds, isPaused = if (autoplay) false else it.isPaused) + } + _pendingSeek.value = decision.targetPlayerPositionSeconds + } + is PlaybackSeekDecision.ServerReanchor -> { + val generation = ++loadGeneration + val sessionId = _uiState.value.sessionId ?: return + _uiState.update { + it.copy(positionSeconds = globalSeconds, isPaused = if (autoplay) false else it.isPaused) + } + viewModelScope.launch { + when ( + val result = playbackSessionManager.reanchorActiveVideoSession( + positionSeconds = decision.targetSourcePositionSeconds, + diagnostics = mapOf( + "surface" to "audiobook", + "reason" to decision.reason.name.lowercase(), + ), + ) + ) { + is ApiResult.Success -> { + val replacement = result.data + if ( + generation != loadGeneration || + isClosing || + _uiState.value.sessionId != sessionId + ) { + if (replacement is VideoSessionStartV3.Ready) { + abandonUnpublishedSession(replacement) + } + return@launch + } + if (replacement is VideoSessionStartV3.Ready) { + applyStartedSession( + ready = replacement, + localSeek = decision.targetSourcePositionSeconds, + globalPosition = globalSeconds, + trackIndex = trackIndex, + fileId = fileId, + isCurrent = { + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + }, + ) + } else { + applyFailedSessionStart( + replacement.failureMessage(), + expectedSessionId = sessionId, + ) + } + } + is ApiResult.Error -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + ) { + _uiState.update { it.copy(error = result.message.ifBlank { "Seek failed" }) } + } + is ApiResult.NetworkError -> if ( + generation == loadGeneration && + !isClosing && + _uiState.value.sessionId == sessionId + ) { + _uiState.update { it.copy(error = result.exception.message ?: "Seek failed") } + } + } + } + } + } + } + fun consumePendingSeek() { _pendingSeek.value = null } fun jumpToChapter(chapter: VersionChapter) { @@ -1121,7 +1471,6 @@ class AudiobookPlayerViewModel( durationSeconds = state.durationSeconds, ) } - reportSessionProgress(state) } } @@ -1167,9 +1516,21 @@ class AudiobookPlayerViewModel( // Invalidate any in-flight cross-part load so it can't resurrect a // session after we clear state here (Apple close() bumps loadGeneration). loadGeneration++ + startGeneration++ pendingTrackLoadLocalStart = null val state = _uiState.value val sessionId = state.sessionId + val sessionLocal = sessionLocalPosition(state) + if (sessionId != null) { + playbackSessionLifecycle.reportPosition( + positionSec = sessionLocal, + durationSec = activePartDurationSeconds(), + isPaused = true, + expectedSessionId = sessionId, + persistencePositionSec = state.positionSeconds, + persistenceDurationSec = state.durationSeconds, + ) + } _uiState.update { it.copy( streamUrl = null, @@ -1178,11 +1539,11 @@ class AudiobookPlayerViewModel( isPaused = true, ) } + activePlaybackTimeline = null + activePlaybackSourceDurationSeconds = null if (sessionId == null) return if (stoppingSessionId == sessionId) return stoppingSessionId = sessionId - // Capture the part-local session position now, before state is cleared. - val sessionLocal = sessionLocalPosition(state) viewModelScope.launch { try { withContext(NonCancellable + Dispatchers.IO) { @@ -1198,12 +1559,7 @@ class AudiobookPlayerViewModel( durationSeconds = state.durationSeconds, ) } - // SINK 1: part-local position to the retiring session. - reportAndStopSession( - sessionId = sessionId, - positionSeconds = sessionLocal, - isPaused = true, - ) + playbackSessionLifecycle.stop(expectedSessionId = sessionId) // Inside NonCancellable so a teardown-cancelled viewModelScope // can't skip the prompt drain (covers downloaded/offline-while- // online where no connectivity change triggers it). @@ -1217,23 +1573,6 @@ class AudiobookPlayerViewModel( } } - /** - * SINK 1 — per-part playback session. Reports the PART-LOCAL position (the - * session streams a single part; admin activity + session keepalive) rather - * than the whole-book position. Distinct from [savePosition]'s SINK 2 which - * carries the whole-book position for durable resume. - */ - private suspend fun reportSessionProgress(state: AudiobookPlayerUiState) { - val sessionId = state.sessionId ?: return - runCatching { - playbackSessionManager.reportProgress( - sessionId = sessionId, - position = sessionLocalPosition(state), - isPaused = state.isPaused, - ) - } - } - /** * The active part's file-local position for [state]'s whole-book * [AudiobookPlayerUiState.positionSeconds] — what the per-part session must @@ -1250,19 +1589,8 @@ class AudiobookPlayerViewModel( } } - private suspend fun reportAndStopSession( - sessionId: String, - positionSeconds: Double, - isPaused: Boolean, - ) { - runCatching { - playbackSessionManager.reportProgress( - sessionId = sessionId, - position = positionSeconds, - isPaused = isPaused, - ) - } - runCatching { playbackSessionManager.stopSession(sessionId) } + private fun activePartDurationSeconds(): Double { + return activePlaybackSourceDurationSeconds ?: 0.0 } override fun onCleared() { @@ -1270,21 +1598,22 @@ class AudiobookPlayerViewModel( // session during teardown (Apple close(): isClosing + loadGeneration). isClosing = true loadGeneration++ + startGeneration++ pendingTrackLoadLocalStart = null sleepTimerJob?.cancel() positionSaveJob?.cancel() val state = _uiState.value - state.sessionId?.let { sessionId -> - runCatching { - runBlocking(Dispatchers.IO) { - // SINK 1: report the retiring session in part-local space. - reportAndStopSession( - sessionId = sessionId, - positionSeconds = sessionLocalPosition(state), - isPaused = true, - ) - } - } + val sessionId = state.sessionId + if (sessionId != null) { + playbackSessionLifecycle.reportPosition( + positionSec = sessionLocalPosition(state), + durationSec = activePartDurationSeconds(), + isPaused = true, + expectedSessionId = sessionId, + persistencePositionSec = state.positionSeconds, + persistenceDurationSec = state.durationSeconds, + ) + playbackSessionLifecycle.stopAsync(expectedSessionId = sessionId) } super.onCleared() } @@ -1292,21 +1621,15 @@ class AudiobookPlayerViewModel( companion object { private const val TAG = "AudiobookPlayerViewModel" - /** Epprairien (seconds) for end-of-part detection and the next-part start + /** Epsilon (seconds) for end-of-part detection and the next-part start * probe, mirroring Apple's 0.01s in advanceAfterTrackEnd. */ - private const val TRACK_END_EPPRAIRIEN = 0.25 + private const val TRACK_END_EPSILON = 0.25 /** Tolerance (seconds) within which the freshly-loaded part's stream is * considered "settled" at its file-local start, after which engine-time * mapping resumes. Wide enough to absorb the ~250ms poll cadence and a * fresh prepare's initial seek. */ private const val TRACK_LOAD_SETTLE_TOLERANCE = 3.0 - - /** Still-image codecs ffprobe reports for embedded audiobook cover - * art. Advertised as "video" so the server resolves these audio-only - * items to DIRECT instead of transcoding the poster. */ - private val AUDIOBOOK_COVER_ART_CODECS = - listOf("mjpeg", "png", "jpeg", "bmp", "gif") } } @@ -1328,3 +1651,29 @@ private fun AudiobookTimeline.toWholeBookChapters(): List = endSeconds = chapter.endSeconds ?: chapter.startSeconds, ) } + +private fun org.prairieserver.prairie.model.playback.PlaybackTimelineV3.toPlaybackTimeline() = + PlaybackTimeline( + sourceStartSeconds = sourceStartSeconds, + playerStartSeconds = playerStartSeconds, + streamOriginSeconds = streamOriginSeconds, + timelineOffsetSeconds = timelineOffsetSeconds, + seekWindowStartSeconds = seekWindowStartSeconds, + seekWindowEndSeconds = seekWindowEndSeconds, + canSeekAnywhere = canSeekAnywhere, + seekRestoration = seekRestoration, + ) + +private fun androidx.media3.common.PlaybackException.audiobookFailureClassification(): String = + when (errorCode) { + androidx.media3.common.PlaybackException.ERROR_CODE_DECODING_FAILED, + androidx.media3.common.PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + androidx.media3.common.PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + -> "decoder_failure" + androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + -> "transport_failure" + androidx.media3.common.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "http_failure" + androidx.media3.common.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND -> "source_unavailable" + else -> "player_error" + } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptor.kt index 44649ed78..66af9dacd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptor.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthInterceptor.kt @@ -38,7 +38,7 @@ class MediaAuthInterceptor( val original = chain.request() val failedSnapshot = runBlocking { authSession.snapshot() } if (!isSameHttpOrigin(failedSnapshot.serverUrl, original.url.toString())) { - return chain.proceed(original.withoutSiloCredentials()) + return chain.proceed(original.withoutPrairieCredentials()) } val authed = original.newBuilder() @@ -65,7 +65,7 @@ class MediaAuthInterceptor( .applyAuthHeaders(retrySnapshot) .build() } else { - original.withoutSiloCredentials() + original.withoutPrairieCredentials() } return chain.proceed(retried) } @@ -76,7 +76,7 @@ class MediaAuthInterceptor( } } -private fun Request.withoutSiloCredentials(): Request = +private fun Request.withoutPrairieCredentials(): Request = newBuilder() .removeHeader("Authorization") .removeHeader("X-Profile-Id") diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthSession.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthSession.kt index 90a08bd71..8a84582d8 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthSession.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/MediaAuthSession.kt @@ -69,8 +69,9 @@ class MediaAuthSession( return MediaAuthSnapshot(null, null, null, tokenManager.getCurrentServerId(), "") } val accessToken = tokenManager.getAccessToken() - val profileId = tokenManager.getProfileId() - val profileToken = tokenManager.getProfileToken() + val profileIdentity = tokenManager.getProfileIdentity() + val profileId = profileIdentity.profileId + val profileToken = profileIdentity.profileToken val serverIdAfter = tokenManager.getCurrentServerId() val serverUrlAfter = tokenManager.getServerUrl() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/Playability.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/Playability.kt index a4c79e554..c1bb0ffc2 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/Playability.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/Playability.kt @@ -20,3 +20,11 @@ sealed class Playability { val classification: String = "transport_stall", ) : Playability() } + +fun Playability.failureDiagnostics(): Map = when (this) { + is Playability.StartupStalled -> mapOf( + "buffered_ahead_ms" to bufferedAheadMs.toString(), + "stalled_for_ms" to stalledForMs.toString(), + ) + else -> emptyMap() +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt index 422742811..74edf0355 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt @@ -1,17 +1,10 @@ package org.prairieserver.prairie.common.player -enum class PlaybackBufferMode(val wireValue: String, val label: String) { - QuickStart("quick_start", "Quick start"), - Balanced("balanced", "Balanced"), - SmoothPlayback("smooth_playback", "Smooth playback"); - - companion object { - fun fromWire(value: String?): PlaybackBufferMode = entries.firstOrNull { - it.wireValue == value - } ?: SmoothPlayback - } -} - +/** + * Buffering policy derived from what the player can observe. There is no user + * setting and no server-supplied mode: the numbers follow from the device and + * the stream. + */ data class PlaybackBufferPolicy( val minBufferMs: Int, val maxBufferMs: Int, @@ -20,50 +13,159 @@ data class PlaybackBufferPolicy( val targetBufferBytes: Int, val prioritizeTimeOverSizeThresholds: Boolean, ) { + init { + require(maxBufferMs - minBufferMs == MAX_LOAD_IDLE_MS) { + "idle window must be exactly MAX_LOAD_IDLE_MS; maxBufferMs is derived, never written by hand" + } + } + companion object { - fun forMode( - mode: PlaybackBufferMode, + /** + * How long the load control may stop reading the socket, in MEDIA + * time. + * + * DefaultLoadControl fills to maxBufferMs, then requests nothing until + * the buffer drains below minBufferMs — so the gap between them is + * literally how long the connection sits idle, in the player's media + * clock. Upstream proxies close an idle response body based on WALL + * CLOCK time: nginx's send_timeout defaults to 60s. The old + * hand-written 50s/120s pair left a 70s gap and dropped the connection + * every time the buffer filled on a long direct-play file. + * + * Those two clocks only agree at 1.0x. DefaultLoadControl scales + * minBufferUs for speeds ABOVE 1.0, but not below it, and the UI + * offers rates down to [SLOWEST_PLAYBACK_SPEED] (0.5x, see + * SPEED_PRESETS in TvAudiobookSpeedPanel.kt and the clamp in + * AudiobookSpeedSheet.kt) for audiobooks, which share this load + * control. At 0.5x, one media-time second of idle window takes two + * wall-clock seconds to elapse — so a naive 30s media-time window + * becomes 60s of wall clock, exactly nginx's default send_timeout, + * with zero margin. + * + * 15_000 is that same 30s wall-clock budget scaled down by the + * slowest rate (30_000 * SLOWEST_PLAYBACK_SPEED = 15_000): at 0.5x it + * stretches back out to 30s of wall clock, half of + * ASSUMED_PROXY_SEND_TIMEOUT_MS, so the window holds at every speed + * the UI offers, not just 1.0x. + * + * maxBufferMs is therefore never written by hand; it is always + * minBufferMs + this. Depth can grow without ever widening the window. + */ + const val MAX_LOAD_IDLE_MS = 15_000 + + /** + * The slowest rate the UI lets a viewer select (see SPEED_PRESETS in + * TvAudiobookSpeedPanel.kt and the 0.5f..3.0f clamp in + * AudiobookSpeedSheet.kt). Named so the derivation of + * MAX_LOAD_IDLE_MS above isn't a bare magic number. + */ + const val SLOWEST_PLAYBACK_SPEED = 0.5 + + /** The timeout MAX_LOAD_IDLE_MS is chosen to stay clear of. */ + const val ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000 + + /** Never buffer less than this, however constrained the device. */ + const val MIN_DEPTH_MS = 20_000 + + /** + * Never buffer more than this even when memory allows. Past a few + * minutes we are mostly prefetching content the viewer may seek away + * from — wasted bandwidth, and wasted allowance on mobile data. + */ + const val MAX_DEPTH_MS = 180_000 + + private const val START_MS = 2_000 + + /** + * After a stall the viewer is watching a spinner, so the cushion we + * rebuild before resuming is deliberately small. + */ + private const val REBUFFER_MS = 5_000 + + fun forConditions( deviceProfile: PlaybackBufferDeviceProfile = PlaybackBufferDeviceProfile.Unknown, - ): PlaybackBufferPolicy = when (mode) { - PlaybackBufferMode.QuickStart -> PlaybackBufferPolicy( - minBufferMs = 30_000, - maxBufferMs = 60_000, - bufferForPlaybackMs = 2_000, - bufferForPlaybackAfterRebufferMs = 6_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 32, medium = 64, roomy = 128), - prioritizeTimeOverSizeThresholds = false, - ) - PlaybackBufferMode.Balanced -> PlaybackBufferPolicy( - minBufferMs = 50_000, - maxBufferMs = 120_000, - bufferForPlaybackMs = 3_000, - bufferForPlaybackAfterRebufferMs = 10_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 48, medium = 96, roomy = 160), - prioritizeTimeOverSizeThresholds = false, - ) - PlaybackBufferMode.SmoothPlayback -> PlaybackBufferPolicy( - minBufferMs = 90_000, - maxBufferMs = 180_000, - bufferForPlaybackMs = 5_000, - bufferForPlaybackAfterRebufferMs = 15_000, - targetBufferBytes = targetBufferBytes(deviceProfile, low = 64, medium = 128, roomy = 192), + ): PlaybackBufferPolicy { + val depthMs = MAX_DEPTH_MS + return PlaybackBufferPolicy( + minBufferMs = depthMs, + maxBufferMs = depthMs + MAX_LOAD_IDLE_MS, + bufferForPlaybackMs = START_MS, + bufferForPlaybackAfterRebufferMs = REBUFFER_MS, + targetBufferBytes = memoryBudgetBytes(deviceProfile), prioritizeTimeOverSizeThresholds = false, ) } - private fun targetBufferBytes( - deviceProfile: PlaybackBufferDeviceProfile, - low: Int, - medium: Int, - roomy: Int, - ): Int = when { - deviceProfile.isLowRamDevice || deviceProfile.memoryClassMb in 1 until 192 -> low * MIB - deviceProfile.memoryClassMb <= 0 -> low * MIB - deviceProfile.memoryClassMb in 192 until 384 -> medium * MIB - else -> roomy * MIB + /** + * The byte ceiling this device can afford. PrairieLoadControl sizes the + * real target from the stream's bitrate and clamps it to this. + * + * This is a fraction of the app's own heap rather than a pick from + * fixed tiers. A fixed tier either starves a small-heap device (a + * flat 48 MiB floor is half of a 96 MB heap — a real OOM risk) or + * leaves a large-heap device's headroom unused (a flat 160 MiB + * ceiling caps a 1 GB heap the same as a 384 MB one). Scaling with + * memoryClassMb keeps the budget proportionate at both ends without + * hand-picking where the tier boundaries should sit. + * + * The fraction is half the heap, not a quarter. Measured hardware: + * the NVIDIA Shield reports memoryClass=192MB and the Google TV + * Streamer reports memoryClass=384MB, and neither is flagged + * low-RAM. A quarter-heap rule gives the Shield 48 MiB and the + * Streamer 96 MiB — LESS buffer than each device shipped with before + * this policy existed (96 MiB and 160 MiB respectively), the exact + * opposite of scaling correctly from small-memory devices to large + * ones. Half the heap gives the Shield 96 MiB and the Streamer + * 192 MiB (at the cap), both at or above their prior fixed values. + */ + internal fun memoryBudgetBytes(deviceProfile: PlaybackBufferDeviceProfile): Int { + val proportionalBytes = + if (deviceProfile.memoryClassMb > 0) { + deviceProfile.memoryClassMb.toLong() * MIB / MEMORY_BUDGET_HEAP_DIVISOR + } else { + null + } + + if (deviceProfile.isLowRamDevice || deviceProfile.memoryClassMb <= 0) { + // A flat 24 MiB is the conservative fallback for a heap size + // we don't trust at all (unknown, or explicitly flagged + // low-RAM). But when memoryClassMb IS known, even on a + // low-RAM device, ignoring it can hand out MORE than the + // proportional share would — a low-RAM stick reporting 48MB + // would get 24 MiB verbatim, half its heap, exactly the flaw + // the proportional rule exists to remove. Take the smaller + // of the two so the flat fallback only ever tightens the + // budget, never loosens it. + return proportionalBytes + ?.coerceAtMost(LOW_RAM_MEMORY_BUDGET_BYTES.toLong()) + ?.toInt() + ?: LOW_RAM_MEMORY_BUDGET_BYTES + } + return checkNotNull(proportionalBytes) + .coerceIn(MIN_MEMORY_BUDGET_BYTES.toLong(), MAX_MEMORY_BUDGET_BYTES.toLong()) + .toInt() } private const val MIB = 1024 * 1024 + + /** The budget is this fraction (1/2) of the app heap — see [memoryBudgetBytes]. */ + private const val MEMORY_BUDGET_HEAP_DIVISOR = 2L + + /** Never budget less than this, however small the heap. */ + private const val MIN_MEMORY_BUDGET_BYTES = 16 * MIB + + /** Never budget more than this even on a very large heap. */ + private const val MAX_MEMORY_BUDGET_BYTES = 192 * MIB + + /** + * Fixed fallback for devices that report no usable heap size, or that + * flag themselves as low-RAM outright — conservative rather than + * proportional, since half of an unknown heap is not a number worth + * trusting. When memoryClassMb is known, this is only a ceiling on + * the proportional share (see [memoryBudgetBytes]), not a value + * handed out regardless of what the device actually reports. + */ + private const val LOW_RAM_MEMORY_BUDGET_BYTES = 24 * MIB } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt index 13b89821d..b368874e4 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.common.player +import android.app.UiModeManager import android.content.Context +import android.content.res.Configuration import android.media.MediaCodecList import android.media.MediaFormat import android.os.Build @@ -8,30 +10,30 @@ import androidx.media3.common.C import androidx.media3.common.MimeTypes import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.player.DolbyVisionPolicy import org.prairieserver.prairie.common.player.video.media3OriginalPlaybackContainers import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.ClientCodecCapabilities -import org.prairieserver.prairie.model.playback.EngineCapabilityEnvelope -import org.prairieserver.prairie.model.playback.EngineSubtitleCapabilities -import org.prairieserver.prairie.model.playback.DETAILED_DECODE_CAPABILITIES_FEATURE -import org.prairieserver.prairie.model.playback.LAYOUT_AWARE_PASSTHROUGH_FEATURE -import org.prairieserver.prairie.model.playback.CLIENT_VIDEO_TRANSFORMATIONS_FEATURE -import org.prairieserver.prairie.model.playback.DEVICE_QUIRKS_V3_FEATURE +import org.prairieserver.prairie.model.playback.CAPABILITY_EVIDENCE_EXACT +import org.prairieserver.prairie.model.playback.CAPABILITY_EVIDENCE_PLATFORM_ATTESTED +import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_HLS +import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_ORIGINAL_HTTP +import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_PROGRESSIVE +import org.prairieserver.prairie.model.playback.DeliveryCapability +import org.prairieserver.prairie.model.playback.DeliverySubtitleCapabilities import org.prairieserver.prairie.model.playback.CLIENT_DV8_HDR10_PLUS_SANITIZER import org.prairieserver.prairie.model.playback.CLIENT_POST_RESUME_VIDEO_RECOVERY import org.prairieserver.prairie.model.playback.CLIENT_SURFACE_RECOVERY import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_DV81 import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_HDR10 import org.prairieserver.prairie.model.playback.CLIENT_DV_TRANSFORM_RECIPE_VERSION -import org.prairieserver.prairie.model.playback.MEDIA3_ONLY_FEATURE -import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE -import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_FEATURE import org.prairieserver.prairie.model.playback.PlaybackDeviceContext -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackTransformationExecutor import org.prairieserver.prairie.model.playback.PlaybackTransformationV3 import org.prairieserver.prairie.model.playback.PlaybackOutputContext +import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities +import org.prairieserver.prairie.model.playback.AudioPassthroughEntry import kotlinx.coroutines.flow.StateFlow import org.prairieserver.prairie.libass.LibassBridge @@ -53,13 +55,21 @@ class PlaybackCapabilityDetector( private val context: Context, private val audioCapabilityManager: AudioCapabilityManager, private val libassBridge: LibassBridge, + /** + * Public so the Cast path can report the same build and channel this + * detector puts on a local session — `CastPrepareRequest` describes the + * phone driving the cast, not the receiver. + */ + val buildIdentity: PrairieClientBuildIdentity, ) { val outputRouteGeneration: StateFlow = audioCapabilityManager.outputRouteGeneration + private val planningSnapshots = PlaybackPlanningSnapshotRegistry( + maxSize = MAX_RETAINED_PLANNING_SNAPSHOTS, + ) // Platform software-audio decoders are static for the process; cache the - // MediaCodecList enumeration so back-to-back detect()/detectPlaybackContext() - // calls per playback start don't re-run it. + // MediaCodecList enumeration for callers that need a fresh snapshot later. @Volatile - private var cachedPlatformSoftwareAudioCodecs: List? = null + private var cachedPlatformSoftwareAudioProbe: PlatformSoftwareAudioProbe? = null /** * Inspect the resolved [Tracks] object (emitted by `Player.Listener.onTracksChanged`) * and declare whether direct play can proceed. Looks at the selected video @@ -117,24 +127,65 @@ class PlaybackCapabilityDetector( if (selectedAudio != null) { val mime = selectedAudio.sampleMimeType.orEmpty() val channels = selectedAudio.channelCount - val passthroughCodecs = audioCapabilityManager.capabilities.value.passthroughCodecs.toSet() - val maxChannels = audioCapabilityManager.capabilities.value.maxChannels + // ONE snapshot: read three times, a route change mid-check could + // mix one snapshot's codec list with another's channel limits. + val routeCaps = audioCapabilityManager.capabilities.value + val maxChannels = routeCaps.maxChannels - val rendererCanDecode = isSoftwareDecodableAudioMime( + val rendererCanDecode = canDecodeAudio( mime = mime, + channelCount = channels, + platformDecoders = detectPlatformSoftwareAudioCodecs().decoders, ffmpegAvailable = FfmpegAudioSupport.isAvailable(), ) - val sinkCanPassthrough = when (mime) { - MimeTypes.AUDIO_TRUEHD -> "truehd" in passthroughCodecs - MimeTypes.AUDIO_DTS_HD -> "dts_hd" in passthroughCodecs - MimeTypes.AUDIO_DTS -> "dts" in passthroughCodecs - MimeTypes.AUDIO_AC4 -> "ac4" in passthroughCodecs - else -> false + // A sink that carries the encoded stream bypasses the decoder + // entirely, so its channel limit is irrelevant. AC-3/E-AC-3/JOC were + // missing here: harmless while the decoder was assumed able to take + // anything, but a false refusal the moment that assumption is + // dropped — an E-AC-3-capable receiver plays 5.1 fine behind a + // stereo-only decoder. + // Asked per codec AND per layout: the sink carrying this codec says + // nothing about it carrying this many channels of it. JOC tries its + // own entry first and then plain E-AC-3 — picking by codec presence + // alone refused a JOC stream whose layout only the E-AC-3 entry + // covered, which Android explicitly permits. + val passthroughCandidates = when (mime) { + MimeTypes.AUDIO_TRUEHD -> listOf("truehd") + MimeTypes.AUDIO_DTS_HD -> listOf("dts_hd") + MimeTypes.AUDIO_DTS -> listOf("dts") + MimeTypes.AUDIO_AC4 -> listOf("ac4") + MimeTypes.AUDIO_AC3 -> listOf("ac3") + MimeTypes.AUDIO_E_AC3 -> listOf("eac3") + MimeTypes.AUDIO_E_AC3_JOC -> listOf("eac3_joc", "eac3") + else -> emptyList() + } + val sinkCanPassthrough = passthroughCandidates.any { + sinkCanPassthrough(it, channels, routeCaps) } - if (!rendererCanDecode && !sinkCanPassthrough) { + // Absent codec and unusable channel layout are different verdicts: + // the first tells the viewer their device cannot play this format at + // all, the second that it cannot play this LAYOUT — and the server + // picks a different fallback for each. Deciding on the + // channel-aware answer alone reported every Pixel channel failure + // as an unsupported encoding. + val codecKnownAtAll = platformCanDecodeAudio( + mime = mime, + channelCount = 0, + platformDecoders = detectPlatformSoftwareAudioCodecs().decoders, + ) || (FfmpegAudioSupport.isAvailable() && mime in FfmpegAudioSupport.mimeTypes) + + if (!codecKnownAtAll && !sinkCanPassthrough) { return Playability.UnsupportedAudioCodec(mime) } + if (!rendererCanDecode && !sinkCanPassthrough) { + return Playability.UnsupportedChannelCount(mime, channels) + } + // rendererCanDecode is now channel-aware, so a decoder that exists + // but cannot take this many channels no longer excuses the sink + // from having to carry the track. That was the bug: a 5.1 E-AC3 + // track was declared playable by a two-channel decoder and failed + // at the codec once playback had already begun. if (channels > 0 && channels > maxChannels && !rendererCanDecode) { return Playability.UnsupportedChannelCount(mime, channels) } @@ -155,12 +206,13 @@ class PlaybackCapabilityDetector( ffmpegAvailable: Boolean = FfmpegAudioSupport.isAvailable(), dolbyVision: DolbyVisionPolicy.Snapshot = DolbyVisionPolicy.Snapshot(), ): ClientCodecCapabilities { + val audioRoute = audioCapabilityManager.playbackRouteSnapshot() val codecProbe = MediaCodecCapabilitiesProbe.probe() val displayHdr = DisplayHdrProbe.probe(context) // With Dolby Vision off, stop advertising DV profiles (except 5, // which has no watchable base layer) so the server plans base-layer / // HDR10 delivery and local direct-play checks agree. Single decision - // source: DolbyVisionPolicy (Apple parity, prairie-apple e9bd775). + // source: DolbyVisionPolicy (Apple parity, silo-apple e9bd775). val intersectedHdr = TvPlaybackOutputPolicy.effectiveHdrCapabilities( codec = codecProbe.hdr, display = displayHdr, @@ -173,18 +225,31 @@ class PlaybackCapabilityDetector( ) } + val platformAudio = detectPlatformSoftwareAudioCodecs() val softwareAudio = advertisedAudioDecodeCodecs( - platformCodecs = detectPlatformSoftwareAudioCodecs(), + platformCodecs = platformAudio.codecs, ffmpegAvailable = ffmpegAvailable, isTv = TvModeDetector.isTv(context), ) - val passthrough = audioCapabilityManager.capabilities.value + val passthrough = audioRoute.capabilities val hasAnyHdr = intersectedHdr.hdr10 || intersectedHdr.hdr10Plus || intersectedHdr.hlg || intersectedHdr.dolbyVisionProfiles.isNotEmpty() - return ClientCodecCapabilities( + val detected = ClientCodecCapabilities( + // Stated rather than defaulted: both lists below come from a + // MediaCodecList probe of the concrete profile/level/bit-depth + // tuples this device reports, which is what "exact" claims. If a + // future path ever fabricates part of them, the tier has to drop + // here — the server strictly validates plans against exact + // evidence, and only exact evidence earns audio passthrough. + videoEvidence = CAPABILITY_EVIDENCE_EXACT, + audioEvidence = if (platformAudio.exact) { + CAPABILITY_EVIDENCE_EXACT + } else { + CAPABILITY_EVIDENCE_PLATFORM_ATTESTED + }, codecsVideo = codecProbe.videoCodecs.toList(), codecsVideoHardware = codecProbe.videoCodecs.toList(), // This list is decode-only. Encoded formats accepted by the @@ -198,99 +263,83 @@ class PlaybackCapabilityDetector( audioPassthrough = passthrough, videoDecode = codecProbe.videoDecodeCapabilities, ) + planningSnapshots.remember(detected, audioRoute) + return detected } + /** + * The form factor implied by the current UI mode, for callers that live in + * `android-shared` and so cannot see either app's `BuildConfig`. The app + * modules pass their own literal ("mobile" / "tv") because they know it + * statically; shared players (the audiobook one) call this instead of + * guessing. + */ + fun detectedFormFactor(): String = androidFormFactor(context) + + /** The installed version name, for the same shared callers. */ + fun detectedAppVersion(): String = androidAppVersion(context) + fun detectPlaybackContext( - formFactor: String, - appVersion: String = "unknown", + formFactor: String = detectedFormFactor(), + appVersion: String = detectedAppVersion(), ffmpegAvailable: Boolean = FfmpegAudioSupport.isAvailable(), dolbyVision: DolbyVisionPolicy.Snapshot = DolbyVisionPolicy.Snapshot(), + capabilities: ClientCodecCapabilities? = null, ): ClientPlaybackContext { - val caps = detect(ffmpegAvailable, dolbyVision) - val supportedAbis = Build.SUPPORTED_ABIS?.toList().orEmpty() + val caps = capabilities ?: detect(ffmpegAvailable, dolbyVision) + val audioRoute = planningSnapshots.resolve( + capabilities = caps, + currentRoute = audioCapabilityManager.playbackRouteSnapshot(), + ) val passthrough = caps.audioPassthrough val decodeAudio = caps.codecsAudio - val media3Audio = decodeAudio val libassRendering = libassBridge.isRenderingSupported val libassEmbeddedFonts = libassBridge.isEmbeddedFontsSupported val libassDirectFidelity = libassRendering && libassEmbeddedFonts - val contextFeatures = buildList { - add(PLAYBACK_PLAN_V3_FEATURE) - add(SEEK_REANCHOR_V3_FEATURE) - add(MEDIA3_ONLY_FEATURE) - add(DETAILED_DECODE_CAPABILITIES_FEATURE) - if (!passthrough?.entries.isNullOrEmpty()) add(LAYOUT_AWARE_PASSTHROUGH_FEATURE) - add(CLIENT_VIDEO_TRANSFORMATIONS_FEATURE) - add(DEVICE_QUIRKS_V3_FEATURE) - } - val clientVideoTransformations = buildList { - if (8 in caps.hdrDetails?.dolbyVisionProfiles.orEmpty() && NativeDolbyVisionRpuConverter.isAvailable) { - add( - PlaybackTransformationV3( - name = CLIENT_DV7_TO_DV81, - executor = PlaybackTransformationExecutor.CLIENT, - recipeVersion = CLIENT_DV_TRANSFORM_RECIPE_VERSION, - validatedClaims = listOf( - "profile7_rpu_converted_to_profile81", - "hdr10_base_layer_preserved", - "enhancement_layer_discarded", - ), - ), - ) - } - if (caps.hdrDetails?.hdr10 == true) { - add( - PlaybackTransformationV3( - name = CLIENT_DV7_TO_HDR10, - executor = PlaybackTransformationExecutor.CLIENT, - recipeVersion = CLIENT_DV_TRANSFORM_RECIPE_VERSION, - validatedClaims = listOf( - "dolby_vision_metadata_removed", - "hdr10_base_layer_preserved", - "enhancement_layer_discarded", - ), - ), - ) - } - } + val clientVideoTransformations = advertisedClientDolbyVisionTransformations( + hdrDetails = caps.hdrDetails, + nativeRpuConverterAvailable = NativeDolbyVisionRpuConverter.isAvailable, + ) return ClientPlaybackContext( - features = contextFeatures, formFactor = formFactor, appVersion = appVersion, + // Taken from the injected identity rather than a per-caller + // argument, so the shared audiobook player reports the same build + // and channel as the two video players instead of omitting them. + appBuild = buildIdentity.reportedBuildNumber, + appChannel = buildIdentity.reportedChannel, device = PlaybackDeviceContext( + platform = "android", + osVersion = Build.VERSION.RELEASE, manufacturer = Build.MANUFACTURER, model = Build.MODEL, - brand = Build.BRAND, - device = Build.DEVICE, - product = Build.PRODUCT, - socManufacturer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Build.SOC_MANUFACTURER - } else null, - socModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) Build.SOC_MODEL else null, - buildId = Build.ID, - buildDisplay = Build.DISPLAY, - securityPatch = Build.VERSION.SECURITY_PATCH, - sdkInt = Build.VERSION.SDK_INT, - abis = supportedAbis, + // Everything below is Android-shaped detail the neutral + // contract does not model. It exists for device quirks and + // support diagnostics, so it goes in the free-form bag rather + // than growing platform-specific fields on the wire type. + platformDetails = androidPlatformDetails(), ), output = PlaybackOutputContext( hdrDetails = caps.hdrDetails, audioPassthrough = passthrough, currentSink = if (passthrough?.passthroughCodecs?.isNotEmpty() == true) "passthrough_sink" else "local_output", - sinkType = audioCapabilityManager.currentSinkType(), - outputRouteGeneration = audioCapabilityManager.outputRouteGeneration.value, + sinkType = audioRoute.sinkType, + // Opaque to the server, which only ever compares it for + // equality. Android's route generation counter is exactly that: + // it changes when the audio route changes and nothing else. + outputContextId = audioRoute.routeGeneration.toString(), ), - engines = mapOf( - PlaybackEngineKind.MEDIA3_DIRECT to EngineCapabilityEnvelope( + deliveries = mapOf( + DELIVERY_CLASS_ORIGINAL_HTTP to DeliveryCapability( enabled = true, supportedOnDevice = true, containers = media3OriginalPlaybackContainers, videoCodecs = caps.codecsVideo, - audioDecodeCodecs = media3Audio, + audioDecodeCodecs = decodeAudio, audioPassthroughCodecs = passthrough?.passthroughCodecs.orEmpty(), maxChannels = passthrough?.maxChannels, hdrDetails = caps.hdrDetails, - subtitles = EngineSubtitleCapabilities( + subtitles = DeliverySubtitleCapabilities( embeddedText = true, sidecarText = true, assStyling = libassDirectFidelity, @@ -315,17 +364,17 @@ class PlaybackCapabilityDetector( authHeaderRefresh = true, validatedClaims = emptyList(), ), - PlaybackEngineKind.MEDIA3_PROGRESSIVE_REMUX to EngineCapabilityEnvelope( + DELIVERY_CLASS_PROGRESSIVE to DeliveryCapability( enabled = false, supportedOnDevice = false, failureReason = "disabled_pending_seekable_transport", containers = listOf("mp4", "m4v", "webm", "mkv", "matroska"), videoCodecs = caps.codecsVideo, - audioDecodeCodecs = media3Audio, + audioDecodeCodecs = decodeAudio, audioPassthroughCodecs = passthrough?.passthroughCodecs.orEmpty(), maxChannels = passthrough?.maxChannels, hdrDetails = caps.hdrDetails, - subtitles = EngineSubtitleCapabilities( + subtitles = DeliverySubtitleCapabilities( embeddedText = true, sidecarText = true, ), @@ -340,16 +389,16 @@ class PlaybackCapabilityDetector( authHeaderRefresh = true, validatedClaims = emptyList(), ), - PlaybackEngineKind.MEDIA3_HLS to EngineCapabilityEnvelope( + DELIVERY_CLASS_HLS to DeliveryCapability( enabled = true, supportedOnDevice = true, containers = listOf("m3u8", "hls"), videoCodecs = caps.codecsVideo, - audioDecodeCodecs = media3Audio, + audioDecodeCodecs = decodeAudio, audioPassthroughCodecs = passthrough?.passthroughCodecs.orEmpty(), maxChannels = passthrough?.maxChannels, hdrDetails = caps.hdrDetails, - subtitles = EngineSubtitleCapabilities( + subtitles = DeliverySubtitleCapabilities( embeddedText = true, sidecarText = true, assStyling = libassRendering, @@ -374,31 +423,151 @@ class PlaybackCapabilityDetector( ) } - /** Returns codecs backed by an Android platform [MediaCodec] decoder. */ - private fun detectPlatformSoftwareAudioCodecs(): List { - cachedPlatformSoftwareAudioCodecs?.let { return it } - val result = mutableSetOf() - val list = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS) }.getOrNull() - ?: return listOf("aac", "mp3") - for (info in list.codecInfos) { - if (info.isEncoder) continue - for (type in info.supportedTypes) { - when { - type.equals(MediaFormat.MIMETYPE_AUDIO_AAC, ignoreCase = true) -> result += "aac" - type.equals(MediaFormat.MIMETYPE_AUDIO_AC3, ignoreCase = true) -> result += "ac3" - type.equals(MediaFormat.MIMETYPE_AUDIO_EAC3, ignoreCase = true) -> result += "eac3" - type.equals(MediaFormat.MIMETYPE_AUDIO_EAC3_JOC, ignoreCase = true) -> result += "eac3_joc" - type.equals(MediaFormat.MIMETYPE_AUDIO_FLAC, ignoreCase = true) -> result += "flac" - type.equals(MediaFormat.MIMETYPE_AUDIO_OPUS, ignoreCase = true) -> result += "opus" - type.equals(MediaFormat.MIMETYPE_AUDIO_VORBIS, ignoreCase = true) -> result += "vorbis" - type.equals(MediaFormat.MIMETYPE_AUDIO_MPEG, ignoreCase = true) -> result += "mp3" + /** + * The Android-specific half of the device description, as a flat string map. + * + * The server bounds this at 16 entries with keys and values under 128 + * characters, so keep it to the fields device quirks actually match on. + */ + private fun androidPlatformDetails(): Map = buildMap { + fun putBounded(key: String, value: String) { + put(key, value.take(MAX_PLATFORM_DETAIL_CHARS)) + } + + Build.BRAND?.let { putBounded("brand", it) } + Build.DEVICE?.let { putBounded("device", it) } + Build.PRODUCT?.let { putBounded("product", it) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MANUFACTURER?.let { putBounded("soc_manufacturer", it) } + Build.SOC_MODEL?.let { putBounded("soc_model", it) } + } + Build.ID?.let { putBounded("build_id", it) } + Build.DISPLAY?.let { putBounded("build_display", it) } + Build.VERSION.SECURITY_PATCH?.let { putBounded("security_patch", it) } + putBounded("sdk_int", Build.VERSION.SDK_INT.toString()) + Build.SUPPORTED_ABIS?.toList()?.takeIf { it.isNotEmpty() } + ?.let { putBounded("abis", it.joinToString(",")) } + } + + /** + * Derives the form factor from the current UI mode. Mirrors the diagnostics + * collector's classification so a device reports the same shape to the + * playback contract and to support bundles. + */ + private fun androidFormFactor(context: Context): String { + val uiMode = (context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager)?.currentModeType + return when { + uiMode == Configuration.UI_MODE_TYPE_TELEVISION -> "tv" + uiMode == Configuration.UI_MODE_TYPE_WATCH -> "watch" + uiMode == Configuration.UI_MODE_TYPE_CAR -> "automotive" + context.resources.configuration.smallestScreenWidthDp >= 600 -> "tablet" + else -> "mobile" + } + } + + private fun androidAppVersion(context: Context): String = + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "unknown" + + /** + * Returns codecs backed by an Android platform [MediaCodec] decoder, + * together with how many channels each decoder will actually accept. + * + * The channel limit is the point. A device can advertise an E-AC3 decoder + * that only takes two channels, and asking it to decode a 5.1 track fails + * at the codec with ERROR_CODE_DECODING_FAILED — after playback has already + * started. MIME presence alone cannot answer "can this device play this + * track", so it is not collected alone. + */ + private fun detectPlatformSoftwareAudioCodecs(): PlatformSoftwareAudioProbe { + cachedPlatformSoftwareAudioProbe?.let { return it } + val probe = runCatching { + val decoders = mutableListOf() + for (info in MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + val codec = platformAudioCodecName(type) ?: continue + // An unreadable limit is recorded as unknown, distinct from + // a stated one. The preflight then treats unknown as + // permissive (see canDecodeAudio) — a filter that refused + // every device which will not state a limit would reject a + // great deal that plays. + val maxChannels = runCatching { + info.getCapabilitiesForType(type).audioCapabilities?.maxInputChannelCount + }.getOrNull()?.takeIf { it > 0 } + decoders += PlatformAudioDecodeCapability( + mimeType = type, + codec = codec, + decoderName = info.name.orEmpty(), + maxInputChannelCount = maxChannels, + ) } } + PlatformSoftwareAudioProbe(decoders = decoders, exact = true) + }.getOrElse { + // A failed probe is a guess, and a guess must never be described as + // exact evidence — the server grants passthrough on that word. + PlatformSoftwareAudioProbe( + decoders = listOf( + PlatformAudioDecodeCapability(MimeTypes.AUDIO_AAC, "aac", "", null), + PlatformAudioDecodeCapability(MimeTypes.AUDIO_MPEG, "mp3", "", null), + ), + exact = false, + ) } - return result.toList().also { cachedPlatformSoftwareAudioCodecs = it } + cachedPlatformSoftwareAudioProbe = probe + return probe + } + + private data class PlatformSoftwareAudioProbe( + val decoders: List, + val exact: Boolean, + ) { + val codecs: List get() = decoders.map { it.codec }.distinct() + } + + private companion object { + const val MAX_PLATFORM_DETAIL_CHARS = 128 + const val MAX_RETAINED_PLANNING_SNAPSHOTS = 32 } } +/** + * Retains the route evidence captured with a capability object so planning + * context cannot combine that object with a route change that happened later. + * Capability equality is intentionally insufficient: two routes may expose + * identical codecs while still requiring distinct output context identities. + */ +internal class PlaybackPlanningSnapshotRegistry( + private val maxSize: Int, +) { + private val snapshots = ArrayDeque>() + + init { + require(maxSize > 0) + } + + @Synchronized + fun remember( + capabilities: ClientCodecCapabilities, + route: AudioPlaybackRouteSnapshot, + ) { + snapshots.addLast(capabilities to route) + while (snapshots.size > maxSize) { + snapshots.removeFirst() + } + } + + @Synchronized + fun resolve( + capabilities: ClientCodecCapabilities, + currentRoute: AudioPlaybackRouteSnapshot, + ): AudioPlaybackRouteSnapshot = + snapshots.lastOrNull { (planned, _) -> planned === capabilities }?.second ?: currentRoute +} + /** * Audio decoders safe to advertise to the server's route planner. * @@ -423,6 +592,63 @@ internal fun advertisedAudioDecodeCodecs( return (platformCodecs + ffmpegCodecs).distinct() } +/** + * Client-side Dolby Vision transformations safe to expose to the v3 planner. + * + * A packaged converter and a compatible output range are prerequisites, not + * end-to-end evidence. In particular, the SM-F976U1 can decode HDR10 and run + * the packaged RPU bridge, yet a transformed Profile 7 stream renders one + * frame and then makes no forward progress. Advertising the transformation in + * that state makes every fresh session select the same unusable route before + * runtime recovery can ask the server for its validated transformation. + * + * Keep the default evidence set empty. A transformation may be added only + * after the playback fixture matrix validates the complete extractor, + * transformation, decoder, and display path for the Android device class. + */ +internal fun advertisedClientDolbyVisionTransformations( + hdrDetails: org.prairieserver.prairie.model.playback.HdrCapabilities?, + nativeRpuConverterAvailable: Boolean, + fixtureValidatedTransformations: Set = emptySet(), +): List = buildList { + if ( + CLIENT_DV7_TO_DV81 in fixtureValidatedTransformations && + 8 in hdrDetails?.dolbyVisionProfiles.orEmpty() && + nativeRpuConverterAvailable + ) { + add( + PlaybackTransformationV3( + name = CLIENT_DV7_TO_DV81, + executor = PlaybackTransformationExecutor.CLIENT, + recipeVersion = CLIENT_DV_TRANSFORM_RECIPE_VERSION, + validatedClaims = listOf( + "profile7_rpu_converted_to_profile81", + "hdr10_base_layer_preserved", + "enhancement_layer_discarded", + ), + ), + ) + } + if ( + CLIENT_DV7_TO_HDR10 in fixtureValidatedTransformations && + hdrDetails?.hdr10 == true + ) { + add( + PlaybackTransformationV3( + name = CLIENT_DV7_TO_HDR10, + executor = PlaybackTransformationExecutor.CLIENT, + recipeVersion = CLIENT_DV_TRANSFORM_RECIPE_VERSION, + validatedClaims = listOf( + "dolby_vision_metadata_removed", + "hdr10_base_layer_preserved", + "enhancement_layer_discarded", + ), + ), + ) + } +} + +@UnstableApi private fun Tracks.Group.selectedFormat() = (0 until length) .firstOrNull { isTrackSelected(it) } @@ -434,20 +660,152 @@ internal fun isDirectPlayableDolbyVisionProfile( supportedHdr: org.prairieserver.prairie.model.playback.HdrCapabilities, ): Boolean = supportedHdr.dolbyVisionProfiles.contains(profile) -internal fun isSoftwareDecodableAudioMime( +/** + * Whether the connected sink will carry [codec] as an encoded stream at + * [channelCount] channels. + * + * Uses the exact per-codec entries when the route probe produced them, because + * the aggregate `maxChannels` is a maximum across ALL codecs: a receiver taking + * eight-channel TrueHD but only six-channel E-AC-3 reports eight, which would + * wave through an eight-channel E-AC-3 track its own E-AC-3 entry excludes. + * + * Falls back to the aggregate where no entries exist — pre-API-29 routes cannot + * be probed per format, and refusing everything there would be worse than the + * imprecision. + */ +internal fun sinkCanPassthrough( + codec: String, + channelCount: Int, + capabilities: AudioPassthroughCapabilities, +): Boolean { + if (codec !in capabilities.passthroughCodecs) return false + if (channelCount <= 0) return true + val exactCounts = capabilities.entries + .firstOrNull { it.codec == codec } + ?.channelCounts + ?.takeIf { it.isNotEmpty() } + ?: return channelCount <= capabilities.maxChannels + + if (channelCount in exactCounts) return true + // An entry lists what was PROBED, not everything the sink accepts: only + // 2/6/8 are ever tried. Absence is a refusal for those three — they were + // asked and said no. Any other layout was never put to the sink, so it is + // judged against THIS codec's highest known-good count. + // + // Deliberately not the aggregate maxChannels: that is a maximum across all + // codecs, so a receiver doing 8-channel TrueHD would have vouched for + // 7-channel E-AC-3 on a sink whose own E-AC-3 probe stopped at 6. Erring + // toward refusal here costs a transcode; erring the other way costs broken + // audio after playback has started. + if (channelCount in PROBED_PASSTHROUGH_CHANNEL_COUNTS) return false + return channelCount <= exactCounts.max() +} + +/** + * Channel counts [AudioCapabilityManager] actually probes per encoded format. + * Only for these does an entry's silence mean "no"; anything else was never + * asked. + * + * DERIVED from the probe list rather than restated, so reader and writer cannot + * drift. The previous version asserted the match with a check() in the probe + * itself — which would have thrown inside capability detection on any API 29+ + * device if the two ever disagreed. A structural invariant is not worth + * crashing playback setup over. + */ +@UnstableApi +internal val PROBED_PASSTHROUGH_CHANNEL_COUNTS: Set = + PASSTHROUGH_LAYOUT_PROBES.mapTo(mutableSetOf()) { it.channelCount } + +/** One platform decoder's claim about one MIME type. */ +internal data class PlatformAudioDecodeCapability( + val mimeType: String, + val codec: String, + val decoderName: String, + /** Null when the device would not say — recorded as unknown, not as a limit. */ + val maxInputChannelCount: Int?, +) + +/** + * Whether this device can decode [mime] at [channelCount] channels. + * + * Replaces a hardcoded MIME list that always claimed E-AC3 and E-AC3 JOC were + * decodable regardless of the device or the track. A Pixel whose E-AC3 decoder + * accepts two channels reported a 5.1 track as playable, and the failure only + * surfaced as ERROR_CODE_DECODING_FAILED after playback started — then the + * recovery replan made the same claim and chose the same route again. + * + * Any matching decoder is enough: several can expose the same MIME with + * different limits, and the widest one is the one that would be used. A limit + * is never borrowed from a different MIME, and JOC stays a separate claim from + * plain E-AC3 unless the device actually advertises it. + * + * An unknown [channelCount] (non-positive) asks only whether the codec exists — + * there is nothing to compare against, and refusing on that basis would reject + * tracks that play fine. + */ +internal fun canDecodeAudio( mime: String, + channelCount: Int, + platformDecoders: List, ffmpegAvailable: Boolean, -): Boolean = - mime in platformSoftwareDecodableAudioMimes || - (ffmpegAvailable && mime in FfmpegAudioSupport.mimeTypes) - -private val platformSoftwareDecodableAudioMimes = setOf( - MimeTypes.AUDIO_AAC, - MimeTypes.AUDIO_AC3, - MimeTypes.AUDIO_E_AC3, - MimeTypes.AUDIO_E_AC3_JOC, - MimeTypes.AUDIO_FLAC, - MimeTypes.AUDIO_OPUS, - MimeTypes.AUDIO_VORBIS, - MimeTypes.AUDIO_MPEG, -) +): Boolean { + if (platformCanDecodeAudio(mime, channelCount, platformDecoders)) return true + // FFmpeg genuinely rescues a format the platform decoder cannot take. + // EXTENSION_RENDERER_MODE_ON puts the platform renderer FIRST, but order is + // only the tie-break: MappingTrackSelector picks the renderer reporting the + // greatest format support, and MediaCodecAudioRenderer answers + // FORMAT_EXCEEDS_CAPABILITIES for a channel count its decoder will not take + // while FfmpegAudioRenderer answers FORMAT_HANDLED. So the extension is not + // limited to codecs the platform lacks entirely. + return ffmpegAvailable && mime in FfmpegAudioSupport.mimeTypes +} + +/** + * Whether a platform decoder alone can take [mime] at [channelCount]. + * + * Separate from [canDecodeAudio] so a caller can tell "this device has no + * decoder for this codec at all" from "it has one that will not take this many + * channels" — those are different answers for the viewer and different + * fallbacks for the server. + * + * Media3 soft-matches E-AC3 JOC onto a plain E-AC3 decoder + * (`MediaCodecUtil.getAlternativeCodecMimeType`), so a JOC track is accepted by + * an E-AC3 decoder here too; refusing it would reject content Media3 plays. + */ +internal fun platformCanDecodeAudio( + mime: String, + channelCount: Int, + platformDecoders: List, +): Boolean { + val acceptable = buildSet { + add(mime.lowercase()) + if (mime.equals(MimeTypes.AUDIO_E_AC3_JOC, ignoreCase = true)) { + add(MimeTypes.AUDIO_E_AC3.lowercase()) + } + } + return platformDecoders.any { decoder -> + decoder.mimeType.lowercase() in acceptable && + when { + channelCount <= 0 -> true + // The device would not state a limit. Not a claim of an + // unlimited one, but refusing every such decoder would reject a + // great deal that plays; the preflight is a filter, and the + // decoder-init failure path still exists behind it. + decoder.maxInputChannelCount == null -> true + else -> decoder.maxInputChannelCount >= channelCount + } + } +} + +/** The wire name this project uses for a platform audio MIME, if it tracks one. */ +internal fun platformAudioCodecName(mimeType: String): String? = when { + mimeType.equals(MimeTypes.AUDIO_AAC, ignoreCase = true) -> "aac" + mimeType.equals(MimeTypes.AUDIO_AC3, ignoreCase = true) -> "ac3" + mimeType.equals(MimeTypes.AUDIO_E_AC3, ignoreCase = true) -> "eac3" + mimeType.equals(MimeTypes.AUDIO_E_AC3_JOC, ignoreCase = true) -> "eac3_joc" + mimeType.equals(MimeTypes.AUDIO_FLAC, ignoreCase = true) -> "flac" + mimeType.equals(MimeTypes.AUDIO_OPUS, ignoreCase = true) -> "opus" + mimeType.equals(MimeTypes.AUDIO_VORBIS, ignoreCase = true) -> "vorbis" + mimeType.equals(MimeTypes.AUDIO_MPEG, ignoreCase = true) -> "mp3" + else -> null +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt index 176e50002..41b7fdcc8 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt @@ -7,12 +7,10 @@ import org.prairieserver.prairie.common.diagnostics.DiagnosticsPlaybackSessionRe import org.prairieserver.prairie.model.personal.SyncProgressItem import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext -import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.repository.PersonalDataRepository -import org.prairieserver.prairie.repository.ProfileRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -45,15 +43,20 @@ import kotlinx.coroutines.withContext * collapse to a single observer of [state] and [notice]. * * Lifecycle: - * start(params) -> Loading -> Active(session) | Failed(message) + * adoptActiveSession(params, session) -> Active(session) * reportPosition(...) -> debounced 10s flush via `sessionManager` - * - 404 session_not_found -> sync snapshot, re-invoke start with override + * - 404 session_not_found -> sync snapshot, emit [missingSessionEvents] * - NetworkError -> Reconnecting + health-probe loop * stop() -> Idle (also flushes one final progress snapshot) + * + * This class does not start sessions. Under protocol v3 a session is planned + * by [PlaybackSessionManager.startVideoSessionV3] — which owns the attempt key, + * the staged-replan machinery, and the publication handshake — and handed here + * already started, so a second start entry point could only produce a session + * the manager does not know it owns. */ class PlaybackSessionLifecycle( private val sessionManager: PlaybackSessionManager, - private val profileRepository: ProfileRepository, private val healthApi: HealthApi, private val personalDataRepository: PersonalDataRepository, private val scope: CoroutineScope, @@ -66,8 +69,8 @@ class PlaybackSessionLifecycle( private val _notice = MutableStateFlow(null) val notice: StateFlow = _notice.asStateFlow() - private val _missingSessionEvents = MutableSharedFlow(extraBufferCapacity = 1) - val missingSessionEvents: SharedFlow = _missingSessionEvents.asSharedFlow() + private val _missingSessionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val missingSessionEvents: SharedFlow = _missingSessionEvents.asSharedFlow() /** * Mutex protects the small set of mutable transitions we make from @@ -81,14 +84,17 @@ class PlaybackSessionLifecycle( @Volatile private var lastStartParams: StartParams? = null @Volatile private var lastReportedPosition: Double? = null @Volatile private var lastReportedDuration: Double = 0.0 + /** Durable content-time coordinate; differs from session time for multipart audio. */ + @Volatile private var lastPersistencePosition: Double? = null + @Volatile private var lastPersistenceDuration: Double = 0.0 @Volatile private var recoveringFromMissingSession: String? = null @Volatile private var flushProgressOnStop: Boolean = true @Volatile private var stopActiveSessionOnStop: Boolean = true - @Volatile private var renewMissingSessionWithLegacyStart: Boolean = true @Volatile private var diagnosticsRecording: DiagnosticsPlaybackSessionRecording = DiagnosticsPlaybackSessionRecording.None private var reporterJob: Job? = null + private val recoveryJobLock = Any() private var recoveryJob: Job? = null private var outageJob: Job? = null private val pendingStopLock = Any() @@ -96,18 +102,30 @@ class PlaybackSessionLifecycle( /** Session [pendingStopJob] is stopping; guarded by `pendingStopLock`. */ private var pendingStopSessionId: String? = null + private val externalFinalizationLock = Any() + private val pendingExternalFinalizations = mutableMapOf() private data class ActiveSessionSnapshot( val state: SessionState, + /** + * The ownership token as it stood, captured rather than derived. + * + * [SessionState] carries a session id only while Active, but + * Reconnecting and Failed deliberately keep owning theirs — so + * reconstructing the token from the restored state alone erases + * ownership exactly for the states that exist to survive an outage. + */ + val lastAdoptedSessionId: String?, val notice: PlayerNotice?, val lastStartParams: StartParams?, val lastReportedPosition: Double?, val lastReportedDuration: Double, + val lastPersistencePosition: Double?, + val lastPersistenceDuration: Double, val lastIsPaused: Boolean, val recoveringFromMissingSession: String?, val flushProgressOnStop: Boolean, val stopActiveSessionOnStop: Boolean, - val renewMissingSessionWithLegacyStart: Boolean, val diagnosticsRecording: DiagnosticsPlaybackSessionRecording, val reporterWasActive: Boolean, ) @@ -123,11 +141,11 @@ class PlaybackSessionLifecycle( * The session this lifecycle owns, independent of what it is presenting. * * [SessionState] carries a session id only while Active, so any guard that - * reads state alone is blind exactly when it matters. During Reconnecting, - * Loading or Failed a stale deferred stop finds no id, falls through, and - * cancels the reconnect for a session it has no business touching — the - * banner vanishes with nothing replacing it and progress reporting for that - * episode is dead for the rest of playback. + * reads state alone is blind exactly when it matters. During Reconnecting or + * Failed a stale deferred stop finds no id, falls through, and cancels the + * reconnect for a session it has no business touching — the banner vanishes + * with nothing replacing it and progress reporting for that episode is dead + * for the rest of playback. */ @Volatile private var lastAdoptedSessionId: String? = null @@ -135,11 +153,12 @@ class PlaybackSessionLifecycle( /** * Bumped by every [stop] that actually tears down. * - * `start()` runs its API call outside the mutex, and during that window - * `_state` is Loading and [lastAdoptedSessionId] is null — so the ownership - * guard in [stop] finds no id to compare and tears down regardless. Compare - * this instead: an unchanged value at publication time proves no stop ran - * while the start was in flight. + * The owner plans a session before handing it here, and that planning runs + * outside this mutex — so between [acquireOwnershipEpoch] and adoption + * there is a window where [lastAdoptedSessionId] is still null and the + * ownership guard in [stop] has no id to compare. Compare this instead: an + * epoch unchanged at adoption time proves no stop ran while the plan was in + * flight. */ @Volatile private var stopEpoch: Long = 0L @@ -147,36 +166,15 @@ class PlaybackSessionLifecycle( // ---- Public API --------------------------------------------------------- /** - * Starts a new playback session. Resolves to [SessionState.Active] on - * success or [SessionState.Failed] on profile-id absence or session API - * failure (Error or NetworkError). - */ - suspend fun start(params: StartParams): SessionState { - awaitPendingStop() - DiagnosticsPlaybackLogger.sessionEvent("session start requested") - // New start cancels any in-flight recovery / outage probing, by design: - // this is the explicit "user/code wants a fresh session now" path. - cancelRecoveryJobs() - mutex.withLock { - pendingActiveSessionPublication = null - } - val recording = playbackSessions.recording() - diagnosticsRecording = recording - return startInternal(params, recording) - } - - /** - * Hands the lifecycle a session that the caller already started. By - * default, the lifecycle also owns progress reporting, recovery, final - * progress flush, and stop. Callers that have not migrated those paths yet - * can adopt passively without creating a second playback session. + * Hands the lifecycle a session the caller already started. The lifecycle + * then owns progress reporting, recovery, the final progress flush, and + * stop. */ suspend fun adoptActiveSession( params: StartParams, session: PlaybackSessionResponse, manageProgress: Boolean = true, stopSessionOnStop: Boolean = true, - renewMissingSessionWithLegacyStart: Boolean = true, deferPublication: Boolean = false, ) { awaitPendingStop() @@ -185,7 +183,6 @@ class PlaybackSessionLifecycle( session = session, manageProgress = manageProgress, stopSessionOnStop = stopSessionOnStop, - renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, deferPublication = deferPublication, isCurrent = { true }, ) @@ -210,7 +207,6 @@ class PlaybackSessionLifecycle( session: PlaybackSessionResponse, manageProgress: Boolean = true, stopSessionOnStop: Boolean = true, - renewMissingSessionWithLegacyStart: Boolean = true, deferPublication: Boolean = false, isCurrent: () -> Boolean, ): Boolean { @@ -223,23 +219,35 @@ class PlaybackSessionLifecycle( } else { null } + // A protocol-v3 replan keeps the same server session id. Keep its + // reporter alive as well: cancelling an in-flight Ktor POST can + // leave the server with a truncated JSON body, and the network + // wrapper turns that local cancellation into a NetworkError. That + // briefly pushed a healthy subtitle replan through outage recovery. + val reuseProgressReporter = + manageProgress && + lastAdoptedSessionId == session.sessionId && + reporterJob?.isActive == true cancelRecoveryJobs() - reporterJob?.cancel() - reporterJob = null + if (!reuseProgressReporter) { + reporterJob?.cancel() + reporterJob = null + } _notice.value = null lastStartParams = params lastReportedPosition = params.startPosition ?: session.position lastReportedDuration = session.durationSeconds ?: 0.0 + lastPersistencePosition = params.startPosition ?: session.position + lastPersistenceDuration = session.durationSeconds ?: 0.0 lastIsPaused = session.isPaused recoveringFromMissingSession = null flushProgressOnStop = manageProgress stopActiveSessionOnStop = stopSessionOnStop - this.renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart this.diagnosticsRecording = diagnosticsRecording diagnosticsRecording.record(session.sessionId) lastAdoptedSessionId = session.sessionId _state.value = SessionState.Active(session) - if (manageProgress) { + if (manageProgress && !reuseProgressReporter) { startProgressReporter() } pendingActiveSessionPublication = predecessor?.let { @@ -262,7 +270,6 @@ class PlaybackSessionLifecycle( session: PlaybackSessionResponse, manageProgress: Boolean = true, stopSessionOnStop: Boolean = true, - renewMissingSessionWithLegacyStart: Boolean = true, deferPublication: Boolean = false, expectedOwnershipEpoch: Long, ): Boolean = try { @@ -272,7 +279,6 @@ class PlaybackSessionLifecycle( session = session, manageProgress = manageProgress, stopSessionOnStop = stopSessionOnStop, - renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, deferPublication = deferPublication, isCurrent = { stopEpoch == expectedOwnershipEpoch }, ) @@ -364,15 +370,17 @@ class PlaybackSessionLifecycle( private fun captureActiveSessionSnapshot(): ActiveSessionSnapshot = ActiveSessionSnapshot( state = _state.value, + lastAdoptedSessionId = lastAdoptedSessionId, notice = _notice.value, lastStartParams = lastStartParams, lastReportedPosition = lastReportedPosition, lastReportedDuration = lastReportedDuration, + lastPersistencePosition = lastPersistencePosition, + lastPersistenceDuration = lastPersistenceDuration, lastIsPaused = lastIsPaused, recoveringFromMissingSession = recoveringFromMissingSession, flushProgressOnStop = flushProgressOnStop, stopActiveSessionOnStop = stopActiveSessionOnStop, - renewMissingSessionWithLegacyStart = renewMissingSessionWithLegacyStart, diagnosticsRecording = diagnosticsRecording, reporterWasActive = reporterJob?.isActive == true, ) @@ -384,15 +392,23 @@ class PlaybackSessionLifecycle( lastStartParams = snapshot.lastStartParams lastReportedPosition = snapshot.lastReportedPosition lastReportedDuration = snapshot.lastReportedDuration + lastPersistencePosition = snapshot.lastPersistencePosition + lastPersistenceDuration = snapshot.lastPersistenceDuration lastIsPaused = snapshot.lastIsPaused recoveringFromMissingSession = snapshot.recoveringFromMissingSession flushProgressOnStop = snapshot.flushProgressOnStop stopActiveSessionOnStop = snapshot.stopActiveSessionOnStop - renewMissingSessionWithLegacyStart = snapshot.renewMissingSessionWithLegacyStart diagnosticsRecording = snapshot.diagnosticsRecording _notice.value = snapshot.notice - // A rollback to the predecessor hands ownership back to that session. - (snapshot.state as? SessionState.Active)?.let { lastAdoptedSessionId = it.session.sessionId } + // Restore the token the snapshot captured, rather than deriving it from + // the restored state. Deriving gets both ends wrong: reading it only + // from Active leaves a rolled-back first deferred adoption naming the + // discarded replacement, while clearing everything that is not Active + // erases ownership for Reconnecting and Failed — which hold a session + // precisely so an outage does not lose it. A predecessor restored as + // Reconnecting would then have no id for stop() to name, and its + // transcode would run until the server expired it. + lastAdoptedSessionId = snapshot.lastAdoptedSessionId _state.value = snapshot.state if ( restartReporter && @@ -403,135 +419,50 @@ class PlaybackSessionLifecycle( } } - private suspend fun startInternal( - params: StartParams, - diagnosticsRecording: DiagnosticsPlaybackSessionRecording, - alreadyLocked: Boolean = false, - ): SessionState { - // `handleSessionMissing` calls this from inside the lifecycle mutex, and - // Mutex is not reentrant, so locking is the caller's choice. - suspend fun guarded(block: suspend () -> T): T = - if (alreadyLocked) block() else mutex.withLock { block() } - - // Read under the lock together with the state we are about to publish: - // `stop()` bumps this, so an unchanged value at publication time proves - // no teardown ran while the start API call was in flight. - val epochAtStart = guarded { - _notice.value = null - // Starting fresh: the previous session is no longer ours. start() has - // already awaited any pending stop, so nothing is left to guard. - lastAdoptedSessionId = null - _state.value = SessionState.Loading - lastStartParams = params - flushProgressOnStop = true - stopActiveSessionOnStop = true - renewMissingSessionWithLegacyStart = true - stopEpoch - } - suspend fun publishFailureUnlessStopped(message: String): SessionState = - guarded { - if (stopEpoch != epochAtStart) { - SessionState.Idle.also { _state.value = it } - } else { - SessionState.Failed(message).also { _state.value = it } - } - } - - val profileId = profileRepository.getActiveProfileId() - if (profileId == null) { - return publishFailureUnlessStopped("No active profile selected.") - } - - val result = if ( - params.clientPlaybackContext != null || - params.subtitleTrackIndex != null || - params.preserveDirectAudioSelection || - params.playMethod != null - ) { - sessionManager.startSessionV2( - fileId = params.fileId, - profileId = profileId, - capabilities = params.capabilities, - audioTrackIndex = params.audioTrackIndex, - subtitleTrackIndex = params.subtitleTrackIndex, - qualityPreference = params.qualityPreference, - startPosition = params.startPosition, - clientPlaybackContext = params.clientPlaybackContext, - preserveDirectAudioSelection = params.preserveDirectAudioSelection, - playMethod = params.playMethod, - ) - } else { - sessionManager.startSession( - fileId = params.fileId, - profileId = profileId, - capabilities = params.capabilities, - audioTrackIndex = params.audioTrackIndex, - qualityPreference = params.qualityPreference, - startPosition = params.startPosition, - ) - } - return when (result) { - is ApiResult.Success -> guarded { - // A stop that landed while this start was in flight means the - // user left. Publishing anyway would resurrect a screen they - // dismissed, and — because that stop has already run its - // teardown and never saw this id — would strand the session on - // the server, where it keeps counting against the account's - // concurrent-stream cap until it times out. - if (stopEpoch != epochAtStart) { - DiagnosticsPlaybackLogger.sessionEvent("session abandoned, stopped during start") - Log.w(TAG, "stop landed during start; stopping session ${result.data.sessionId}") - when (val stopResult = sessionManager.stopSession(result.data.sessionId)) { - is ApiResult.Error -> - Log.w(TAG, "abandon stopSession error: ${stopResult.code} ${stopResult.message}") - is ApiResult.NetworkError -> - Log.w(TAG, "abandon stopSession network error: ${stopResult.exception}") - else -> {} - } - _state.value = SessionState.Idle - return@guarded SessionState.Idle - } - DiagnosticsPlaybackLogger.sessionEvent("session active") - diagnosticsRecording.record(result.data.sessionId) - val active = SessionState.Active(result.data) - lastAdoptedSessionId = result.data.sessionId - _state.value = active - // Re-assert: a concurrent stop clears these, and without them - // 404-session recovery and the final progress flush both no-op, - // which silently loses the user's resume position on exit. - lastStartParams = params - lastReportedPosition = params.startPosition ?: result.data.position - // Clear the missing-session debounce — fresh session id. - recoveringFromMissingSession = null - startProgressReporter() - active - } - is ApiResult.Error -> { - DiagnosticsPlaybackLogger.sessionEvent("session start failed") - Log.w(TAG, "start session error: ${result.code} ${result.error} ${result.message}") - publishFailureUnlessStopped( - result.message.ifBlank { "Failed to start playback." }, - ) - } - is ApiResult.NetworkError -> { - DiagnosticsPlaybackLogger.sessionEvent("session start network failure") - Log.w(TAG, "start session network error: ${result.exception}") - publishFailureUnlessStopped("Network error starting playback.") - } - } - } - /** * Push a position update from the player. Non-suspend — the actual server * report happens on the internal 10s debounce loop (see [PROGRESS_REPORT_INTERVAL_MS]). */ - fun reportPosition(positionSec: Double, durationSec: Double, isPaused: Boolean) { + fun reportPosition( + positionSec: Double, + durationSec: Double, + isPaused: Boolean, + /** + * The session the caller believes produced this sample; null when the + * caller owns none, as downloaded and local playback do not. + * + * These fields are process-global and the reporter loop pairs them with + * whichever session is current when it next fires — so a final callback + * from an outgoing player, arriving after the next screen has adopted, + * would otherwise flush the previous episode's position under the new + * episode's id. That is the "resume jumped to the last episode's time" + * shape. + * + * Note that null is NOT "skip the check": both exit paths clear the UI + * session id while player callbacks are still draining, so treating null + * as permission is exactly the hole this closes. A caller with no + * session may only write these fields while the lifecycle owns none + * either. + */ + expectedSessionId: String?, + /** Content-level coordinate used by durable resume persistence. */ + persistencePositionSec: Double = positionSec, + /** Content-level duration paired with [persistencePositionSec]. */ + persistenceDurationSec: Double = durationSec, + ) { + if (expectedSessionId != lastAdoptedSessionId) return if (positionSec.isFinite() && positionSec >= 0) { lastReportedPosition = positionSec } if (durationSec.isFinite() && durationSec > 0) { lastReportedDuration = durationSec } + if (persistencePositionSec.isFinite() && persistencePositionSec >= 0) { + lastPersistencePosition = persistencePositionSec + } + if (persistenceDurationSec.isFinite() && persistenceDurationSec > 0) { + lastPersistenceDuration = persistenceDurationSec + } lastIsPaused = isPaused } @@ -614,7 +545,14 @@ class PlaybackSessionLifecycle( ) } + // The adopted id, not the published state's. Reconnecting and + // Failed carry no session id, so reading it from Active alone meant + // leaving during an outage — or after the outage timeout gave up — + // never told the server to stop. The transcode then ran on until it + // timed out, holding a stream slot the viewer had already walked + // away from. val sessionId = (_state.value as? SessionState.Active)?.session?.sessionId + ?: lastAdoptedSessionId // Fire the final snapshot regardless — even during Reconnecting we // want to durably record where the user was so a fresh login resumes // there. @@ -633,10 +571,11 @@ class PlaybackSessionLifecycle( lastStartParams = null lastReportedPosition = null lastReportedDuration = 0.0 + lastPersistencePosition = null + lastPersistenceDuration = 0.0 recoveringFromMissingSession = null flushProgressOnStop = true stopActiveSessionOnStop = true - renewMissingSessionWithLegacyStart = true pendingActiveSessionPublication = null _notice.value = null lastAdoptedSessionId = null @@ -645,6 +584,24 @@ class PlaybackSessionLifecycle( DiagnosticsPlaybackLogger.sessionEvent("session stopped") } + /** + * Retires a terminal playback attempt, then rechecks screen ownership. + * + * [stop] cancels the reporter without joining it, so a report may still be + * in flight here. It clears `lastAdoptedSessionId` under [mutex] first, and + * [ownsProgressReply] then discards any late reply. Phone and TV must share + * this terminal-first ordering so a stale progress tick cannot renew the + * retired server session. + */ + suspend fun stopTerminalSessionIfCurrent( + expectedSessionId: String, + isCurrent: () -> Boolean, + ): Boolean { + stop(expectedSessionId = expectedSessionId) + currentCoroutineContext().ensureActive() + return isCurrent() + } + /** * Fire-and-forget [stop] for teardown paths that must not block. [stop] * performs up to two HTTP round-trips (final progress sync + stopSession), @@ -666,7 +623,19 @@ class PlaybackSessionLifecycle( context = NonCancellable + Dispatchers.IO, start = CoroutineStart.LAZY, ) { - stop(expectedSessionId) + // This scope has a SupervisorJob but no + // CoroutineExceptionHandler, and nothing joins this job for + // its result, so an unexpected throw from stop() would + // escape as an uncaught coroutine exception and take the + // process down. Teardown failing is not worth a crash: the + // server session expires on its own timeout. + try { + stop(expectedSessionId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (t: Throwable) { + Log.w(TAG, "async stop failed for $expectedSessionId", t) + } }.also { pendingStopJob = it pendingStopSessionId = expectedSessionId @@ -683,6 +652,47 @@ class PlaybackSessionLifecycle( job.start() } + /** + * Reports and stops a session that remains externally owned. + * + * This does not adopt the session or mutate lifecycle-owned playback state. + * The application scope outlives the external owner, while [NonCancellable] + * and IO dispatch keep its final network writes off teardown callers. + */ + fun reportAndStopExternalSessionAsync( + sessionId: String, + positionSeconds: Double, + isPaused: Boolean, + ) { + val job = synchronized(externalFinalizationLock) { + pendingExternalFinalizations[sessionId] + ?.takeUnless { it.isCompleted } + ?: scope.launch( + context = NonCancellable + Dispatchers.IO, + start = CoroutineStart.LAZY, + ) { + runCatching { + sessionManager.reportProgress( + sessionId = sessionId, + position = positionSeconds, + isPaused = isPaused, + ) + } + runCatching { sessionManager.stopSession(sessionId) } + }.also { + pendingExternalFinalizations[sessionId] = it + } + } + job.invokeOnCompletion { + synchronized(externalFinalizationLock) { + if (pendingExternalFinalizations[sessionId] === job) { + pendingExternalFinalizations.remove(sessionId) + } + } + } + job.start() + } + private suspend fun awaitPendingStop() { val job = synchronized(pendingStopLock) { pendingStopJob } ?: return job.join() @@ -702,6 +712,22 @@ class PlaybackSessionLifecycle( position = pos, isPaused = lastIsPaused, ) + // The API wrapper represents CancellationException as a + // NetworkError. Never interpret cancellation of this reporter + // itself as evidence that the server is offline. + if (!currentCoroutineContext().isActive) continue + // Re-check ownership AFTER the call. Cancelling this job is not + // enough to stop what follows: the network wrapper catches + // cancellation and hands back a NetworkError, so a reporter + // belonging to the previous episode carries on and acts on a + // reply about a session nobody is watching any more. + // + // Everything below reacts to that reply by rewriting shared + // state — publishing Reconnecting, restoring Active, or + // starting a replacement session from the CURRENT start + // params. Left unguarded, a late answer about episode A does + // all of that to episode B. + if (!ownsProgressReply(sess.sessionId)) continue when { isPlaybackSessionMissing(result) -> handleSessionMissing(sess.sessionId) result is ApiResult.NetworkError -> { @@ -720,110 +746,143 @@ class PlaybackSessionLifecycle( } } + /** + * Whether a progress reply still concerns the session on screen. + * + * Compared against the adopted id rather than the published state, because + * the states this guards against — Reconnecting and Failed — carry no + * session id of their own, and a reply arriving during one of them is + * exactly the case that must not act. + */ + private fun ownsProgressReply(sessionId: String): Boolean = + lastAdoptedSessionId == sessionId + // ---- Internal: 404 session-missing recovery ----------------------------- + /** + * The session vanished server-side (404). Renewal is the owner's job: only + * the ViewModel that planned this session can replan it through + * [PlaybackSessionManager.startVideoSessionV3] and re-adopt the result, so + * the lifecycle persists the resume position and hands it over. + * + * The snapshot is written before the event because the owner's replan can + * fail — and if it does, this write is all that stands between the user and + * losing their place. + */ private fun handleSessionMissing(staleSessionId: String) { // Debounce: a flurry of 404s should only trigger one renewal. - if (recoveringFromMissingSession == staleSessionId) return val params = lastStartParams ?: return - - recoveringFromMissingSession = staleSessionId - DiagnosticsPlaybackLogger.sessionEvent("session missing") - if (!renewMissingSessionWithLegacyStart) { - _missingSessionEvents.tryEmit(lastReportedPosition ?: params.startPosition ?: 0.0) - return - } - recoveryJob?.cancel() - recoveryJob = scope.launch { - mutex.withLock { - Log.w(TAG, "Playback session missing; renewing") - val resumePos = lastReportedPosition ?: params.startPosition + val resumePosition = lastReportedPosition ?: params.startPosition ?: 0.0 + val persistencePosition = lastPersistencePosition ?: resumePosition + val persistenceDuration = lastPersistenceDuration.takeIf { it > 0.0 } + ?: lastReportedDuration + val job = synchronized(recoveryJobLock) { + if (recoveringFromMissingSession == staleSessionId) return + recoveringFromMissingSession = staleSessionId + recoveryJob?.cancel() + scope.launch(start = CoroutineStart.LAZY) { + if (!ownsProgressReply(staleSessionId)) return@launch syncProgressSnapshot( contentId = params.contentId, - position = resumePos, - duration = lastReportedDuration, + position = persistencePosition, + duration = persistenceDuration, ) - // Re-invoke the start flow with the latest position without - // cancelling this recovery coroutine out from under itself. - startInternal( - params.copy(startPosition = resumePos), - diagnosticsRecording, - alreadyLocked = true, + if (!ownsProgressReply(staleSessionId)) return@launch + _missingSessionEvents.emit( + MissingSessionRenewal( + staleSessionId = staleSessionId, + positionSeconds = resumePosition, + startParams = params, + ), ) - recoveryJob = null + }.also { recoveryJob = it } + } + DiagnosticsPlaybackLogger.sessionEvent("session missing") + job.invokeOnCompletion { + synchronized(recoveryJobLock) { + if (recoveryJob === job) recoveryJob = null } } + job.start() } // ---- Internal: server-outage recovery ----------------------------------- private fun beginOutageRecovery(currentSession: PlaybackSessionResponse) { - if (outageJob?.isActive == true) return // already probing - if (_state.value is SessionState.Reconnecting) return - - val deadline = nowMs() + OUTAGE_TIMEOUT_MS - _state.value = SessionState.Reconnecting(deadlineEpochMs = deadline, tone = NoticeTone.Warning) - DiagnosticsPlaybackLogger.sessionEvent("session reconnecting") - _notice.value = PlayerNotice( - message = OUTAGE_RECONNECT_MESSAGE, - tone = NoticeTone.Warning, - expiresAtEpochMs = deadline, - ) + val job = synchronized(recoveryJobLock) { + if (outageJob?.isActive == true) return + if (_state.value is SessionState.Reconnecting) return - val diagnosticsRecording = this.diagnosticsRecording - // Ownership token for this recovery run. The probe cannot be aborted - // mid-flight, so the loop can resume after cancellation and after a new - // session has been adopted; every publication below is gated on this - // still being the session we set out to recover. - val recoveredSessionId = currentSession.sessionId - outageJob = scope.launch { - // Track elapsed via accumulating delay sums. We can't rely on - // System.currentTimeMillis() here because tests run with a virtual - // clock — `delay()` advances virtual time but the wall clock does - // not. Counting our own delays is correct in both regimes. - var elapsed = 0L - var delayMs = OUTAGE_INITIAL_DELAY_MS - while (isActive && elapsed < OUTAGE_TIMEOUT_MS) { - val step = delayMs.coerceAtMost(OUTAGE_TIMEOUT_MS - elapsed) - delay(step) - elapsed += step - if (elapsed >= OUTAGE_TIMEOUT_MS) break - // Leave via return, not break: falling out of the loop reaches - // the terminal Failed publication below, which a cancelled - // recovery must never perform. - if (!isActive) return@launch - val probe = healthApi.checkHealth() - // A probe that completed after we were cancelled must not - // publish anything. - currentCoroutineContext().ensureActive() - if (probe is ApiResult.Success) { - // Only a decoded health payload is authoritative. Reverse - // proxies/tunnels can still produce HTTP errors, or even - // an HTML 200 page, while the Prairie origin is down. - if (!ownsRecoveredSession(recoveredSessionId)) return@launch - Log.i(TAG, "Health probe succeeded; resuming playback session") - DiagnosticsPlaybackLogger.sessionEvent("session reconnected") - diagnosticsRecording.record(currentSession.sessionId) - lastAdoptedSessionId = currentSession.sessionId - _state.value = SessionState.Active(currentSession) - _notice.value = null - return@launch - } - // Error or NetworkError — back off and try again. - delayMs = (delayMs * 2).coerceAtMost(OUTAGE_MAX_DELAY_MS) - } - // Timed out before the server came back. - currentCoroutineContext().ensureActive() - if (!ownsRecoveredSession(recoveredSessionId)) return@launch - Log.w(TAG, "Outage recovery exhausted for playback session") - DiagnosticsPlaybackLogger.sessionEvent("session reconnect failed") - _state.value = SessionState.Failed(OUTAGE_TIMEOUT_MESSAGE) + val deadline = nowMs() + OUTAGE_TIMEOUT_MS + _state.value = SessionState.Reconnecting(deadlineEpochMs = deadline, tone = NoticeTone.Warning) + DiagnosticsPlaybackLogger.sessionEvent("session reconnecting") _notice.value = PlayerNotice( - message = OUTAGE_TIMEOUT_MESSAGE, + message = OUTAGE_RECONNECT_MESSAGE, tone = NoticeTone.Warning, - expiresAtEpochMs = null, + expiresAtEpochMs = deadline, ) + + val diagnosticsRecording = this.diagnosticsRecording + // Ownership token for this recovery run. The probe cannot be aborted + // mid-flight, so the loop can resume after cancellation and after a new + // session has been adopted; every publication below is gated on this + // still being the session we set out to recover. + val recoveredSessionId = currentSession.sessionId + scope.launch(start = CoroutineStart.LAZY) { + // Track elapsed via accumulating delay sums. We can't rely on + // System.currentTimeMillis() here because tests run with a virtual + // clock — `delay()` advances virtual time but the wall clock does + // not. Counting our own delays is correct in both regimes. + var elapsed = 0L + var delayMs = OUTAGE_INITIAL_DELAY_MS + while (isActive && elapsed < OUTAGE_TIMEOUT_MS) { + val step = delayMs.coerceAtMost(OUTAGE_TIMEOUT_MS - elapsed) + delay(step) + elapsed += step + if (elapsed >= OUTAGE_TIMEOUT_MS) break + // Leave via return, not break: falling out of the loop reaches + // the terminal Failed publication below, which a cancelled + // recovery must never perform. + if (!isActive) return@launch + val probe = healthApi.checkHealth() + // A probe that completed after we were cancelled must not + // publish anything. + currentCoroutineContext().ensureActive() + if (probe is ApiResult.Success) { + // Only a decoded health payload is authoritative. Reverse + // proxies/tunnels can still produce HTTP errors, or even + // an HTML 200 page, while the Silo origin is down. + if (!ownsRecoveredSession(recoveredSessionId)) return@launch + Log.i(TAG, "Health probe succeeded; resuming playback session") + DiagnosticsPlaybackLogger.sessionEvent("session reconnected") + diagnosticsRecording.record(currentSession.sessionId) + lastAdoptedSessionId = currentSession.sessionId + _state.value = SessionState.Active(currentSession) + _notice.value = null + return@launch + } + // Error or NetworkError — back off and try again. + delayMs = (delayMs * 2).coerceAtMost(OUTAGE_MAX_DELAY_MS) + } + // Timed out before the server came back. + currentCoroutineContext().ensureActive() + if (!ownsRecoveredSession(recoveredSessionId)) return@launch + Log.w(TAG, "Outage recovery exhausted for playback session") + DiagnosticsPlaybackLogger.sessionEvent("session reconnect failed") + _state.value = SessionState.Failed(OUTAGE_TIMEOUT_MESSAGE) + _notice.value = PlayerNotice( + message = OUTAGE_TIMEOUT_MESSAGE, + tone = NoticeTone.Warning, + expiresAtEpochMs = null, + ) + }.also { outageJob = it } } + job.invokeOnCompletion { + synchronized(recoveryJobLock) { + if (outageJob === job) outageJob = null + } + } + job.start() } // ---- Internal: snapshot & helpers --------------------------------------- @@ -856,16 +915,19 @@ class PlaybackSessionLifecycle( val params = lastStartParams ?: return syncProgressSnapshot( contentId = params.contentId, - position = lastReportedPosition, - duration = lastReportedDuration, + position = lastPersistencePosition ?: lastReportedPosition, + duration = lastPersistenceDuration.takeIf { it > 0.0 } + ?: lastReportedDuration, ) } private fun cancelRecoveryJobs() { - recoveryJob?.cancel() - recoveryJob = null - outageJob?.cancel() - outageJob = null + synchronized(recoveryJobLock) { + recoveryJob?.cancel() + recoveryJob = null + outageJob?.cancel() + outageJob = null + } } private fun isPlaybackSessionMissing(result: ApiResult<*>): Boolean { @@ -888,7 +950,7 @@ class PlaybackSessionLifecycle( const val OUTAGE_TIMEOUT_MS: Long = 90_000L const val OUTAGE_RECONNECT_MESSAGE: String = - "Reconnecting — The server is updating. Playback will resume when it is ready." + "Reconnecting. Playback will resume automatically." const val OUTAGE_TIMEOUT_MESSAGE: String = "The server did not come back online in time." } @@ -899,10 +961,14 @@ internal fun Int.isGatewayOrTunnelFailureStatus(): Boolean = // ---- Public types ---------------------------------------------------------- -/** State of the playback session lifecycle. */ +/** + * State of the playback session lifecycle. + * + * There is no Loading state: this lifecycle is handed sessions that are already + * planned and started, so it is never the thing waiting on the server. + */ sealed interface SessionState { data object Idle : SessionState - data object Loading : SessionState data class Active(val session: PlaybackSessionResponse) : SessionState data class Reconnecting( val deadlineEpochMs: Long, @@ -925,9 +991,23 @@ data class PlayerNotice( ) /** - * Parameters for [PlaybackSessionLifecycle.start]. Captured on every call so - * 404-session-missing recovery can re-invoke `start()` with the same shape - * plus an updated `startPosition`. + * Durable inputs for renewing a server-side session that disappeared. + * + * Media3 may publish an empty track snapshot while it is failing, so renewal + * must not reconstruct the viewer's audio/subtitle choices from live player + * tracks. [PlaybackSessionLifecycle] captures these parameters at adoption and + * returns the exact snapshot with the last reported source position. + */ +data class MissingSessionRenewal( + val staleSessionId: String, + val positionSeconds: Double, + val startParams: StartParams, +) + +/** + * The shape of the session [PlaybackSessionLifecycle] is presenting. Captured + * on adoption so a 404-session-missing event can hand its owner back the exact + * content, version, route and track intent to renew. */ data class StartParams( val contentId: String, @@ -937,7 +1017,5 @@ data class StartParams( val subtitleTrackIndex: Int? = null, val qualityPreference: String? = null, val startPosition: Double? = null, - val clientPlaybackContext: ClientPlaybackContext? = null, - val preserveDirectAudioSelection: Boolean = false, - val playMethod: PlayMethod? = null, + val clientPlaybackContext: ClientPlaybackContext, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt index a9924d8d6..cd922fcfc 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt @@ -5,35 +5,32 @@ import android.os.SystemClock import android.util.Log import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext -import org.prairieserver.prairie.model.playback.PlayMethod -import org.prairieserver.prairie.model.playback.PlaybackDelivery -import org.prairieserver.prairie.model.playback.PlaybackEngineKind -import org.prairieserver.prairie.model.playback.PlaybackRouteFamily -import org.prairieserver.prairie.model.playback.PlaybackStreamRequest -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse -import org.prairieserver.prairie.model.playback.PlaybackTimeline -import org.prairieserver.prairie.model.playback.TranscodeStartRequest -import org.prairieserver.prairie.model.playback.TranscodeStartResponse import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 import org.prairieserver.prairie.model.playback.PlaybackV3Validation import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference -import org.prairieserver.prairie.model.playback.planAttemptKey import org.prairieserver.prairie.model.playback.validateForMedia3 import org.prairieserver.prairie.model.playback.PlaybackFailureV3 import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 +import org.prairieserver.prairie.model.playback.ProgressPersistenceV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 +import org.prairieserver.prairie.model.playback.FAILURE_RECOVERY_V3_OPERATION +import org.prairieserver.prairie.model.playback.INTENT_V3_OPERATIONS +import org.prairieserver.prairie.model.playback.QUALITY_CHANGE_V3_OPERATION import org.prairieserver.prairie.model.playback.SEEK_FAILURE_RECOVERY_V3_OPERATION import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_FEATURE import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_OPERATION +import org.prairieserver.prairie.model.playback.TRACK_CHANGE_V3_OPERATION +import org.prairieserver.prairie.model.playback.playbackClientFeaturesV3 import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.PlaybackRepository import java.util.IdentityHashMap import java.util.UUID +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -50,6 +47,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withContext import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionRegistry +import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionScope data class StagedVideoReplan( val basePlaybackAttemptId: String, @@ -57,7 +55,12 @@ data class StagedVideoReplan( val basePlanAttemptId: String, val candidate: VideoSessionStartV3.Ready, val candidateSessionId: String, - val outputRouteGeneration: Long, + /** + * The opaque output context the candidate was planned against. Route events + * emitted while the stage is in flight carry it so server-side diagnostics + * can tell a stale route apart from a current one. + */ + val outputContextId: String?, ) /** @@ -85,6 +88,16 @@ open class PlaybackSessionManager( * [NEVER_SELF_HEAL]; the test that asserts self-healing passes a real value. */ private val pendingPublicationSettleTimeoutMs: Long? = PENDING_PUBLICATION_SETTLE_TIMEOUT_MS, + /** + * Where this manager scopes passthrough suppression. The registry is + * process-global by necessity — the audio sink that reads it is constructed + * deep inside Media3 with no route back to a session — so a manager whose + * audio never reaches a local sink must pass + * [PassthroughSuppressionScope.None] rather than reset the suppression set + * belonging to whatever is actually playing. Cast preparation is the case + * that matters: its plans are for a receiver across the room. + */ + private val passthroughSuppression: PassthroughSuppressionScope = PassthroughSuppressionRegistry, ) { private data class ActiveVideoAttempt( val fileId: Int, @@ -93,6 +106,13 @@ open class PlaybackSessionManager( val context: ClientPlaybackContext, val playbackAttemptId: String, val qualityPreference: String, + /** + * The bandwidth cap this attempt started under. Carried on the attempt + * so every replan re-sends it: the cap is a delivery ceiling the server + * applies per request, so omitting it on recovery would silently lift + * the limit for the rest of the session. + */ + val bandwidthCapKbps: Int?, val networkEvidence: PlaybackNetworkSnapshot, val sessionId: String, val plan: PlaybackPlanV3, @@ -118,16 +138,11 @@ open class PlaybackSessionManager( val serverPlanCursor: ServerPlanCursor? = null, ) - /** - * The plan the server currently holds, falling back to the rendered plan. - * - * Every replan/recovery request must address the server by THIS, not by - * `plan`: after a rollback the two differ, and sending the rendered plan - * retires a planId the server has already superseded — after which every - * later request is rejected 409 for the rest of the session. - */ - private val ActiveVideoAttempt.serverPlanId: String - get() = serverPlanCursor?.planId ?: plan.planId + /** Atomic identity tuple used by control requests after a local rollback. */ + private val ActiveVideoAttempt.serverControlIdentity: Triple + get() = serverPlanCursor?.let { cursor -> + Triple(cursor.planId, cursor.planAttemptId, cursor.planAttemptKey) + } ?: Triple(plan.planId, planAttemptId, planAttemptKey) /** Identity of the plan the server last acknowledged for a session. */ private data class ServerPlanCursor( @@ -172,7 +187,44 @@ open class PlaybackSessionManager( private val activeVideoAttempt = AtomicReference() private val stagedVideoReplans = IdentityHashMap() - private val orphanedSessionIds = mutableSetOf() + // Insertion-ordered so the bound in [rememberOrphanedSessionLocked] evicts + // the oldest unconfirmed session rather than an arbitrary one. + private val orphanedSessionIds = LinkedHashSet() + + /** + * Sessions registered as orphans under [videoAttemptMutex] whose stop still + * has to be issued, each tagged with the release claim of the lock holder + * that queued it. Guarded by that same mutex; drained immediately after it + * is released. See [stopRetainingFailureLocked]. + */ + private val pendingOwnershipReleases = mutableListOf>() + + /** + * Sessions whose stop is in flight via the queued-release or orphan-drain + * paths, counted rather than flagged. + * + * Two callers can legitimately be releasing the same id — a queued release + * and an orphan drain that selected it before the queue existed. The count + * keeps the marker honest for that overlap, so the first to finish cannot + * clear protection while the second is still running. It does NOT prevent + * the duplicate request itself: the second stop still goes out, which is + * tolerable only because stopping an already-stopped session is harmless. + * + * Not a register of every stop in the manager: committed-session cleanup and + * the direct retaining-stop helpers issue their own unmarked stops, so this + * excludes duplicates between the two paths that consult it, not globally. + * Guarded by [videoAttemptMutex]. + */ + private val releasesInFlight = mutableMapOf() + + private val releaseClaims = AtomicLong() + + /** + * The claim of whoever currently holds [videoAttemptMutex]. Only read and + * written under that lock, which is what makes a plain field safe here — + * exactly one coroutine can be inside the lock at a time. + */ + private var currentReleaseClaim = 0L private var pendingVideoPublication: PendingVideoPublication? = null private var contentResetInProgress = false @@ -183,10 +235,16 @@ open class PlaybackSessionManager( videoAttemptMutex.lock() val pending = pendingVideoPublication if (pending == null) { + val claim = releaseClaims.incrementAndGet() + currentReleaseClaim = claim try { return block() } finally { videoAttemptMutex.unlock() + // After the unlock, deliberately: the block may have queued + // stops for sessions it discarded, and issuing them under + // the lock would hold every other caller behind network I/O. + drainPendingOwnershipReleases(claim) } } videoAttemptMutex.unlock() @@ -203,10 +261,57 @@ open class PlaybackSessionManager( subtitleTrackIndex: Int?, qualityPreference: String?, startPosition: Double?, + /** + * The bandwidth half of the user's quality choice + * (`playback.max_bitrate_kbps`); null is uncapped. + * + * Quality is two axes, and the server applies the cap only from what + * the client sends — nothing on the playback path reads the stored + * setting. Sending the resolution alone means a capped preset like + * "1080p Low" delivers 1080p at whatever bitrate the ladder picks, + * which is the bandwidth the user explicitly declined. + */ + maxBitrateKbps: Int? = null, subtitleFidelityPreference: SubtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + progressPersistence: ProgressPersistenceV3 = ProgressPersistenceV3.SERVER, deferPublication: Boolean = false, ): ApiResult = contentStartMutex.withLock { + /** + * The session this call is currently answerable for. + * + * Once the server responds it has allocated a session, but every branch + * below still suspends — acquiring [videoAttemptMutex], emitting a route + * event, issuing its own stop — before that id is either published into + * [activeVideoAttempt] or stopped. A cancellation in that window leaves + * the id owned by nobody: the manager never published it, and the + * callers never learn it, because they only see an id when this function + * returns. The transcode then runs on until the server's own expiry, + * holding a stream slot; a retry can produce a second session for the + * same screen, or fail outright as "too many streams". + * + * So: arm this the moment the response decodes, and clear it only where + * responsibility genuinely moves — to the manager on publication, or to + * a stop the *server acknowledged*. A branch that takes ownership back + * (the replan error path) re-arms it. The finally releases whatever is + * still held, uncancellably. + * + * Scope: this covers ids allocated by *this* call. The internal replan + * reached from the ReplanRequired branch allocates its own candidates + * and clears `activeVideoAttempt` before its own suspending cleanup; + * those windows are held by [stopRetainingFailureLocked] instead, which + * is the same register-before-stop discipline expressed against the + * manager's orphan set because those paths already hold + * [videoAttemptMutex]. + */ + var leasedSessionId: String? = null try { + if (progressPersistence == ProgressPersistenceV3.CLIENT && startPosition == null) { + return@withLock ApiResult.Error( + code = 400, + error = "client_progress_requires_start_position", + message = "Client-owned progress requires an explicit file-local start position.", + ) + } beginContentReset() val predecessorForPublication = videoAttemptMutex.withLock { activeVideoAttempt.get() @@ -220,6 +325,7 @@ open class PlaybackSessionManager( qualityPreference = qualityPreference?.lowercase() ?: "auto", subtitleFidelityPreference = subtitleFidelityPreference, startPosition = startPosition, + progressPersistence = progressPersistence, audioTrackId = audioTrackIndex?.let { stableTrackId(fileId, "audio", it) }, audioTrackIndex = audioTrackIndex, subtitleTrackId = subtitleTrackIndex?.takeIf { it >= 0 } @@ -233,15 +339,17 @@ open class PlaybackSessionManager( // identical without tripping the validator. The replan path and // the track id above already filter negatives the same way. subtitleTrackIndex = subtitleTrackIndex?.takeIf { it >= 0 }, - outputRouteGeneration = clientPlaybackContext.output.outputRouteGeneration, + clientFeatures = playbackClientFeaturesV3(clientPlaybackContext), metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + bandwidthCapKbps = maxBitrateKbps?.takeIf { it > 0 }, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, ) return@withLock when (val result = playbackRepository.startPlaybackV3(request)) { is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) { is PlaybackV3Validation.Playable -> { + leasedSessionId = validated.sessionId val planAttemptId = UUID.randomUUID().toString() val active = newActiveAttempt( request = request, @@ -258,7 +366,10 @@ open class PlaybackSessionManager( deferPublication = deferPublication, ) } - PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) + // Published: the manager owns this id now, so teardown + // is its problem rather than this call's. + leasedSessionId = null + passthroughSuppression.beginAttempt(active.planAttemptKey) reportActiveVideoEvent("plan_selected", network.asRouteDiagnostics()) ApiResult.Success( VideoSessionStartV3.Ready( @@ -267,10 +378,14 @@ open class PlaybackSessionManager( playbackAttemptId = playbackAttemptId, planAttemptId = planAttemptId, planAttemptKey = active.planAttemptKey, + capabilities = request.capabilities, + clientPlaybackContext = request.clientPlaybackContext, ), ) } is PlaybackV3Validation.Terminal -> { + leasedSessionId = + result.data.playbackPlan?.sessionId ?: result.data.sessionId if (!deferPublication) { videoAttemptMutex.withLock { activeVideoAttempt.set(null) @@ -282,28 +397,38 @@ open class PlaybackSessionManager( sessionId = result.data.sessionId, event = "terminal", fallbackReason = validated.reason, - outputRouteGeneration = request.outputRouteGeneration, + outputContextId = request.clientPlaybackContext.output.outputContextId, ), ) - (result.data.playbackPlan?.sessionId ?: result.data.sessionId) + // Only a stop the server acknowledged discharges the + // lease. An Error/NetworkError does not throw, so + // clearing on the call alone would drop the session on + // exactly the failure the lease exists to survive. + val stopped = (result.data.playbackPlan?.sessionId ?: result.data.sessionId) ?.let { playbackRepository.stopPlayback(it) } + if (stopped.isStopDischarged()) leasedSessionId = null ApiResult.Success( VideoSessionStartV3.Terminal(validated.reason, validated.message, validated.retryable), ) } is PlaybackV3Validation.Incompatible -> { + leasedSessionId = validated.allocatedSessionId if (!deferPublication) { videoAttemptMutex.withLock { activeVideoAttempt.set(null) } } - validated.allocatedSessionId?.let { playbackRepository.stopPlayback(it) } + val stopped = validated.allocatedSessionId + ?.let { playbackRepository.stopPlayback(it) } + if (stopped.isStopDischarged()) leasedSessionId = null ApiResult.Success(VideoSessionStartV3.ServerUpgradeRequired) } is PlaybackV3Validation.ReplanRequired -> { - // Decode stale engine enums, but never execute them. Preserve - // the allocated session and give the v3 planner exactly one - // opportunity to replace the route with a Media3 plan. + leasedSessionId = validated.sessionId + // The plan is well-formed but names a client-side + // correction or transformation this build cannot + // execute. Preserve the allocated session and give the + // planner exactly one chance to route around it. val planAttemptId = UUID.randomUUID().toString() val active = newActiveAttempt( request = request, @@ -314,11 +439,21 @@ open class PlaybackSessionManager( planAttemptId = planAttemptId, ) videoAttemptMutex.withLock { activeVideoAttempt.set(active) } - PassthroughSuppressionRegistry.beginAttempt(active.planAttemptKey) + // The lease STAYS ARMED across the nested replan. Being + // installed as the active attempt is not the same as + // being findable: nothing outside this call has the id + // yet, and the replan below suspends — on + // finishContentReset, then on its own mutex — before it + // reaches any cancellation-safe cleanup of its own. A + // cancellation in that window used to leave the session + // installed, unknown to every caller, and running until + // the server expired it. The branches after the replan + // clear or re-arm it once its fate is decided. + passthroughSuppression.beginAttempt(active.planAttemptKey) finishContentReset() val replanResult = replanActiveVideoSession( classification = validated.reason, - message = "The server returned a legacy player route.", + message = UNEXECUTABLE_ROUTE_MESSAGE, positionSeconds = startPosition ?: 0.0, audioTrackIndex = audioTrackIndex, subtitleTrackIndex = subtitleTrackIndex, @@ -340,11 +475,18 @@ open class PlaybackSessionManager( } revertRenderedPlanKeepingCursor(predecessorForPublication) predecessorForPublication?.let { - PassthroughSuppressionRegistry.beginAttempt(it.planAttemptKey) + passthroughSuppression.beginAttempt(it.planAttemptKey) } } } - abandonedSessionId?.let { playbackRepository.stopPlayback(it) } + // The lock above decided this id's fate. Either it + // was abandoned — in which case the lease names it + // until the stop is acknowledged — or it is the + // published replacement and the manager owns it. + leasedSessionId = abandonedSessionId + val stopped = abandonedSessionId + ?.let { playbackRepository.stopPlayback(it) } + if (stopped.isStopDischarged()) leasedSessionId = null } else if ( replanResult is ApiResult.Error || replanResult is ApiResult.NetworkError @@ -353,8 +495,21 @@ open class PlaybackSessionManager( activeVideoAttempt.compareAndSet(active, null) } if (cleared) { - playbackRepository.stopPlayback(validated.sessionId) + // Same reasoning as the deferred branch: the CAS + // above removed the manager's only reference. + leasedSessionId = validated.sessionId + val stopped = + playbackRepository.stopPlayback(validated.sessionId) + if (stopped.isStopDischarged()) leasedSessionId = null } + } else { + // Replan succeeded and published through the manager. + // The base id is either the committed attempt or was + // stopped by the replan itself; either way this call + // is no longer answerable for it, and leaving the + // lease armed would have the finally stop a session + // that is playing. + leasedSessionId = null } replanResult } @@ -364,6 +519,14 @@ open class PlaybackSessionManager( } } finally { finishContentReset() + // NonCancellable because this runs precisely when the surrounding + // work was cancelled. Failures stay queued in orphanedSessionIds so + // the next content reset drains them. + leasedSessionId?.let { orphan -> + withContext(NonCancellable) { + stopSessionsRetainingFailures(listOf(orphan)) + } + } } } @@ -375,7 +538,9 @@ open class PlaybackSessionManager( serverFeatures: Set, planAttemptId: String, ): ActiveVideoAttempt { - val planAttemptKey = plan.planAttemptKey(request.outputRouteGeneration) + // Server-minted and opaque: the client stores it and echoes it back, it + // never derives one. + val planAttemptKey = plan.planAttemptKey return ActiveVideoAttempt( fileId = request.fileId, profileId = request.profileId, @@ -383,6 +548,7 @@ open class PlaybackSessionManager( context = request.clientPlaybackContext, playbackAttemptId = request.playbackAttemptId, qualityPreference = request.qualityPreference, + bandwidthCapKbps = request.bandwidthCapKbps, networkEvidence = network, sessionId = sessionId, plan = plan, @@ -541,6 +707,7 @@ open class PlaybackSessionManager( qualityPreference = qualityPreference, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, + operation = replanOperationForClassification(classification), preserveImmediateOutcomes = true, ) ) { @@ -576,6 +743,7 @@ open class PlaybackSessionManager( qualityPreference = qualityPreference, capabilities = capabilities, clientPlaybackContext = clientPlaybackContext, + operation = replanOperationForClassification(classification), preserveImmediateOutcomes = false, ) ) { @@ -602,6 +770,7 @@ open class PlaybackSessionManager( qualityPreference: String? = null, capabilities: ClientCodecCapabilities? = null, clientPlaybackContext: ClientPlaybackContext? = null, + operation: String, preserveImmediateOutcomes: Boolean, ): ApiResult = withSettledVideoAttempt { if (contentResetInProgress) { @@ -616,38 +785,73 @@ open class PlaybackSessionManager( error = "playback_attempt_not_active", message = "No protocol-v3 playback attempt is active.", ) - if (classification == SEEK_REANCHOR_V3_OPERATION || - classification == SEEK_FAILURE_RECOVERY_V3_OPERATION - ) { + if (operation == SEEK_REANCHOR_V3_OPERATION || operation == SEEK_FAILURE_RECOVERY_V3_OPERATION) { return@withSettledVideoAttempt ApiResult.Error( code = 400, error = "reserved_playback_operation", message = "Seek operations must use the dedicated playback session methods.", ) } + val intent = operation in INTENT_V3_OPERATIONS + val effectiveQuality = qualityPreference?.lowercase() ?: active.qualityPreference + // Mirror the server's own validator so a malformed operation is caught + // before it costs a round trip: failure recovery must name what failed, + // and a quality change must name the rung it wants — an empty + // preference would silently mean "auto", a different user intent than + // the menu selection this operation models. + if (!intent && classification.isBlank()) { + return@withSettledVideoAttempt ApiResult.Error( + code = 400, + error = "invalid_replan_operation", + message = "Failure recovery requires a failure classification.", + ) + } + if (operation == QUALITY_CHANGE_V3_OPERATION && effectiveQuality.isBlank()) { + return@withSettledVideoAttempt ApiResult.Error( + code = 400, + error = "invalid_replan_operation", + message = "A quality change requires a quality preference.", + ) + } val currentCapabilities = capabilities ?: active.capabilities val currentContext = clientPlaybackContext ?: active.context val network = networkEvidenceProvider.snapshot() - val failedKey = active.planAttemptKey - val invalidation = classification in USER_INVALIDATION_CLASSIFICATIONS + // A rollback can leave the player rendering its predecessor after the + // server has committed the candidate. In that state the cursor is one + // atomic server-facing identity tuple; mixing its plan id with the + // rendered plan's key/history produces a request that never existed. + val cursor = active.serverPlanCursor + val (serverPlanId, serverPlanAttemptId, failedKey) = active.serverControlIdentity + val priorAttemptedKeys = cursor?.attemptedPlanKeys ?: active.attemptedPlanKeys + // An intent operation is a user's choice, not a failure: the previous + // route stays eligible, so no attempt history is sent and the attempt + // counter restarts. `output_route_changed` is still failure-shaped — + // the route the client was using genuinely stopped working — so it + // keeps the legacy classification path while resetting the same state. + val invalidation = intent || classification in USER_INVALIDATION_CLASSIFICATIONS val attemptedKeys = if (invalidation) { emptyList() } else { - (active.attemptedPlanKeys + failedKey).distinct() + (priorAttemptedKeys + failedKey).distinct() + } + val requestAttemptCount = if (invalidation) { + 1 + } else { + cursor?.attemptCount ?: active.attemptCount } - val requestAttemptCount = if (invalidation) 1 else active.attemptCount + val candidateAttemptCount = if (invalidation) 1 else requestAttemptCount + 1 emitRouteEvent( PlaybackRouteEventV3( playbackAttemptId = active.playbackAttemptId, sessionId = active.sessionId, planId = active.plan.planId, planAttemptId = active.planAttemptId, - planAttemptKey = failedKey, + planAttemptKey = active.planAttemptKey, event = if (invalidation) "plan_invalidated" else "plan_failed", - failureClassification = classification, + failureClassification = classification.takeIf { it.isNotBlank() }, appliedQuirkIds = active.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = active.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = currentContext.output.outputRouteGeneration, + outputContextId = currentContext.output.outputContextId, diagnostics = diagnostics + mapOfNotNull("decoder_name" to decoderName) + network.asRouteDiagnostics(), ), @@ -655,39 +859,50 @@ open class PlaybackSessionManager( // Address the server by the plan IT holds, not the one we are rendering. // After a rollback those differ, and using the rendered plan sends a // retired failedPlanId that the server rejects with 409. - val cursor = active.serverPlanCursor val request = PlaybackReplanRequestV3( + clientFeatures = playbackClientFeaturesV3(currentContext), + operation = operation, playbackAttemptId = active.playbackAttemptId, replanRequestId = UUID.randomUUID().toString(), - failedPlanId = active.serverPlanId, - planAttemptId = cursor?.planAttemptId ?: active.planAttemptId, + failedPlanId = serverPlanId, + planAttemptId = serverPlanAttemptId, planAttemptKey = failedKey, attemptedPlanKeys = attemptedKeys, + // Route changes the client made to the server's recipe on its own. + // The server folds them into the keys it excludes; the client never + // hashes anything itself. + localMutations = active.localMutations, attemptCount = requestAttemptCount, - qualityPreference = qualityPreference?.lowercase() ?: active.qualityPreference, + qualityPreference = effectiveQuality, positionSeconds = positionSeconds, - outputRouteGeneration = currentContext.output.outputRouteGeneration, metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + // The cap is a per-request delivery ceiling: omitting it on a + // replan would silently lift the user's bandwidth limit for the + // rest of the session. + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = SelectedPlaybackTracksV3( audio = selectedTrackIdentity(active, "audio", audioTrackIndex, active.plan.selectedTracks.audio), subtitle = subtitleTrackIndex?.takeIf { it >= 0 } ?.let { selectedTrackIdentity(active, "subtitle", it, active.plan.selectedTracks.subtitle) }, ), - failure = PlaybackFailureV3(classification, message, decoderName), + // Intent operations describe a user's choice, so they carry no + // failure block at all. + failure = if (intent) null else PlaybackFailureV3(classification, message, decoderName), capabilities = currentCapabilities, clientPlaybackContext = currentContext, ) val result = playbackRepository.replanPlaybackV3(active.sessionId, request) + var committedPlanAttemptId: String? = null if (result is ApiResult.Success) { // The server has committed this plan. Record it before any // validation branch: several of those return early (loop detected, // invalid candidate, discard) and every one of them would otherwise // leave the cursor addressing a plan the server has already retired. result.data.playbackPlan?.let { committedPlan -> - val committedKey = committedPlan.planAttemptKey( - currentContext.output.outputRouteGeneration, - ) + val committedKey = committedPlan.planAttemptKey + val nextAttemptId = UUID.randomUUID().toString() + committedPlanAttemptId = nextAttemptId // Compare-and-set: a supersession may already have swapped the // attempt while this response was in flight, and a plain // get()/set() would silently restore the superseded one. @@ -699,13 +914,12 @@ open class PlaybackSessionManager( live.copy( serverPlanCursor = ServerPlanCursor( planId = committedPlan.planId, - // planAttemptId is client-generated per - // attempt; the server keys currency off - // planId, so carry ours forward unchanged. - planAttemptId = live.planAttemptId, + planAttemptId = nextAttemptId, planAttemptKey = committedKey, - attemptedPlanKeys = (attemptedKeys + committedKey).distinct(), - attemptCount = requestAttemptCount, + attemptedPlanKeys = ( + attemptedKeys + listOfNotNull(committedKey.takeIf(String::isNotBlank)) + ).distinct(), + attemptCount = candidateAttemptCount, ), ), ) @@ -715,7 +929,7 @@ open class PlaybackSessionManager( when (result) { is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) { is PlaybackV3Validation.Playable -> { - val nextKey = validated.plan.planAttemptKey(currentContext.output.outputRouteGeneration) + val nextKey = validated.plan.planAttemptKey if (nextKey in attemptedKeys) { stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) if (preserveImmediateOutcomes) { @@ -739,6 +953,7 @@ open class PlaybackSessionManager( val subtitleMismatch = subtitleCandidateMismatch( requested = request.selectedTracks.subtitle, candidate = validated.plan, + currentEffectiveFileId = active.plan.effectiveMediaFileId ?: active.fileId, ) if (subtitleMismatch != null) { stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) @@ -748,7 +963,7 @@ open class PlaybackSessionManager( message = subtitleMismatch, ) } - val nextAttemptId = UUID.randomUUID().toString() + val nextAttemptId = checkNotNull(committedPlanAttemptId) val next = active.copy( sessionId = validated.sessionId, plan = validated.plan, @@ -766,7 +981,7 @@ open class PlaybackSessionManager( planAttemptKey = nextKey, localMutations = emptyList(), attemptedPlanKeys = attemptedKeys + nextKey, - attemptCount = if (invalidation) 1 else active.attemptCount + 1, + attemptCount = candidateAttemptCount, qualityPreference = request.qualityPreference, networkEvidence = network, capabilities = currentCapabilities, @@ -784,6 +999,8 @@ open class PlaybackSessionManager( playbackAttemptId = active.playbackAttemptId, planAttemptId = nextAttemptId, planAttemptKey = nextKey, + capabilities = currentCapabilities, + clientPlaybackContext = currentContext, ) val staged = StagedVideoReplan( basePlaybackAttemptId = active.playbackAttemptId, @@ -791,7 +1008,7 @@ open class PlaybackSessionManager( basePlanAttemptId = active.planAttemptId, candidate = ready, candidateSessionId = validated.sessionId, - outputRouteGeneration = next.context.output.outputRouteGeneration, + outputContextId = next.context.output.outputContextId, ) stagedVideoReplans[staged] = PreparedStagedVideoReplan( nextAttempt = next, @@ -865,8 +1082,8 @@ open class PlaybackSessionManager( ApiResult.Success( PreparedVideoReplan.ImmediateOutcome( VideoSessionStartV3.Terminal( - "unsupported_legacy_engine", - "The server could not provide a Media3 playback route.", + UNEXECUTABLE_ROUTE_REASON, + UNEXECUTABLE_ROUTE_MESSAGE, false, ), ), @@ -875,8 +1092,8 @@ open class PlaybackSessionManager( stopCandidateSessionIfUnowned(active.sessionId, validated.sessionId) ApiResult.Error( code = 502, - error = "unsupported_legacy_engine", - message = "The server could not provide a Media3 playback route.", + error = UNEXECUTABLE_ROUTE_REASON, + message = UNEXECUTABLE_ROUTE_MESSAGE, ) } } @@ -889,7 +1106,24 @@ open class PlaybackSessionManager( suspend fun commitStagedVideoReplan( staged: StagedVideoReplan, deferPublication: Boolean = false, + ): ApiResult { + val claim = releaseClaims.incrementAndGet() + return try { + commitStagedVideoReplanLocked(staged, deferPublication, claim) + } finally { + // Same contract as withSettledVideoAttempt: stops queued while the + // lock was held are issued once it is released, and this awaits only + // the ones this call queued. + drainPendingOwnershipReleases(claim) + } + } + + private suspend fun commitStagedVideoReplanLocked( + staged: StagedVideoReplan, + deferPublication: Boolean, + claim: Long, ): ApiResult = videoAttemptMutex.withLock { + currentReleaseClaim = claim val prepared = stagedVideoReplans.remove(staged) ?: return@withLock stagedVideoReplanUnavailable() val active = activeVideoAttempt.get() @@ -903,7 +1137,7 @@ open class PlaybackSessionManager( } val next = prepared.nextAttempt - PassthroughSuppressionRegistry.beginAttempt(next.planAttemptKey) + passthroughSuppression.beginAttempt(next.planAttemptKey) val routeEvent = PlaybackRouteEventV3( playbackAttemptId = next.playbackAttemptId, sessionId = next.sessionId, @@ -914,12 +1148,14 @@ open class PlaybackSessionManager( fallbackReason = prepared.fallbackReason, appliedQuirkIds = next.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = next.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = next.context.output.outputRouteGeneration, + outputContextId = next.context.output.outputContextId, ) // This is the commit point. Everything after it is best-effort, // non-blocking bookkeeping: callers must always receive the committed - // candidate once manager ownership has moved to [next]. + // candidate once manager ownership has moved to [next]. A commit that + // reaches here has queued no release, so the caller's drain finds + // nothing of its own and returns without waiting. activeVideoAttempt.set(next) if (deferPublication) { pendingVideoPublication = PendingVideoPublication( @@ -946,7 +1182,7 @@ open class PlaybackSessionManager( pendingVideoPublication = null val predecessorSessionId = pending.predecessor?.sessionId ?.takeIf { it != sessionId } - predecessorSessionId?.let { orphanedSessionIds += it } + predecessorSessionId?.let { rememberOrphanedSessionLocked(it) } pending.settled.complete(Unit) true to predecessorSessionId } ?: return false @@ -999,6 +1235,74 @@ open class PlaybackSessionManager( return disowned } + /** + * Fire-and-forget [abandonActiveVideoSession] on the manager's own scope. + * + * Callers reach this exactly when their own scope is being torn down, which + * rules out doing the work inline. `viewModelScope.launch(NonCancellable)` + * looks like the answer and does run, but it severs the parent link to + * produce an untracked coroutine nothing can await or observe failures from + * — the pattern the coroutines documentation warns against. The manager's + * cleanup scope already outlives any screen and is what the committed-session + * cleanup path uses, so ownership of a release belongs there rather than in + * a ViewModel that is on its way out. + */ + fun abandonActiveVideoSessionAsync(sessionId: String) { + sessionCleanupScope.launch { + runCatching { abandonActiveVideoSessionIfCurrent(sessionId) } + } + } + + /** + * [abandonActiveVideoSession], but only while this session is still the one + * the manager holds. + * + * The unconditional variant stops the session even when it failed to disown + * it, and [stopSession]'s predecessor branch then clears a *newer* pending + * publication and stops its replacement. Running abandonment on a dispatched + * scope widens that window enough to matter: a stale result scheduled for + * release can land after a newer deferred publication has installed itself + * with this id as its predecessor, and tear the new one down. + * + * When ownership has already moved on, the id is recorded as an orphan + * instead. The drain stops it with a plain repository call that cannot + * disturb whoever owns playback now. + */ + suspend fun abandonActiveVideoSessionIfCurrent(sessionId: String): Boolean { + val disowned = videoAttemptMutex.withLock { + val active = activeVideoAttempt.get() + if (active?.sessionId != sessionId) { + rememberOrphanedSessionLocked(sessionId) + false + } else { + activeVideoAttempt.compareAndSet(active, null) + } + } + if (!disowned) return false + stopSession(sessionId) + return true + } + + /** + * Drops an unpublished candidate only while the manager still owns that + * exact server plan. This is stricter than session-id ownership because an + * in-place replan legitimately reuses the same session id; a late UI + * transaction must never tear down the newer plan that superseded it. + */ + suspend fun abandonActiveVideoPlanIfCurrent(sessionId: String, planId: String): Boolean { + val disowned = videoAttemptMutex.withLock { + val active = activeVideoAttempt.get() + if (active?.sessionId != sessionId || active.plan.planId != planId) { + false + } else { + activeVideoAttempt.compareAndSet(active, null) + } + } + if (!disowned) return false + stopSession(sessionId) + return true + } + suspend fun rollbackUnpublishedVideoSession(sessionId: String): Boolean { val rollback = videoAttemptMutex.withLock { rollbackPendingPublicationLocked(sessionId) @@ -1070,7 +1374,7 @@ open class PlaybackSessionManager( pendingVideoPublication = null revertRenderedPlanKeepingCursor(pending.predecessor) pending.predecessor?.let { - PassthroughSuppressionRegistry.beginAttempt(it.planAttemptKey) + passthroughSuppression.beginAttempt(it.planAttemptKey) } val protectedSessionIds = setOfNotNull( sessionId, @@ -1093,7 +1397,7 @@ open class PlaybackSessionManager( activeSessionId: String, ) { if (oldSessionId == activeSessionId) return - orphanedSessionIds += oldSessionId + rememberOrphanedSessionLocked(oldSessionId) scheduleRegisteredCommittedSessionCleanup( oldSessionId = oldSessionId, activeSessionId = activeSessionId, @@ -1116,7 +1420,7 @@ open class PlaybackSessionManager( } catch (_: Throwable) { null } - if (result is ApiResult.Success) { + if (result.isStopDischarged()) { stopped = true break } @@ -1131,21 +1435,48 @@ open class PlaybackSessionManager( } private suspend fun drainOrphanedSessions(protectedSessionIds: Set) { + // Claim the ids under the lock, marking them in flight in the same + // critical section. Filtering alone only holds for the instant of the + // snapshot — the stops below run unlocked, and without a marker another + // drain could select the same session and stop it concurrently. val orphanIds = videoAttemptMutex.withLock { val live = setOfNotNull(activeVideoAttempt.get()?.sessionId) - orphanedSessionIds.filterNot { it in protectedSessionIds || it in live } + orphanedSessionIds.filterNot { + it in protectedSessionIds || + it in live || + // A queued release already owns this one, and its own drain + // will remove it on discharge. + it in releasesInFlight || + pendingOwnershipReleases.any { pending -> pending.second == it } + }.onEach { markReleaseInFlightLocked(it) } } orphanIds.forEach { sessionId -> - val result = try { - playbackRepository.stopPlayback(sessionId) - } catch (_: CancellationException) { - return@forEach - } catch (_: Throwable) { - null - } - if (result is ApiResult.Success) { - videoAttemptMutex.withLock { - orphanedSessionIds -= sessionId + try { + val result = try { + playbackRepository.stopPlayback(sessionId) + } catch (_: CancellationException) { + return@forEach + } catch (_: Throwable) { + null + } + if (result.isStopDischarged()) { + videoAttemptMutex.withLock { + orphanedSessionIds -= sessionId + } + } + } finally { + // Including the cancellation return above: a marker left behind + // would hide this session from every future drain. + withContext(NonCancellable) { + videoAttemptMutex.withLock { + clearReleaseInFlightLocked(sessionId) + // Re-trim here too. Trimming skips in-flight ids, so + // whichever release path clears the last marker has to + // re-apply the cap — otherwise a burst drained from here + // leaves the ledger over its bound until some unrelated + // future orphan happens to trigger a trim. + trimOrphanedSessionsLocked() + } } } } @@ -1155,7 +1486,7 @@ open class PlaybackSessionManager( val uniqueSessionIds = sessionIds.distinct() if (uniqueSessionIds.isEmpty()) return videoAttemptMutex.withLock { - orphanedSessionIds += uniqueSessionIds + uniqueSessionIds.forEach { rememberOrphanedSessionLocked(it) } } uniqueSessionIds.forEach { sessionId -> val result = try { @@ -1163,7 +1494,7 @@ open class PlaybackSessionManager( } catch (_: Throwable) { null } - if (result is ApiResult.Success) { + if (result.isStopDischarged()) { videoAttemptMutex.withLock { orphanedSessionIds -= sessionId } @@ -1183,7 +1514,7 @@ open class PlaybackSessionManager( stagedVideoReplans.keys.none { it.candidateSessionId == candidateSessionId } - }?.also { orphanedSessionIds += it } + }?.also { rememberOrphanedSessionLocked(it) } } if (candidateSessionId == null) return @@ -1193,7 +1524,7 @@ open class PlaybackSessionManager( } catch (_: Throwable) { null } - if (result is ApiResult.Success) { + if (result.isStopDischarged()) { videoAttemptMutex.withLock { orphanedSessionIds -= candidateSessionId } @@ -1207,6 +1538,145 @@ open class PlaybackSessionManager( message = "The staged playback replan was already consumed or no longer matches the active content.", ) + /** + * Stops a session this caller is discarding, keeping a record of it until + * the server confirms it is gone. Caller must hold [videoAttemptMutex]. + * + * Some callers reach here having already cleared `activeVideoAttempt` or + * removed the staged handle, and for those the id exists nowhere else in the + * process from that moment until the server replies. A bare suspending stop + * there is cancellable — and these run from ViewModel recovery jobs that + * exit, content replacement and teardown all cancel — so the id would simply + * be lost and the transcode would hold its stream slot until the server's + * own expiry. Registering first makes the worst case a retry on the next + * content reset rather than an orphan nobody remembers. Callers that have + * not given up ownership (a rejected validation candidate, say) are + * registered on the same path because it costs nothing. + */ + private fun stopRetainingFailureLocked(sessionId: String) { + // The stop is queued rather than issued. Requests time out at 60s, and + // awaiting one while holding videoAttemptMutex would serialise every + // start, replan, content reset and staged commit behind a dying + // session's teardown. [drainPendingOwnershipReleases] runs it once the + // lock is released, still awaited by the caller that queued it. + // + // Tagged with the claim of the lock holder that queued it. Without that + // tag one shared queue lets any concurrent drain take another caller's + // work: the queuing caller then returns before its own stop ran, while + // an unrelated caller — which queued nothing — blocks for a full network + // timeout on someone else's teardown. + // + // Queued BEFORE registering, because rememberOrphanedSessionLocked + // trims and trimming protects only ids already queued or in flight — + // so with a full ledger of protected entries this session could be the + // one evictable entry and get dropped before its stop had even started. + pendingOwnershipReleases += currentReleaseClaim to sessionId + // Registration is what makes the id survivable: if the queued stop + // fails, this is the record the next content reset retries from. + rememberOrphanedSessionLocked(sessionId) + } + + /** + * Issues the stops [stopRetainingFailureLocked] queued under [claim]. Must + * be called with [videoAttemptMutex] NOT held. + * + * NonCancellable throughout: these sessions are already registered as + * orphans and unreferenced anywhere else, and the callers reaching here are + * frequently being cancelled. Anything that fails stays registered for the + * next content reset to drain — unless the ledger is at its cap and the + * entry has already been evicted, in which case that session falls back to + * the server's own expiry. + */ + private suspend fun drainPendingOwnershipReleases(claim: Long) { + withContext(NonCancellable) { + while (true) { + val sessionId = videoAttemptMutex.withLock { + val index = pendingOwnershipReleases.indexOfFirst { it.first == claim } + if (index < 0) { + null + } else { + pendingOwnershipReleases.removeAt(index).second.also { + // Visible to drainOrphanedSessions for as long as the + // stop is in flight, so a concurrent content reset + // does not issue a second stop for the same session. + markReleaseInFlightLocked(it) + } + } + } ?: return@withContext + val result = try { + playbackRepository.stopPlayback(sessionId) + } catch (_: Throwable) { + null + } + videoAttemptMutex.withLock { + clearReleaseInFlightLocked(sessionId) + if (result.isStopDischarged()) { + orphanedSessionIds -= sessionId + } + // The cap can only skip entries that were mid-release, so + // re-apply it once one finishes; otherwise a burst of + // concurrent releases leaves the ledger permanently over + // its bound with nothing to bring it back down. + trimOrphanedSessionsLocked() + } + } + } + } + + /** + * Records a session whose stop has not been confirmed, oldest evicted first. + * + * The ledger has to be bounded. Only a discharged stop removes an entry, so + * a server that keeps failing this call — while playback keeps producing new + * sessions — would otherwise grow it without limit and make every later + * content reset retry an ever-larger collection. Dropping the oldest entry + * costs that session its explicit stop and falls back to the server's own + * expiry, which is exactly what happens today when a stop never succeeds. + */ + private fun markReleaseInFlightLocked(sessionId: String) { + releasesInFlight[sessionId] = (releasesInFlight[sessionId] ?: 0) + 1 + } + + private fun clearReleaseInFlightLocked(sessionId: String) { + val remaining = (releasesInFlight[sessionId] ?: 0) - 1 + if (remaining > 0) releasesInFlight[sessionId] = remaining else releasesInFlight -= sessionId + } + + private fun rememberOrphanedSessionLocked(sessionId: String) { + orphanedSessionIds += sessionId + trimOrphanedSessionsLocked() + } + + private fun trimOrphanedSessionsLocked() { + while (orphanedSessionIds.size > MAX_RETAINED_ORPHANED_SESSIONS) { + // Never evict an id whose release this manager is tracking — the + // queued and in-flight sets. Committed-session cleanup and the + // direct retaining-stop helpers issue unmarked stops, so this is not + // protection against every release in flight, only the ones the + // queue knows about. + // When everything over the cap is mid-release there is nothing + // safe to drop, so the set stays over its bound until one of those + // releases completes and re-runs this. + val oldest = orphanedSessionIds.firstOrNull { + it !in releasesInFlight && + pendingOwnershipReleases.none { pending -> pending.second == it } + } ?: break + orphanedSessionIds -= oldest + } + } + + /** + * True once the server owes us nothing more for this session. + * + * A typed session-missing 404 counts: the session is already gone, and + * treating that as a failure would keep the id in [orphanedSessionIds] + * forever and retry it on every single drain. A bare 404 does not — routing, + * proxy and compatibility 404s prove nothing about the session, so this uses + * the same predicate the rest of the manager uses for absence. + */ + private fun ApiResult?.isStopDischarged(): Boolean = + this is ApiResult.Success || this?.isPlaybackSessionMissingError() == true + private suspend fun stopCandidateSessionIfUnowned( activeSessionId: String?, candidateSessionId: String?, @@ -1218,7 +1688,7 @@ open class PlaybackSessionManager( ) { return } - playbackRepository.stopPlayback(candidateSessionId) + stopRetainingFailureLocked(candidateSessionId) } private suspend fun stopCandidateSessionsIfUnowned( @@ -1235,7 +1705,9 @@ open class PlaybackSessionManager( stopActiveSession: Boolean, ) { if (stopActiveSession) { - playbackRepository.stopPlayback(activeSessionId) + // Ownership was cleared immediately above, so this is the same + // register-before-stop case as the candidates below. + stopRetainingFailureLocked(activeSessionId) } candidateSessionIds.filterNotNull().distinct() .filter { it != activeSessionId } @@ -1245,6 +1717,7 @@ open class PlaybackSessionManager( private fun subtitleCandidateMismatch( requested: PlaybackTrackIdentityV3?, candidate: PlaybackPlanV3, + currentEffectiveFileId: Int, ): String? { val selected = candidate.selectedTracks.subtitle val subtitle = candidate.subtitle @@ -1259,11 +1732,21 @@ open class PlaybackSessionManager( "The candidate did not keep subtitles off." } } - if (selected?.id != requested.id || - selected?.index != requested.index || - subtitle.trackId != requested.id - ) { - return "The candidate did not select the exact requested subtitle track." + val candidateEffectiveFileId = candidate.effectiveMediaFileId + ?: candidate.requestedMediaFileId + ?: currentEffectiveFileId + if (candidateEffectiveFileId == currentEffectiveFileId) { + if (selected?.id != requested.id || + (selected.index != null && selected.index != requested.index) || + subtitle.trackId != requested.id + ) { + return "The candidate did not select the exact requested subtitle track." + } + } else if (selected == null || subtitle.trackId != selected.id) { + // Edition adaptation may remap both the stable id and ordinal. The + // candidate inventory has already been validated, so require only + // that its own selected identity and subtitle decision agree. + return "The adapted candidate did not preserve a selected subtitle identity." } return when (subtitle.mode) { PlaybackSubtitleModeV3.BURN_IN -> null @@ -1333,6 +1816,7 @@ open class PlaybackSessionManager( val network = networkEvidenceProvider.snapshot() val request = PlaybackReplanRequestV3( + clientFeatures = playbackClientFeaturesV3(active.context), operation = SEEK_REANCHOR_V3_OPERATION, playbackAttemptId = active.playbackAttemptId, replanRequestId = UUID.randomUUID().toString(), @@ -1340,17 +1824,14 @@ open class PlaybackSessionManager( planAttemptId = active.planAttemptId, planAttemptKey = active.planAttemptKey, attemptedPlanKeys = active.attemptedPlanKeys, + localMutations = active.localMutations, attemptCount = active.attemptCount, qualityPreference = active.qualityPreference, positionSeconds = positionSeconds, - outputRouteGeneration = active.context.output.outputRouteGeneration, metered = active.networkEvidence.metered, bandwidthEstimateKbps = active.networkEvidence.bandwidthEstimateKbps, + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = active.plan.selectedTracks, - failure = PlaybackFailureV3( - classification = SEEK_REANCHOR_V3_OPERATION, - message = "Reanchor the active stream at the requested source position.", - ), capabilities = active.capabilities, clientPlaybackContext = active.context, ) @@ -1364,7 +1845,7 @@ open class PlaybackSessionManager( event = "seek_reanchor_requested", appliedQuirkIds = active.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = active.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = active.context.output.outputRouteGeneration, + outputContextId = active.context.output.outputContextId, diagnostics = diagnostics + network.asRouteDiagnostics() + ("target_source_position_seconds" to positionSeconds.toString()), ), @@ -1414,7 +1895,7 @@ open class PlaybackSessionManager( event = "seek_reanchored", appliedQuirkIds = next.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = next.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = next.context.output.outputRouteGeneration, + outputContextId = next.context.output.outputContextId, diagnostics = diagnostics + ("target_source_position_seconds" to positionSeconds.toString()), ), @@ -1427,6 +1908,8 @@ open class PlaybackSessionManager( playbackAttemptId = next.playbackAttemptId, planAttemptId = next.planAttemptId, planAttemptKey = next.planAttemptKey, + capabilities = next.capabilities, + clientPlaybackContext = next.context, ), ) } @@ -1437,7 +1920,10 @@ open class PlaybackSessionManager( "The server returned an incompatible seek re-anchor response.", ) is PlaybackV3Validation.ReplanRequired -> invalidSeekReanchorResponse( - "The server changed the player engine during seek re-anchoring.", + // Re-anchoring is not allowed to change the route, so a + // plan this client cannot execute means the server moved + // it off the route already playing. + "$UNEXECUTABLE_ROUTE_MESSAGE Re-anchoring may not change the playback route.", ) } } @@ -1473,18 +1959,26 @@ open class PlaybackSessionManager( if (candidate.selectedTracks != active.plan.selectedTracks) { return "The server changed the selected tracks during seek re-anchoring." } - if (!candidate.hasSameSeekReanchorBaseRoute(active.plan, active.context.output.outputRouteGeneration)) { + if (!candidate.hasSameSeekReanchorBaseRoute(active.plan)) { return "The server changed the playback route during seek re-anchoring." } return null } - private fun PlaybackPlanV3.hasSameSeekReanchorBaseRoute( - current: PlaybackPlanV3, - outputRouteGeneration: Long, - ): Boolean = - planAttemptKey(outputRouteGeneration) == current.planAttemptKey(outputRouteGeneration) && - engine == current.engine && + /** + * Mirrors the server's own re-anchor validator: a re-anchored plan may move + * the timeline, rotate signed URLs, and refresh the transport, but it may + * not change the route it describes. + * + * The attempt keys compared here are server-minted, so an unchanged pair is + * the server's own assertion that the recipe survived. The field comparison + * that follows is the client's independent check of what it actually + * renders, and the delivery class is what the two sides negotiate over — + * there is no engine name in the neutral contract to compare. + */ + private fun PlaybackPlanV3.hasSameSeekReanchorBaseRoute(current: PlaybackPlanV3): Boolean = + planAttemptKey == current.planAttemptKey && + delivery == current.delivery && stream.mimeType == current.stream.mimeType && stream.headerRefresh == current.stream.headerRefresh && effectiveRecipe == current.effectiveRecipe && @@ -1493,6 +1987,7 @@ open class PlaybackSessionManager( subtitle.trackId == current.subtitle.trackId && subtitle.artifact?.mimeType == current.subtitle.artifact?.mimeType && subtitle.artifact?.format == current.subtitle.artifact?.format && + subtitleFidelityPolicy == current.subtitleFidelityPolicy && transformations.toSet() == current.transformations.toSet() && appliedQuirks.toSet() == current.appliedQuirks.toSet() && runtimeCorrections.toSet() == current.runtimeCorrections.toSet() @@ -1589,24 +2084,33 @@ open class PlaybackSessionManager( } val network = networkEvidenceProvider.snapshot() - val attemptedKeys = (active.attemptedPlanKeys + active.planAttemptKey).distinct() + val cursor = active.serverPlanCursor + val (serverPlanId, serverPlanAttemptId, failedKey) = active.serverControlIdentity + val requestAttemptCount = cursor?.attemptCount ?: active.attemptCount + val attemptedKeys = ( + (cursor?.attemptedPlanKeys ?: active.attemptedPlanKeys) + failedKey + ).distinct() + val nextAttemptCount = requestAttemptCount + 1 + val nextAttemptId = UUID.randomUUID().toString() val request = PlaybackReplanRequestV3( + clientFeatures = playbackClientFeaturesV3(active.context), operation = SEEK_FAILURE_RECOVERY_V3_OPERATION, playbackAttemptId = active.playbackAttemptId, replanRequestId = UUID.randomUUID().toString(), - failedPlanId = active.serverPlanId, - planAttemptId = active.planAttemptId, - planAttemptKey = active.planAttemptKey, + failedPlanId = serverPlanId, + planAttemptId = serverPlanAttemptId, + planAttemptKey = failedKey, attemptedPlanKeys = attemptedKeys, - attemptCount = active.attemptCount, + localMutations = active.localMutations, + attemptCount = requestAttemptCount, qualityPreference = active.qualityPreference, positionSeconds = positionSeconds, - outputRouteGeneration = active.context.output.outputRouteGeneration, // Fresh snapshot, matching replanActiveVideoSession: this path lets // the server pick a different route, so session-start network // evidence would misinform that decision. metered = network.metered, bandwidthEstimateKbps = network.bandwidthEstimateKbps, + bandwidthCapKbps = active.bandwidthCapKbps, selectedTracks = active.plan.selectedTracks, failure = PlaybackFailureV3( classification = classification, @@ -1627,7 +2131,7 @@ open class PlaybackSessionManager( failureClassification = classification, appliedQuirkIds = active.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = active.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = active.context.output.outputRouteGeneration, + outputContextId = active.context.output.outputContextId, diagnostics = diagnostics + mapOfNotNull("decoder_name" to decoderName) + network.asRouteDiagnostics() + ("seek_recovery_position_seconds" to positionSeconds.toString()), @@ -1636,6 +2140,28 @@ open class PlaybackSessionManager( when (val result = playbackRepository.replanPlaybackV3(active.sessionId, request)) { is ApiResult.Success -> { + result.data.playbackPlan?.let { committedPlan -> + val committedKey = committedPlan.planAttemptKey + activeVideoAttempt.get() + ?.takeIf { it.sessionId == active.sessionId } + ?.let { live -> + activeVideoAttempt.compareAndSet( + live, + live.copy( + serverPlanCursor = ServerPlanCursor( + planId = committedPlan.planId, + planAttemptId = nextAttemptId, + planAttemptKey = committedKey, + attemptedPlanKeys = ( + attemptedKeys + + listOfNotNull(committedKey.takeIf(String::isNotBlank)) + ).distinct(), + attemptCount = nextAttemptCount, + ), + ), + ) + } + } if (SEEK_REANCHOR_V3_FEATURE !in result.data.serverFeatures) { return@withSettledVideoAttempt invalidSeekRecoveryResponse( "The server omitted the negotiated seek recovery feature from its response.", @@ -1652,9 +2178,7 @@ open class PlaybackSessionManager( if (mismatch != null) { return@withSettledVideoAttempt invalidSeekRecoveryResponse(mismatch) } - val nextKey = validated.plan.planAttemptKey( - active.context.output.outputRouteGeneration, - ) + val nextKey = validated.plan.planAttemptKey if (nextKey in attemptedKeys) { return@withSettledVideoAttempt ApiResult.Success( VideoSessionStartV3.Terminal( @@ -1664,7 +2188,6 @@ open class PlaybackSessionManager( ), ) } - val nextAttemptId = UUID.randomUUID().toString() val next = adoptSeekRecoveryPlan( expected = active, plan = validated.plan, @@ -1672,6 +2195,7 @@ open class PlaybackSessionManager( planAttemptId = nextAttemptId, planAttemptKey = nextKey, attemptedPlanKeys = attemptedKeys, + attemptCount = nextAttemptCount, ) if (next == null) { return@withSettledVideoAttempt ApiResult.Error( @@ -1680,7 +2204,7 @@ open class PlaybackSessionManager( message = "The active playback attempt changed during seek recovery.", ) } - PassthroughSuppressionRegistry.beginAttempt(nextKey) + passthroughSuppression.beginAttempt(nextKey) emitRouteEvent( PlaybackRouteEventV3( playbackAttemptId = next.playbackAttemptId, @@ -1692,7 +2216,7 @@ open class PlaybackSessionManager( fallbackReason = classification, appliedQuirkIds = next.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = next.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = next.context.output.outputRouteGeneration, + outputContextId = next.context.output.outputContextId, ), ) ApiResult.Success( @@ -1702,6 +2226,8 @@ open class PlaybackSessionManager( playbackAttemptId = next.playbackAttemptId, planAttemptId = next.planAttemptId, planAttemptKey = next.planAttemptKey, + capabilities = next.capabilities, + clientPlaybackContext = next.context, ), ) } @@ -1716,7 +2242,7 @@ open class PlaybackSessionManager( "The server returned an incompatible seek recovery response.", ) is PlaybackV3Validation.ReplanRequired -> invalidSeekRecoveryResponse( - "The server returned an unsupported player engine during seek recovery.", + UNEXECUTABLE_ROUTE_MESSAGE, ) } } @@ -1765,6 +2291,7 @@ open class PlaybackSessionManager( planAttemptId: String, planAttemptKey: String, attemptedPlanKeys: List, + attemptCount: Int, ): ActiveVideoAttempt? { val current = activeVideoAttempt.get() ?: return null if (current.playbackAttemptId != expected.playbackAttemptId || @@ -1783,8 +2310,8 @@ open class PlaybackSessionManager( planAttemptId = planAttemptId, planAttemptKey = planAttemptKey, localMutations = emptyList(), - attemptedPlanKeys = (current.attemptedPlanKeys + attemptedPlanKeys + planAttemptKey).distinct(), - attemptCount = current.attemptCount + 1, + attemptedPlanKeys = (attemptedPlanKeys + planAttemptKey).distinct(), + attemptCount = attemptCount, startedAtElapsedRealtimeMs = SystemClock.elapsedRealtime(), firstFrameReported = false, ) @@ -1831,7 +2358,7 @@ open class PlaybackSessionManager( event = event, appliedQuirkIds = active.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = active.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = active.context.output.outputRouteGeneration, + outputContextId = active.context.output.outputContextId, diagnostics = diagnostics, ), ) @@ -1854,7 +2381,7 @@ open class PlaybackSessionManager( event = "first_frame", appliedQuirkIds = active.plan.appliedQuirks.map { it.id }, quirkRegistryRevision = active.plan.appliedQuirks.firstOrNull()?.registryRevision, - outputRouteGeneration = active.context.output.outputRouteGeneration, + outputContextId = active.context.output.outputContextId, diagnostics = stats.firstFrameDiagnostics(firstFrameMs), ), ) @@ -1872,150 +2399,71 @@ open class PlaybackSessionManager( if (index == null) return null val effectiveFileId = active.plan.effectiveMediaFileId ?: active.fileId return selected?.takeIf { it.index == index } + ?: active.plan.subtitle.inventory + .takeIf { kind == "subtitle" } + ?.singleOrNull { it.combinedIndex == index } + ?.let { PlaybackTrackIdentityV3(it.trackId, index) } ?: PlaybackTrackIdentityV3(stableTrackId(effectiveFileId, kind, index), index) } - fun trySingleLocalPcmRetry(mime: String, channels: Int): Boolean { - val active = activeVideoAttempt.get() ?: return false - val mutation = "pcm:${mime.lowercase()}:${channels.coerceAtLeast(0)}" - if (active.localMutations.any { it.startsWith("pcm:") }) return false - val mutations = active.localMutations + mutation - val key = active.plan.planAttemptKey(active.context.output.outputRouteGeneration, mutations) - val next = active.copy( - planAttemptKey = key, - localMutations = mutations, - attemptedPlanKeys = (active.attemptedPlanKeys + key).distinct(), - ) - if (!activeVideoAttempt.compareAndSet(active, next)) return false - PassthroughSuppressionRegistry.beginAttempt(key) - return PassthroughSuppressionRegistry.suppressForSinglePcmRetry(mime, channels) - } - - fun recordTransportReopen(): Boolean { - val active = activeVideoAttempt.get() ?: return false - val mutation = "transport_reopen" - if (mutation in active.localMutations) return false - val mutations = active.localMutations + mutation - val key = active.plan.planAttemptKey(active.context.output.outputRouteGeneration, mutations) - val next = active.copy( - planAttemptKey = key, - localMutations = mutations, - attemptedPlanKeys = (active.attemptedPlanKeys + key).distinct(), - ) - if (!activeVideoAttempt.compareAndSet(active, next)) return false - PassthroughSuppressionRegistry.beginAttempt(key) - return true - } - /** - * Starts a new playback session for the given file. - * The server decides the play method (direct, remux, transcode). + * Records a client-applied route mutation on the active attempt. + * + * The mutation is NOT hashed here. Attempt keys are server-minted under the + * neutral v3 contract, so the client records what it changed locally and + * echoes it in `local_mutations` on the next replan; the server folds it + * into the keys it excludes. Returns the new attempt, or null when there is + * no active attempt, the mutation is already recorded, or another thread + * won the CAS. */ - open suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int? = null, - qualityPreference: String? = null, - startPosition: Double? = null, - disableProgressPersistence: Boolean = false, - ): ApiResult = startSessionInternal( - fileId = fileId, - profileId = profileId, - capabilities = capabilities, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = null, - qualityPreference = qualityPreference, - startPosition = startPosition, - clientPlaybackContext = null, - preserveDirectAudioSelection = false, - playMethod = null, - disableProgressPersistence = disableProgressPersistence, - ) - - suspend fun startSessionV2( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int? = null, - subtitleTrackIndex: Int? = null, - qualityPreference: String? = null, - startPosition: Double? = null, - clientPlaybackContext: ClientPlaybackContext? = null, - preserveDirectAudioSelection: Boolean = false, - playMethod: PlayMethod? = null, - seekableStreamsOnly: Boolean = false, - ): ApiResult = startSessionInternal( - fileId = fileId, - profileId = profileId, - capabilities = capabilities, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - qualityPreference = qualityPreference, - startPosition = startPosition, - clientPlaybackContext = clientPlaybackContext, - preserveDirectAudioSelection = preserveDirectAudioSelection, - playMethod = playMethod, - disableProgressPersistence = false, - seekableStreamsOnly = seekableStreamsOnly, - ) - - private suspend fun startSessionInternal( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - subtitleTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - clientPlaybackContext: ClientPlaybackContext?, - preserveDirectAudioSelection: Boolean, - playMethod: PlayMethod?, - disableProgressPersistence: Boolean, - seekableStreamsOnly: Boolean = false, - ): ApiResult { - Log.i( - TAG, - "startSession fileId=$fileId profileId=$profileId " + - "video=${capabilities.codecsVideo} audio=${capabilities.codecsAudio} " + - "containers=${capabilities.containers} max=${capabilities.maxResolution} " + - "hdr=${capabilities.hdr} hdrDetails=${capabilities.hdrDetails} " + - "passthrough=${capabilities.audioPassthrough} " + - "qualityPreference=$qualityPreference " + - "preserveDirectAudioSelection=$preserveDirectAudioSelection " + - "requestedPlayMethod=$playMethod", - ) - val result = playbackRepository.startPlayback( - fileId = fileId, - profileId = profileId, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - qualityPreference = qualityPreference, - startPosition = startPosition, - capabilities = capabilities, - clientPlaybackContext = clientPlaybackContext, - preserveDirectAudioSelection = preserveDirectAudioSelection, - playMethod = playMethod, - disableProgressPersistence = disableProgressPersistence, - seekableStreamsOnly = seekableStreamsOnly, - ) - when (result) { - is ApiResult.Success -> Log.i( - TAG, - "startSession -> playMethod=${result.data.playMethod} " + - "playbackInfo=${result.data.playbackInfo} " + - "plan=${result.data.playbackPlan?.planId}:${result.data.playbackPlan?.engine}", + private fun recordLocalMutation( + mutation: String, + refreshPassthroughSuppression: Boolean, + alreadyRecorded: (List) -> Boolean, + ): ActiveVideoAttempt? { + val active = activeVideoAttempt.get() ?: return null + if (alreadyRecorded(active.localMutations)) return null + val next = active.copy(localMutations = active.localMutations + mutation) + if (!activeVideoAttempt.compareAndSet(active, next)) return null + // The suppression registry only equality-compares an opaque scope token, + // so a locally-derived one is sufficient — and necessary, because the + // server-minted key does not change when the client mutates its own + // route. + if (refreshPassthroughSuppression) { + passthroughSuppression.beginAttempt( + "${next.planAttemptKey}#${next.localMutations.joinToString("|")}", ) - is ApiResult.Error -> Log.w(TAG, "startSession error: ${result.code} ${result.message}") - is ApiResult.NetworkError -> Log.w(TAG, "startSession network error: ${result.exception}") } - return result + return next } + fun trySingleLocalPcmRetry(mime: String, channels: Int): Boolean { + val mutation = "pcm:${mime.lowercase()}:${channels.coerceAtLeast(0)}" + recordLocalMutation(mutation, refreshPassthroughSuppression = true) { mutations -> + mutations.any { it.startsWith("pcm:") } + } + ?: return false + return passthroughSuppression.suppressForSinglePcmRetry(mime, channels) + } + + fun recordTransportReopen(): Boolean = + recordLocalMutation("transport_reopen", refreshPassthroughSuppression = false) { mutations -> + "transport_reopen" in mutations + } != null + companion object { private const val TAG = "PlaybackSessionMgr" private const val COMMITTED_SESSION_CLEANUP_ATTEMPTS = 2 + /** + * Ceiling on unconfirmed orphaned sessions kept for retry. + * + * Generous relative to how many sessions one viewing session produces, + * so it only bites when stops are persistently failing — the case where + * retrying an unbounded backlog on every content reset is pure cost. + */ + private const val MAX_RETAINED_ORPHANED_SESSIONS = 64 + /** * How long a content reset waits for a deferred publication to settle * before rolling it back itself. Comfortably above the 30s local-mount @@ -2044,7 +2492,37 @@ open class PlaybackSessionManager( "subtitle_track_changed", "quality_changed", "output_route_changed", + "subtitle_inventory_changed", ) + + /** + * The v3 replan operation a classification means. + * + * Track and quality changes are user intents, not failures, and the + * contract now has operations that say so — so the classification the + * player already computes selects the operation instead of every call + * site having to name both. `output_route_changed` deliberately stays + * failure recovery: the route the client was using genuinely stopped + * working, and the server should exclude it. + */ + private fun replanOperationForClassification(classification: String): String = when (classification) { + "audio_track_changed", "subtitle_track_changed", "subtitle_inventory_changed" -> + TRACK_CHANGE_V3_OPERATION + "quality_changed" -> QUALITY_CHANGE_V3_OPERATION + else -> FAILURE_RECOVERY_V3_OPERATION + } + + /** + * The server returned a structurally valid plan that names a client-side + * runtime correction or transformation this build cannot execute. + * + * Not a protocol mismatch: the neutral v3 contract has no engine field + * for the server to get wrong, so the only way a plan is unexecutable + * here is a capability this client does not have. + */ + internal const val UNEXECUTABLE_ROUTE_REASON = "unexecutable_client_route" + internal const val UNEXECUTABLE_ROUTE_MESSAGE = + "The server returned a playback route this client cannot execute." } /** @@ -2145,11 +2623,11 @@ open class PlaybackSessionManager( stopSessionsRetainingFailures(candidateSessionIds) if (sessionId != null) { videoAttemptMutex.withLock { - orphanedSessionIds += sessionId + rememberOrphanedSessionLocked(sessionId) } try { result = playbackRepository.stopPlayback(sessionId) - if (result is ApiResult.Success) { + if (result.isStopDischarged()) { videoAttemptMutex.withLock { orphanedSessionIds -= sessionId } @@ -2171,171 +2649,12 @@ open class PlaybackSessionManager( return requireNotNull(result) } - /** - * Requests transcoding with specific parameters. - * Used when switching quality mid-playback or when the server chose transcode - * and the encoding needs to be started explicitly. - */ - suspend fun startTranscode(request: TranscodeStartRequest): ApiResult = - playbackRepository.startTranscode(request) - /** Returns the current access token for stream authentication. */ suspend fun getAccessToken(): String? = tokenManager.getAccessToken() /** Returns the server base URL for resolving relative stream URLs. */ suspend fun getServerUrl(): String = tokenManager.getServerUrl() - enum class TranscodeMode { REMUX, FULL } - - /** - * Issue a `TranscodeStartRequest` for a fallback path — either because the - * server chose REMUX / TRANSCODE up front (`handleSessionStarted`) or - * because client-side preflight determined direct play was impossible - * ([PlaybackPreflightListener] in PR 8). Folds the resulting HLS URL back - * into a [PlaybackSessionResponse] so both VMs can treat the result like - * any other session start. - * - * Does **not** stop the caller's current session — ViewModels handle that - * alongside their state cleanup, which is the point they also tear down - * progress reporting. - */ - suspend fun startTranscodeFallback( - session: PlaybackSessionResponse, - seekSeconds: Double, - resolution: String, - mode: TranscodeMode, - audioTrackIndex: Int? = null, - subtitleTrackIndex: Int? = null, - ): ApiResult { - val isRemux = mode == TranscodeMode.REMUX - val request = TranscodeStartRequest( - sessionId = session.sessionId, - seekSeconds = seekSeconds, - targetResolution = if (isRemux) "" else resolution, - targetCodecVideo = if (isRemux) "copy" else "h264", - // REMUX copies audio to preserve passthrough codecs - // (EAC3/TrueHD/DTS). Forcing AAC clobbers the play-method - // decision. - targetCodecAudio = if (isRemux) "copy" else "aac", - targetBitrateKbps = if (isRemux) 0 else 8000, - segmentDuration = 2, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - subtitleBurnIn = shouldBurnStyledSubtitle( - isRemux = isRemux, - subtitleTrackIndex = subtitleTrackIndex, - subtitleCodec = session.playbackPlan?.source?.subtitleCodec, - ), - ) - Log.i( - TAG, - "startTranscodeFallback session=${session.sessionId} mode=$mode seekSeconds=$seekSeconds " + - "targetResolution=${request.targetResolution} " + - "targetCodecVideo=${request.targetCodecVideo} " + - "targetCodecAudio=${request.targetCodecAudio} " + - "targetBitrateKbps=${request.targetBitrateKbps} " + - "audioTrackIndex=$audioTrackIndex subtitleTrackIndex=$subtitleTrackIndex", - ) - return when (val r = playbackRepository.startTranscode(request)) { - is ApiResult.Success -> { - val tc = r.data - ApiResult.Success( - session.copy( - sessionId = tc.sessionId, - playMethod = if (isRemux) { - org.prairieserver.prairie.model.playback.PlayMethod.REMUX - } else { - org.prairieserver.prairie.model.playback.PlayMethod.TRANSCODE - }, - streamUrl = tc.manifestUrl, - durationSeconds = tc.durationSeconds ?: session.durationSeconds, - position = tc.playerStartSeconds, - playbackPlan = session.playbackPlan?.let { plan -> - plan.copy( - delivery = if (isRemux) { - PlaybackDelivery.SERVER_REMUX_HLS - } else { - PlaybackDelivery.SERVER_TRANSCODE_HLS - }, - engine = PlaybackEngineKind.MEDIA3_HLS, - routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE, - stream = PlaybackStreamRequest( - url = tc.manifestUrl, - streamType = "hls", - playMethod = if (isRemux) { - org.prairieserver.prairie.model.playback.PlayMethod.REMUX - } else { - org.prairieserver.prairie.model.playback.PlayMethod.TRANSCODE - }, - ), - timeline = PlaybackTimeline( - playerStartSeconds = tc.playerStartSeconds, - streamOriginSeconds = tc.streamOriginSeconds, - timelineOffsetSeconds = tc.timelineOffsetSeconds, - canSeekAnywhere = tc.canSeekAnywhere, - ), - degradationWarnings = plan.degradationWarnings + - org.prairieserver.prairie.model.playback.PlaybackDegradationWarning( - code = if (isRemux) { - "server_remux_fallback" - } else { - "server_transcode_fallback" - }, - message = if (isRemux) { - "Playback fell back to server remux." - } else { - "Playback fell back to server transcode." - }, - ), - ) - }, - ), - ) - } - is ApiResult.Error -> r - is ApiResult.NetworkError -> r - } - } - - suspend fun startTranscodeFallbackRecoveringMissingSession( - session: PlaybackSessionResponse, - seekSeconds: Double, - resolution: String, - mode: TranscodeMode, - audioTrackIndex: Int? = null, - subtitleTrackIndex: Int? = null, - renewSession: suspend () -> ApiResult, - ): ApiResult { - val first = startTranscodeFallback( - session = session, - seekSeconds = seekSeconds, - resolution = resolution, - mode = mode, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - ) - if (!first.isPlaybackSessionMissingError()) return first - - Log.w(TAG, "Fallback session missing; renewing playback session before retry") - return when (val renewed = renewSession()) { - is ApiResult.Success -> { - val retry = startTranscodeFallback( - session = renewed.data, - seekSeconds = seekSeconds, - resolution = resolution, - mode = mode, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - ) - if (retry !is ApiResult.Success) { - stopSession(renewed.data.sessionId) - } - retry - } - is ApiResult.Error -> renewed - is ApiResult.NetworkError -> renewed - } - } } internal fun ApiResult<*>.isPlaybackSessionMissingError(): Boolean { @@ -2343,16 +2662,3 @@ internal fun ApiResult<*>.isPlaybackSessionMissingError(): Boolean { return error.code == 404 && (error.error == "playback_session_not_found" || error.message == "Playback session not found") } - -/** - * A styled subtitle selected for a full server transcode is burned in. Remux - * has no video encode surface, and plain text stays client-rendered. - */ -internal fun shouldBurnStyledSubtitle( - isRemux: Boolean, - subtitleTrackIndex: Int?, - subtitleCodec: String?, -): Boolean = - !isRemux && - subtitleTrackIndex != null && - subtitleCodec?.trim()?.lowercase() in setOf("ass", "ssa") diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt new file mode 100644 index 000000000..2f95a742e --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackTeardownGate.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.common.player + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Serialises one player screen's teardown of the process-scoped + * [PlaybackSessionLifecycle] so it happens exactly once. + * + * A screen has several exit routes — an ordered stop awaited before navigation, + * an async Back/remote-stop, and a `ViewModel.onCleared()` fallback — and more + * than one can fire for the same exit. That is not merely redundant: every + * [PlaybackSessionLifecycle.stop] bumps `stopEpoch`, and a stop issued *after* + * the next screen has captured its ownership epoch causes that screen's + * adoption to be rejected ("Playback start was superseded"). On Android TV the + * deferred `onCleared()` stop landed exactly there and broke auto-advance on + * every episode transition. + * + * The gate is deliberately one-shot per screen. Once any route has taken + * ownership of teardown, the others must not touch the singleton lifecycle. + * + * Residual, accepted: if the underlying stop *and* its one handoff both fail + * unexpectedly, the claim stays taken and teardown is incomplete, leaving the + * server session to expire on its own timeout. That is the pre-existing + * behaviour for a failed stop and is not an adoption hazard — every route here + * names the session it is ending, and once the next session has been adopted a + * late stop for the old id returns at the ownership guard *before* touching + * `stopEpoch`, so it cannot supersede anyone. + */ +class PlaybackTeardownGate(private val lifecycle: PlaybackSessionLifecycle) { + + private val claimed = AtomicBoolean(false) + + /** True once some route has taken ownership of this screen's teardown. */ + val isClaimed: Boolean get() = claimed.get() + + /** + * Ordered teardown, awaited before navigating to the next item. + * + * If the stop fails or is cancelled the claim is *not* released: releasing + * it would leave teardown unowned whenever [stopDetached] has already run + * and skipped, because a flag reset neither notifies nor reschedules it. + * Ownership is handed to the lifecycle-owned tracked job instead, which + * outlives the screen and which a later start awaits through + * [PlaybackSessionLifecycle.acquireOwnershipEpoch]. + */ + suspend fun stopOrdered(expectedSessionId: String?) { + if (!claimed.compareAndSet(false, true)) return + try { + lifecycle.stop(expectedSessionId = expectedSessionId) + } catch (t: Throwable) { + lifecycle.stopAsync(expectedSessionId = expectedSessionId) + throw t + } + } + + /** + * Detached teardown: the Back/remote-stop route, and the `onCleared()` + * fallback for a screen that went away without any exit route running. + * Does nothing once teardown is already owned. + * + * Both go through [PlaybackSessionLifecycle.stopAsync] rather than a direct + * suspend stop. Nothing awaits either caller — `onCleared`'s settlement + * callback even wraps its body in `runCatching` — so a direct stop that + * threw would be swallowed with the claim already consumed and no owner + * left to retry. The tracked job cannot be abandoned that way, and it has + * the side benefit that a later start awaits it through + * [PlaybackSessionLifecycle.acquireOwnershipEpoch]. + */ + fun stopDetached(expectedSessionId: String?) { + if (claimed.compareAndSet(false, true)) { + lifecycle.stopAsync(expectedSessionId = expectedSessionId) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt index a3f34b667..41efef615 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt @@ -2,7 +2,6 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDelivery -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackRouteFamily @@ -14,6 +13,10 @@ import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTimeline import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SelectedPlaybackTracks +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_BURN_IN_ONLY +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily sealed interface VideoSessionStartV3 { data class Ready( @@ -22,6 +25,10 @@ sealed interface VideoSessionStartV3 { val playbackAttemptId: String, val planAttemptId: String, val planAttemptKey: String, + /** Exact evidence snapshot used to negotiate this plan. */ + val capabilities: org.prairieserver.prairie.model.playback.ClientCodecCapabilities, + /** Exact output/delivery context used to negotiate this plan. */ + val clientPlaybackContext: org.prairieserver.prairie.model.playback.ClientPlaybackContext, ) : VideoSessionStartV3 data class Terminal( @@ -39,6 +46,7 @@ internal fun PlaybackPlanV3.toSessionResponse( mediaFileId: Int, ): PlaybackSessionResponse { val effectiveFileId = effectiveMediaFileId ?: mediaFileId + val selectedSubtitleIndex = resolvedSelectedSubtitleIndex() val playMethod = when (delivery) { PlaybackDelivery.ORIGINAL_HTTP -> PlayMethod.DIRECT PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, @@ -47,9 +55,10 @@ internal fun PlaybackPlanV3.toSessionResponse( PlaybackDelivery.SERVER_TRANSCODE_HLS -> PlayMethod.TRANSCODE PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION -> PlayMethod.REMUX } - val subtitles = subtitle.artifact?.takeIf { + val selectedSubtitle = subtitle.artifact?.takeIf { subtitle.mode == PlaybackSubtitleModeV3.CONVERT || subtitle.mode == PlaybackSubtitleModeV3.RENDER }?.let { artifact -> + val artifactIndex = selectedSubtitleIndex ?: return@let null // A bitmap RENDER artifact describes the subtitle stream already // embedded in ORIGINAL_HTTP media. It is not a WebVTT sidecar: trying // to mount its descriptive `/subtitles/{index}.vtt` URL makes the @@ -58,10 +67,10 @@ internal fun PlaybackPlanV3.toSessionResponse( val rendersEmbeddedBitmap = subtitle.mode == PlaybackSubtitleModeV3.RENDER && delivery == PlaybackDelivery.ORIGINAL_HTTP && - isBitmapSubtitleCodecOrMime(artifact.format) + isBitmapSubtitleCodecFamily(artifact.format) listOf( PlayerSubtitleInfo( - index = selectedTracks.subtitle?.index ?: 0, + index = artifactIndex, codec = artifact.format, label = if (rendersEmbeddedBitmap) null else "Server subtitle", source = if (rendersEmbeddedBitmap) "embedded" else "server_artifact", @@ -69,6 +78,48 @@ internal fun PlaybackPlanV3.toSessionResponse( ), ) } + // Neutral v3 publishes the complete subtitle inventory on every plan, + // including plans with subtitles off. Project it into native phone/TV UI + // state so both menus retain every authoritative ordinal. The player mount + // separately filters this inventory to the artifact selected by the active + // plan; inventory URLs are choices, not a preload list. During a burn-in + // plan the URLs are still deliberately blanked so no caller can mount a + // sidecar over captions already baked into the video. + val inventorySubtitles = subtitle.inventory.asSequence() + .filter { it.combinedIndex >= 0 && it.trackId.isNotBlank() } + .filter { + it.delivery == SUBTITLE_DELIVERY_SIDECAR || + it.delivery == SUBTITLE_DELIVERY_BURN_IN_ONLY + } + .map { item -> + PlayerSubtitleInfo( + index = item.combinedIndex, + language = item.language, + codec = item.codec, + label = item.label, + source = item.source, + forced = item.forced, + url = if ( + subtitle.mode != PlaybackSubtitleModeV3.BURN_IN && + item.delivery == SUBTITLE_DELIVERY_SIDECAR + ) item.url.orEmpty() else "", + catalogLabel = item.label, + catalogSource = item.source, + isDefault = item.isDefault, + serverTrackId = item.trackId, + serverDelivery = item.delivery.takeIf { + it == SUBTITLE_DELIVERY_SIDECAR || + it == SUBTITLE_DELIVERY_BURN_IN_ONLY + }, + ) + } + .distinctBy(PlayerSubtitleInfo::index) + .sortedBy(PlayerSubtitleInfo::index) + .toList() + val plannedSubtitles = inventorySubtitles + val plannedIndexes = plannedSubtitles.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) + val subtitles = (plannedSubtitles + selectedSubtitle.orEmpty().filterNot { it.index in plannedIndexes }) + .takeIf(List::isNotEmpty) val routeFamily = when (delivery) { PlaybackDelivery.ORIGINAL_HTTP -> PlaybackRouteFamily.PLATFORM_NATIVE PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, @@ -81,7 +132,6 @@ internal fun PlaybackPlanV3.toSessionResponse( planId = planId, protocolVersion = protocolVersion, delivery = delivery, - engine = engine, routeFamily = routeFamily, stream = PlaybackStreamRequest( url = stream.url, @@ -103,7 +153,7 @@ internal fun PlaybackPlanV3.toSessionResponse( ), selectedTracks = SelectedPlaybackTracks( audioIndex = selectedTracks.audio?.index, - subtitleIndex = selectedTracks.subtitle?.index, + subtitleIndex = selectedSubtitleIndex, ), source = PlaybackSourceMetadata( mediaFileId = effectiveFileId, @@ -121,6 +171,7 @@ internal fun PlaybackPlanV3.toSessionResponse( transformations = transformations, appliedQuirks = appliedQuirks, runtimeCorrections = runtimeCorrections, + availableQualities = availableQualities, degradationWarnings = degradationWarnings, decisionTrace = listOf(decisionReason), requestedMediaFileId = requestedMediaFileId ?: mediaFileId, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt index 6abcb2691..2fb6802d3 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt @@ -1,11 +1,14 @@ package org.prairieserver.prairie.common.player +import android.util.Log import androidx.media3.common.C +import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.LoadControl import androidx.media3.exoplayer.trackselection.ExoTrackSelection import androidx.media3.exoplayer.upstream.DefaultAllocator +import org.prairieserver.prairie.player.DolbyVisionDetection /** * Media3 load control with a bitrate-scaled, heap-bounded allocation target. @@ -34,38 +37,231 @@ class PrairieLoadControl( 0, false, ) { + @Volatile private var depthMs: Int = policy.minBufferMs + + /** The forward buffer the memory budget currently affords, in ms. */ + internal fun currentDepthMs(): Int = depthMs + override fun calculateTargetBufferBytes( parameters: LoadControl.Parameters, trackSelections: Array, ): Int { - val selectedBitrateBps = trackSelections.sumOf { selection -> - selection?.selectedBitrateBps() ?: 0L - }.takeIf { it > 0L } - val fallback = super.calculateTargetBufferBytes(parameters, trackSelections) - return calculateBitrateTargetBufferBytes( - selectedBitrateBps = selectedBitrateBps, - desiredForwardBufferMs = policy.minBufferMs, + val sizingTracks = trackSelections.mapNotNull { selection -> + selection?.let { + val format = it.selectedFormat + val trackType = MimeTypes.getTrackType(format.sampleMimeType) + if (trackType == C.TRACK_TYPE_VIDEO || trackType == C.TRACK_TYPE_AUDIO) { + BufferSizingTrackBitrates( + averageBitrateBps = format.averageBitrate, + peakBitrateBps = format.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + isDolbyVision = isDolbyVisionBufferTrack( + sampleMimeType = format.sampleMimeType, + codecs = format.codecs, + ), + ) + } else { + null + } + } + } + val selectedBitrateBps = selectBufferSizingBitrateBps(sizingTracks) + val hasDolbyVision = sizingTracks.any(BufferSizingTrackBitrates::isDolbyVision) + val budgetBytes = playbackBufferBudgetBytes( + baseBudgetBytes = policy.targetBufferBytes, + hasDolbyVision = hasDolbyVision, minimumBytes = MIN_TARGET_BUFFER_BYTES, - maximumBytes = policy.targetBufferBytes, - unknownBitrateFallbackBytes = fallback, ) - } - - private fun ExoTrackSelection.selectedBitrateBps(): Long { - val format = selectedFormat - return listOf( - latestBitrateEstimate, - format.averageBitrate.toLong(), - format.peakBitrate.toLong(), - format.bitrate.toLong(), - ).maxOrNull()?.coerceAtLeast(0L) ?: 0L + val fallback = super.calculateTargetBufferBytes(parameters, trackSelections) + val result = + computeBufferSizing( + selectedBitrateBps = selectedBitrateBps, + desiredDepthMs = policy.minBufferMs, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = budgetBytes, + minimumBytes = MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallback, + ) + depthMs = result.depth.ms + Log.d( + TAG, + "target_bytes=${result.target.bytes} budget_bytes=$budgetBytes " + + "depth_ms=${result.depth.ms} dolby_vision=$hasDolbyVision", + ) + return result.target.bytes } companion object { + private const val TAG = "PrairieLoadControl" internal const val MIN_TARGET_BUFFER_BYTES = 16 * 1024 * 1024 } } +internal fun isDolbyVisionBufferTrack( + sampleMimeType: String?, + codecs: String?, +): Boolean = sampleMimeType == MimeTypes.VIDEO_DOLBY_VISION || + DolbyVisionDetection.isDolbyVision(videoCodec = codecs) + +internal data class BufferSizingTrackBitrates( + val averageBitrateBps: Int, + val peakBitrateBps: Int, + val latestNetworkEstimateBps: Long, + val isDolbyVision: Boolean = false, +) + +/** + * Leaves extra Java-heap headroom while decoding Dolby Vision. + * + * Media3's allocator target is not the process's complete playback cost. The + * extractor, codec bridge and OkHttp all need live Java allocations beside + * that target. A real Shield with a 192 MiB growth limit reached 192/192 MiB + * and crashed while the ordinary half-heap policy allowed a 96 MiB target for + * a DV profile-5 stream. Halving only the Dolby Vision target keeps the + * established buffer policy for every other route while leaving the decoder + * path enough room to keep reading and reporting playback. + */ +internal fun playbackBufferBudgetBytes( + baseBudgetBytes: Int, + hasDolbyVision: Boolean, + minimumBytes: Int, +): Int { + require(baseBudgetBytes > 0) + require(minimumBytes > 0) + if (!hasDolbyVision) return baseBudgetBytes + return (baseBudgetBytes / DOLBY_VISION_BUDGET_DIVISOR) + .coerceAtLeast(minimumBytes.coerceAtMost(baseBudgetBytes)) +} + +private const val DOLBY_VISION_BUDGET_DIVISOR = 2 + +internal fun selectBufferSizingBitrateBps( + tracks: List, +): Long? { + if (tracks.isEmpty()) return null + val mediaBitrates = tracks.map { track -> + track.averageBitrateBps.takeIf { it > 0 }?.toLong() + ?: track.peakBitrateBps.takeIf { it > 0 }?.toLong() + } + val knownMediaBitrate = mediaBitrates.filterNotNull().sum() + if (mediaBitrates.all { it != null }) return knownMediaBitrate + + // A partial sum is not the stream bitrate. Single-rendition HLS commonly + // carries no video BANDWIDTH metadata while its AAC track does expose a + // bitrate. Treating that audio-only number as the complete route sized an + // 84 Mbps 4K stream to the 16 MiB floor: one segment filled the allocator, + // DefaultLoadControl stopped loading, and playback remained buffering at + // two seconds even though the server had produced the following segments. + // Use one live estimate for the unknown media portion; when it is not yet + // available, keep the result unknown so Media3's renderer-aware fallback + // controls the target instead of an incomplete sum. + val unknownMediaEstimate = tracks + .filterIndexed { index, _ -> mediaBitrates[index] == null } + .maxOfOrNull { it.latestNetworkEstimateBps } + ?.takeIf { it > 0L } + ?: return null + return saturatingAdd(knownMediaBitrate, unknownMediaEstimate) +} + +private fun saturatingAdd(left: Long, right: Long): Long = + if (Long.MAX_VALUE - left < right) Long.MAX_VALUE else left + right + +/** + * The forward buffer this device can actually hold at this bitrate. + * + * Memory is finite, so a high-bitrate stream genuinely cannot be buffered as + * deeply as a low-bitrate one. Computing that reduction here — rather than + * letting the byte clamp truncate the buffer wherever it happens to land — + * means the resulting depth is a number the code chose and can be reasoned + * about, and it keeps maxBufferMs one idle window above a depth that is real. + * + * The budget is authoritative: when it affords less than [minimumDepthMs], + * that shortfall is reported honestly rather than padded up to a floor the + * loader cannot actually hold — a false-but-round number is worse than an + * honest one `currentDepthMs()` can be trusted to reflect. [minimumDepthMs] + * only bounds the *unknown-bitrate* branch below, where there is no bitrate + * to derive a number from at all. + * + * The division by 115/100 mirrors the same overhead margin + * [calculateBitrateTargetBufferBytes] multiplies back in when it turns a + * depth into bytes. Without it, a budget-derived depth still produces a byte + * figure that overshoots the budget once that margin is applied, silently + * clamps back down to the ceiling, and erases the depth's effect on the byte + * target — the two must agree, or reducing the depth changes nothing. + */ +internal fun affordableDepthMs( + desiredDepthMs: Int, + selectedBitrateBps: Long?, + budgetBytes: Int, + minimumDepthMs: Int, +): Int { + val bitrate = selectedBitrateBps?.takeIf { it > 0L } + ?: return desiredDepthMs.coerceAtLeast(minimumDepthMs) + val affordableMs = budgetBytes.toLong() * 8L * 1_000L * 100L / (bitrate * 115L) + return affordableMs.coerceAtMost(desiredDepthMs.toLong()).toInt() +} + +/** A forward-buffer depth, in milliseconds. Wrapped so it cannot be confused with [BufferTargetBytes]. */ +@JvmInline +internal value class BufferDepthMs(val ms: Int) + +/** A load-control byte target. Wrapped so it cannot be confused with [BufferDepthMs]. */ +@JvmInline +internal value class BufferTargetBytes(val bytes: Int) + +/** + * The composed result of sizing the buffer: the depth chosen and the bytes it + * maps to. Both are wrapped value classes rather than bare `Int`s so that + * assigning the wrong one to the wrong destination — e.g. storing the byte + * target where the depth belongs — is a compile error, not a bug only a test + * exercising the Media3 override could catch. + */ +internal data class BufferSizingResult(val depth: BufferDepthMs, val target: BufferTargetBytes) + +/** + * Composes [affordableDepthMs] and [calculateBitrateTargetBufferBytes] into the + * single decision `calculateTargetBufferBytes` needs: how deep a buffer the + * budget affords, and how many bytes that depth costs at this bitrate. + * + * Kept separate from the Media3 override so it can be tested directly without + * constructing track selections — the override is a thin adapter over this. + */ +internal fun computeBufferSizing( + selectedBitrateBps: Long?, + desiredDepthMs: Int, + minimumDepthMs: Int, + budgetBytes: Int, + minimumBytes: Int, + unknownBitrateFallbackBytes: Int, +): BufferSizingResult { + val depthMs = + affordableDepthMs( + desiredDepthMs = desiredDepthMs, + selectedBitrateBps = selectedBitrateBps, + budgetBytes = budgetBytes, + minimumDepthMs = minimumDepthMs, + ) + val targetBytes = + calculateBitrateTargetBufferBytes( + selectedBitrateBps = selectedBitrateBps, + desiredForwardBufferMs = depthMs, + // calculateBitrateTargetBufferBytes requires maximumBytes >= + // minimumBytes. That holds today only because + // MIN_MEMORY_BUDGET_BYTES and MIN_TARGET_BUFFER_BYTES are both + // exactly 16 MiB — a coincidence a future change to either + // constant could break, throwing IllegalArgumentException on the + // playback thread during track selection. The relation is + // restored by lowering the floor rather than raising the ceiling, + // so the memory budget stays the binding limit: a device whose + // budget is under the nominal floor gets a smaller buffer, never + // one that overruns the heap it was allowed. + minimumBytes = minimumBytes.coerceIn(1, budgetBytes.coerceAtLeast(1)), + maximumBytes = budgetBytes.coerceAtLeast(1), + unknownBitrateFallbackBytes = unknownBitrateFallbackBytes, + ) + return BufferSizingResult(depth = BufferDepthMs(depthMs), target = BufferTargetBytes(targetBytes)) +} + internal fun calculateBitrateTargetBufferBytes( selectedBitrateBps: Long?, desiredForwardBufferMs: Int, @@ -78,7 +274,14 @@ internal fun calculateBitrateTargetBufferBytes( val desiredBytes = selectedBitrateBps?.takeIf { it > 0L }?.let { bitrate -> // 15% allows for container/segment overhead and ordinary bitrate // variance without turning a stream's nominal bitrate into a promise. - (bitrate * desiredForwardBufferMs.toLong() * 115L) / (8L * 1_000L * 100L) + try { + Math.multiplyExact( + Math.multiplyExact(bitrate, desiredForwardBufferMs.toLong()), + 115L, + ) / (8L * 1_000L * 100L) + } catch (_: ArithmeticException) { + Long.MAX_VALUE + } } ?: unknownBitrateFallbackBytes.toLong() return desiredBytes.coerceIn(minimumBytes.toLong(), maximumBytes.toLong()).toInt() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieMediaSessionBitmapLoader.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieMediaSessionBitmapLoader.kt new file mode 100644 index 000000000..21f0e8213 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieMediaSessionBitmapLoader.kt @@ -0,0 +1,76 @@ +package org.prairieserver.prairie.common.player + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import androidx.media3.common.util.BitmapLoader +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import coil3.imageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.toBitmap +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import kotlinx.coroutines.runBlocking +import java.io.Closeable +import java.io.IOException +import java.util.concurrent.Executors + +/** + * Loads Media3 notification artwork through Silo's process-wide Coil loader. + * + * Media3's default bitmap loader opens remote artwork with a bare + * `HttpURLConnection`. That bypasses the image pipeline used everywhere else + * in the app and can leave Android's media notification with a null large + * icon even while the same backdrop renders in Compose. Reusing Coil gives + * Now Playing the same network behavior and disk cache as the visible UI. + */ +@UnstableApi +class PrairieMediaSessionBitmapLoader(context: Context) : BitmapLoader, Closeable { + private val appContext = context.applicationContext + private val imageLoader = appContext.imageLoader + private val executor = MoreExecutors.listeningDecorator( + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "silo-media-artwork").apply { isDaemon = true } + }, + ) + + override fun supportsMimeType(mimeType: String): Boolean = + Util.isBitmapFactorySupportedMimeType(mimeType) + + override fun decodeBitmap(data: ByteArray): ListenableFuture = executor.submit { + BitmapFactory.decodeByteArray(data, 0, data.size) + ?: throw IOException("Prairie media artwork data could not be decoded") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = executor.submit { + runBlocking { + val result = imageLoader.execute( + ImageRequest.Builder(appContext) + .data(uri) + .size(MAX_ARTWORK_SIZE_PX, MAX_ARTWORK_SIZE_PX) + .build(), + ) + if (result !is SuccessResult) { + throw IOException("Prairie media artwork could not be loaded") + } + val bitmap = result.image.toBitmap() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) { + bitmap.copy(Bitmap.Config.ARGB_8888, false) + } else { + bitmap + } + } + } + + override fun close() { + executor.shutdownNow() + } + + private companion object { + const val MAX_ARTWORK_SIZE_PX = 1_024 + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt index a63309431..ec691eee8 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt @@ -1,8 +1,8 @@ package org.prairieserver.prairie.common.player import android.content.Intent +import android.os.Bundle import androidx.media3.common.C -import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.prairieserver.prairie.common.BuildConfig @@ -87,6 +86,34 @@ class PrairiePlaybackService : MediaSessionService() { } return true } + + /** + * True when [connectionHints] belong to the synthetic caller Media3 + * fabricates for a media-button event that started the service + * (`MediaSessionService.createFallbackMediaButtonCaller`). It is not a + * real controller — it exists only so [onGetSession] can accept or + * refuse being cold-started by a transport key. + */ + internal fun isMediaButtonFallbackCaller(connectionHints: Bundle): Boolean = + connectionHints.getString( + MediaSessionService.CONNECTION_HINT_KEY_CONTROLLER_INFO_TYPE, + ) == Intent.ACTION_MEDIA_BUTTON + + /** + * Pure decision shared by both start-path guards below: the service has + * nothing it could possibly act on when no media is queued, it is not + * already running as a foreground playback service, and no controller is + * connected to it. That combination only occurs when something outside + * the app cold-started us — a remote key or a stale PendingIntent — and + * it is the state in which the service must terminate rather than sit + * idle waiting for a `startForeground()` that will never come. + */ + internal fun hasNothingToServe( + queuedMediaItemCount: Int, + isPlaybackOngoing: Boolean, + connectedControllerCount: Int, + ): Boolean = + queuedMediaItemCount == 0 && !isPlaybackOngoing && connectedControllerCount == 0 } private val playerFactory: PrairiePlayerFactory by inject() @@ -97,6 +124,7 @@ class PrairiePlaybackService : MediaSessionService() { private val subtitleOffsetHolder: SubtitleOffsetHolder by inject() private var mediaSession: MediaSession? = null + private var mediaSessionBitmapLoader: PrairieMediaSessionBitmapLoader? = null private lateinit var scope: CoroutineScope private var positionJob: Job? = null private var audioSyncJob: Job? = null @@ -105,13 +133,6 @@ class PrairiePlaybackService : MediaSessionService() { // The sole Media3 player owned by this service. @Volatile private var activePlayer: Player? = null - private val activeContentId = MutableStateFlow(null) - private val contentIdListener = object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - activeContentId.value = mediaItem?.mediaId?.takeIf(String::isNotBlank) - } - } - private val _positionMs = MutableStateFlow(0L) /** @@ -132,8 +153,6 @@ class PrairiePlaybackService : MediaSessionService() { player.addAnalyticsListener(analyticsListener) } activePlayer = player - player.addListener(contentIdListener) - activeContentId.value = player.currentMediaItem?.mediaId?.takeIf(String::isNotBlank) activePlayerHolder.set(player) val count = playerInstanceCount.incrementAndGet() android.util.Log.i( @@ -150,7 +169,11 @@ class PrairiePlaybackService : MediaSessionService() { "extension on classpath = ${FfmpegAudioSupport.isAvailable()}", ) - mediaSession = MediaSession.Builder(this, player).build() + val bitmapLoader = PrairieMediaSessionBitmapLoader(this) + mediaSessionBitmapLoader = bitmapLoader + mediaSession = MediaSession.Builder(this, player) + .setBitmapLoader(bitmapLoader) + .build() positionJob = scope.launch { while (isActive) { @@ -177,16 +200,14 @@ class PrairiePlaybackService : MediaSessionService() { } } - // Mirror the per-profile SubtitleSyncMs preference into the active + // Mirror the per-device SubtitleSyncMs preference into the active // SubtitleOffsetHolder. The libass renderer reads this value live; // Media3 text sidecars are commonly parsed up front, so changing the // holder alone cannot retime their already-built cue timestamps. // Reprepare at the same position to rebuild those cues while preserving // play/pause intent (the libass clock remains continuous across it). - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) subtitleSyncJob = scope.launch { - activeContentId - .flatMapLatest(playerSettingsStore::subtitleSyncMsFor) + playerSettingsStore.subtitleSyncMsFlow .distinctUntilChanged() .collect { offsetMs -> val previous = subtitleOffsetHolder.getOffsetMs() @@ -225,11 +246,86 @@ class PrairiePlaybackService : MediaSessionService() { private fun createPlaybackPlayer(): Player = playerFactory.createPlayer() - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = - mediaSession + /** + * Live read of [hasNothingToServe] against the service's current state. + * `isPlaybackOngoing()` is Media3's own "am I a running foreground playback + * service" flag, so this is false for every state reached through normal + * playback. + */ + private fun hasNothingToServeNow(): Boolean = hasNothingToServe( + queuedMediaItemCount = (activePlayer ?: mediaSession?.player)?.mediaItemCount ?: 0, + isPlaybackOngoing = isPlaybackOngoing(), + connectedControllerCount = mediaSession?.connectedControllers?.size ?: 0, + ) + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + // Because our manifest advertises `MediaSessionService.SERVICE_INTERFACE` + // and declares no MediaButtonReceiver, Media3 registers this service as + // the session's media-button target using + // `PendingIntent.getForegroundService()` (MediaSessionLegacyStub). A + // transport key on a TV remote is therefore delivered by the system as + // `startForegroundService(PrairiePlaybackService)` even when our process is + // cold — which arms the platform's 10-second "call startForeground() or + // be killed" watchdog (ActiveServices.SERVICE_START_FOREGROUND_TIMEOUT). + // + // Media3 already guards that case, but only through this method: when + // onGetSession() refuses the synthetic media-button caller it runs its + // own `stopSelfSafely()`, which posts a throwaway foreground + // notification, immediately drops it again and stops the service. That + // is the only shutdown sequence that is legal for a + // startForegroundService() launch — a bare stopSelf() would be killed by + // the same watchdog. + // + // We used to return the session unconditionally, so that guard never + // ran. The freshly built player was idle with an empty timeline, + // MediaNotificationManager.shouldShowNotification() bails out on an empty + // timeline, nothing ever called startForeground(), and ten seconds later + // the watchdog killed the whole app with RemoteServiceException — + // observed twice on a Shield (API 30, Media3 1.10.1) while the user was + // pressing remote keys on the sign-in screen. + // + // Refusing here is safe for real playback: once a session has been added + // to the service Media3 resolves media buttons via getSessionByUri() and + // never calls onGetSession() at all, so this branch is unreachable while + // anything is playing. Silo also implements no + // `MediaSession.Callback.onPlaybackResumption`, so a cold transport key + // has genuinely nothing to resume — declining it costs no product + // behaviour. Every other caller (the phone/TV player screens, system and + // Assistant controllers) still gets the session unconditionally. + if (isMediaButtonFallbackCaller(controllerInfo.connectionHints) && hasNothingToServeNow()) { + android.util.Log.i( + TAG, + "Declining media-button cold start: nothing queued to play", + ) + return null + } + return mediaSession + } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (dispatchPictureInPictureAction(intent, activePlayer ?: mediaSession?.player)) { + // PiP transport actions reach us through `PendingIntent.getService()` + // (PrairiePictureInPictureCoordinator), i.e. a plain startService(), so + // unlike the media-button PendingIntent above this branch does not arm + // the start-foreground watchdog and returning without calling + // startForeground() cannot crash us. Keep it that way: switching that + // PendingIntent to getForegroundService() would make this path fatal in + // exactly the way onGetSession() documents. + // + // It can still cold-start the process from a PiP window that outlived + // us. PipActionCapability regenerates its token per process, so a stale + // intent fails authorisation, dispatch is refused, and we would be left + // running an idle service and player forever. Terminate instead. + // pauseAllPlayersAndStopSelf() is Media3's own termination path and is + // documented as safe only while playback is not ongoing, which is + // precisely what hasNothingToServeNow() establishes. + if (hasNothingToServeNow()) { + android.util.Log.i( + TAG, + "Stopping service after unusable PiP action: nothing queued to play", + ) + pauseAllPlayersAndStopSelf() + } return START_STICKY } return super.onStartCommand(intent, flags, startId) @@ -259,6 +355,8 @@ class PrairiePlaybackService : MediaSessionService() { release() } mediaSession = null + mediaSessionBitmapLoader?.close() + mediaSessionBitmapLoader = null activePlayer = null activePlayerHolder.set(null) val count = playerInstanceCount.decrementAndGet() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt index 279e6b54d..68037890c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt @@ -44,7 +44,10 @@ import org.prairieserver.prairie.common.player.audio.DelayAudioProcessor import org.prairieserver.prairie.common.player.audio.PassthroughSuppressingAudioSink import org.prairieserver.prairie.common.player.subtitle.OffsetSubtitleParserFactory import org.prairieserver.prairie.common.player.subtitle.PgsSupExtractor +import org.prairieserver.prairie.common.player.subtitle.SidecarPlaybackFloor +import org.prairieserver.prairie.common.player.subtitle.SidecarSubtitleMediaSource import org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder +import org.prairieserver.prairie.common.player.subtitle.StreamingWebvttExtractor import org.prairieserver.prairie.common.player.video.PrairieMediaCodecVideoRenderer import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionState import org.prairieserver.prairie.libass.LibassBridge @@ -311,15 +314,11 @@ class PrairiePlayerFactory( loadErrorHandlingPolicy = mediaLoadErrorHandlingPolicy, ) - // Staged buffer: start once a modest cushion is ready, wait longer - // after an actual stall, and let playback grow a deeper forward - // buffer in the background. A finite byte cap lets low-bitrate - // streams grow toward the time limit while preventing high-bitrate - // remuxes from filling the app heap on memory-constrained TVs. - val bufferPolicy = PlaybackBufferPolicy.forMode( - PlaybackBufferMode.Balanced, - playbackBufferDeviceProfile(), - ) + // Start on a small cushion and keep filling in the background. Depth is + // bounded by the device's memory budget in PrairieLoadControl; the gap + // between min and max is fixed so the connection is never idle long + // enough for an upstream proxy to close it. + val bufferPolicy = PlaybackBufferPolicy.forConditions(playbackBufferDeviceProfile()) val loadControl = PrairieLoadControl(bufferPolicy) val builder = ExoPlayer.Builder(context, renderersFactory) @@ -379,6 +378,11 @@ class PrairiePlayerFactory( * Apply capability-aware track selection presets to [player]. Call at * player construction and again whenever [AudioPassthroughCapabilities] * changes (HDMI hot-plug, BT pair, user-toggled "force Atmos" setting). + * + * Returns whether parameters were actually assigned. A skip — teardown + * guard or no-op parameters — must be visible to callers that track + * "presets have been applied once", or a skipped first run silently + * reclassifies the real first application as a later change. */ fun applyTrackSelectionPresets( player: Player, @@ -387,16 +391,34 @@ class PrairiePlayerFactory( preferredAudioLanguage: String? = null, preferredTextLanguage: String? = null, hdrEnabled: Boolean = true, - ) { + ): Boolean { + // ExoPlayer resolves a track reselection by seeking the current media + // period. With no media period — idle, empty timeline, or torn down + // while this was in flight — that path dereferences a null holder and + // kills playback outright: + // + // NullPointerException: MediaPeriodHolder.info + // at ExoPlayerImplInternal.seekToCurrentPosition + // at ExoPlayerImplInternal.reselectTracksInternalAndSeek + // + // Capability changes are exactly what lands here at the wrong moment: + // an HDMI route drop fires this while the screen is being left, so the + // player is already past the point of having anything to reselect. + // Presets are re-applied on the next construction anyway, so skipping + // costs nothing. + if (shouldSkipTrackReselection(player.playbackState, player.currentTimeline.isEmpty)) return false + val base = player.trackSelectionParameters val next = if (isTv) { + // preferredTextLanguage is deliberately NOT forwarded on TV: the + // subtitle transaction adapter is the only authority that may + // enable a text track there (see TrackSelectionPresets.buildTvParameters). TrackSelectionPresets.buildTvParameters( context = context, base = base, audioCaps = audioCaps, displayHdr = displayHdr, preferredAudioLanguage = preferredAudioLanguage, - preferredTextLanguage = preferredTextLanguage, allowHdr = hdrEnabled, ) } else { @@ -410,13 +432,14 @@ class PrairiePlayerFactory( ) } player.trackSelectionParameters = next + return true } /** * Build a [MediaItem] the player can consume directly via `setMediaItem`. * The MIME type hint on the item is what lets [DefaultMediaSourceFactory] * pick HLS vs. progressive without requiring the stream URL to carry the - * right extension — Prairie's transcode URLs don't always end in .m3u8. + * right extension — Silo's transcode URLs don't always end in .m3u8. * * Sidecar subtitles are attached via `setSubtitleConfigurations`; the * selected media source factory wires them through a merging source. @@ -623,18 +646,30 @@ class PrairiePlayerFactory( subtitleParserFactory.getCueReplacementBehavior(baseFormat), ) .build() - val extractorsFactory = if (configuration.mimeType == MimeTypes.APPLICATION_PGS) { - ExtractorsFactory { + // Shared with the non-gating wrapper below: it publishes the live + // position, the extractor treats anything before it as history. + val playbackFloor = SidecarPlaybackFloor() + val extractorsFactory = when (configuration.mimeType) { + MimeTypes.APPLICATION_PGS -> ExtractorsFactory { arrayOf( PgsSupExtractor( subtitleParserFactory, subtitleOffsetProvider, outputFormat, + playbackFloor::get, ), ) } - } else { - ExtractorsFactory { + MimeTypes.TEXT_VTT -> ExtractorsFactory { + arrayOf( + StreamingWebvttExtractor( + subtitleParserFactory.create(outputFormat), + outputFormat, + MAX_SUBTITLE_BYTES, + ), + ) + } + else -> ExtractorsFactory { arrayOf( SubtitleExtractor( subtitleParserFactory.create(outputFormat), @@ -643,7 +678,12 @@ class PrairiePlayerFactory( ) } } - return ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + val subtitleDataSourceFactory = if (configuration.mimeType in replayableTextSubtitleMimeTypes) { + ReplayableSubtitleDataSourceFactory(dataSourceFactory) + } else { + dataSourceFactory + } + val progressive = ProgressiveMediaSource.Factory(subtitleDataSourceFactory, extractorsFactory) .setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) .createMediaSource( MediaItem.Builder() @@ -651,8 +691,20 @@ class PrairiePlayerFactory( .setMimeType(configuration.mimeType) .build(), ) + // A sidecar must not decide when playback starts or what loads + // next — left as a plain merged child it starves the video until + // its own download reaches the resume point. See the wrapper. + return SidecarSubtitleMediaSource(progressive, playbackFloor) } } + + private companion object { + val replayableTextSubtitleMimeTypes = setOf( + MimeTypes.TEXT_SSA, + MimeTypes.APPLICATION_SUBRIP, + MimeTypes.APPLICATION_TTML, + ) + } } /** @@ -722,3 +774,17 @@ internal fun mediaItemMimeType( PlayMethod.DIRECT -> videoContainerMimeType(container) } } + +/** + * Whether a track reselection must be withheld from the player. + * + * ExoPlayer applies a reselection by seeking the current media period. With no + * media period — idle, or an empty timeline — that seek dereferences a null + * holder and ends playback with an ExoPlaybackException rather than being a + * no-op. Capability changes (HDMI hot-plug, audio route loss) can fire while a + * player is being torn down, which is exactly that window. + * + * Kept separate from the player so the rule can be tested without a Context. + */ +internal fun shouldSkipTrackReselection(playbackState: Int, timelineEmpty: Boolean): Boolean = + playbackState == Player.STATE_IDLE || timelineEmpty diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSource.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSource.kt new file mode 100644 index 000000000..52bfc40be --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSource.kt @@ -0,0 +1,187 @@ +package org.prairieserver.prairie.common.player + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import java.io.ByteArrayOutputStream +import java.io.IOException +import org.prairieserver.prairie.common.io.checkedLimitedByteCount + +/** + * Buffers one parsed-text subtitle response and replays later opens locally. + * + * Media3's [androidx.media3.extractor.text.SubtitleExtractor] reads the whole + * sidecar to publish cues. A start-position seek can then reopen the same URI + * from byte zero so the extractor can rebuild its seeked output. When that + * second network read shares a [androidx.media3.exoplayer.source.MergingMediaSource] + * with HLS, Media3 may close its load gate after the first video segment. The + * subtitle response then remains backpressured and the primary source cannot + * continue loading. Reading the bounded text artifact during [DataSource.open] + * makes the first request atomic from Media3's perspective and lets every + * subsequent extractor open read the same immutable bytes without HTTP. + * + * A factory instance belongs to one subtitle child source, so its cache cannot + * leak between tracks, sessions, or media mounts. + */ +@OptIn(UnstableApi::class) +internal class ReplayableSubtitleDataSourceFactory( + private val upstreamFactory: DataSource.Factory, + private val maxBytes: Long = MAX_SUBTITLE_BYTES, +) : DataSource.Factory { + private val cache = ReplayableSubtitleCache() + + override fun createDataSource(): DataSource = ReplayableSubtitleDataSource( + upstream = upstreamFactory.createDataSource(), + cache = cache, + maxBytes = maxBytes, + ) +} + +private data class ReplayableSubtitleEntry( + val requestedUri: Uri, + val resolvedUri: Uri, + val responseHeaders: Map>, + val data: ByteArray, +) + +private class ReplayableSubtitleCache { + var entry: ReplayableSubtitleEntry? = null +} + +@OptIn(UnstableApi::class) +private class ReplayableSubtitleDataSource( + private val upstream: DataSource, + private val cache: ReplayableSubtitleCache, + private val maxBytes: Long, +) : DataSource { + private var replay: ReplayableSubtitleEntry? = null + private var replayPosition = 0 + private var replayLimit = 0 + private var upstreamOpen = false + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + close() + + synchronized(cache) { cache.entry } + ?.takeIf { it.requestedUri == dataSpec.uri } + ?.let { return openReplay(it, dataSpec) } + + if (!dataSpec.isWholeResourceFromStart()) { + val length = upstream.open(dataSpec) + upstreamOpen = true + return length + } + + val declaredLength = upstream.open(dataSpec) + upstreamOpen = true + val resolvedUri = upstream.uri ?: dataSpec.uri + val responseHeaders = upstream.responseHeaders + val bytes = try { + if (declaredLength >= 0L) { + checkedLimitedByteCount( + currentBytes = 0L, + additionalBytes = declaredLength, + maxBytes = maxBytes, + limitName = "subtitle", + ) + } + readAllFromUpstream() + } finally { + upstream.close() + upstreamOpen = false + } + val entry = ReplayableSubtitleEntry( + requestedUri = dataSpec.uri, + resolvedUri = resolvedUri, + responseHeaders = responseHeaders, + data = bytes, + ) + synchronized(cache) { + val published = cache.entry + ?.takeIf { it.requestedUri == dataSpec.uri } + ?: entry.also { cache.entry = it } + return openReplay(published, dataSpec) + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val entry = replay ?: return upstream.read(buffer, offset, length) + if (replayPosition >= replayLimit) return C.RESULT_END_OF_INPUT + val count = minOf(length, replayLimit - replayPosition) + entry.data.copyInto( + destination = buffer, + destinationOffset = offset, + startIndex = replayPosition, + endIndex = replayPosition + count, + ) + replayPosition += count + return count + } + + override fun getUri(): Uri? = replay?.resolvedUri ?: upstream.uri + + override fun getResponseHeaders(): Map> = + replay?.responseHeaders ?: upstream.responseHeaders + + override fun close() { + if (upstreamOpen) upstream.close() + upstreamOpen = false + replay = null + replayPosition = 0 + replayLimit = 0 + } + + private fun openReplay(entry: ReplayableSubtitleEntry, dataSpec: DataSpec): Long { + val position = dataSpec.position + if (position < 0L || position > entry.data.size.toLong()) { + throw IOException( + "Subtitle replay position $position is outside ${entry.data.size} bytes", + ) + } + val requestedLength = dataSpec.length + val available = entry.data.size.toLong() - position + val exposedLength = if (requestedLength == C.LENGTH_UNSET.toLong()) { + available + } else { + minOf(available, requestedLength) + } + replay = entry + replayPosition = position.toInt() + replayLimit = (position + exposedLength).toInt() + return exposedLength + } + + private fun readAllFromUpstream(): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_SUBTITLE_READ_BUFFER_SIZE) + var total = 0L + while (true) { + val read = upstream.read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) break + if (read > 0) { + total = checkedLimitedByteCount( + currentBytes = total, + additionalBytes = read.toLong(), + maxBytes = maxBytes, + limitName = "subtitle", + ) + out.write(buffer, 0, read) + } + } + return out.toByteArray() + } +} + +@OptIn(UnstableApi::class) +private fun DataSpec.isWholeResourceFromStart(): Boolean = + position == 0L && length == C.LENGTH_UNSET.toLong() + +private const val DEFAULT_SUBTITLE_READ_BUFFER_SIZE = 16 * 1024 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt index cceb1f1fb..d721caef5 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt @@ -6,6 +6,8 @@ import android.net.Uri import android.util.Log import android.view.Gravity import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver import android.widget.FrameLayout import androidx.media3.common.C import androidx.media3.common.Format @@ -22,6 +24,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.PlayerView +import androidx.media3.ui.SubtitleView import org.prairieserver.prairie.libass.LibassBridge import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity @@ -29,6 +32,8 @@ import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset +import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import java.lang.ref.WeakReference import java.util.WeakHashMap import kotlin.math.roundToInt @@ -39,12 +44,22 @@ import kotlin.math.roundToInt * External subtitles come from the server as URLs that need authentication. * This manager builds subtitle configurations and applies track selection. */ +@UnstableApi +enum class AndroidSubtitlePresentation { + Phone, + Television, +} + @UnstableApi class SubtitleManager( private val libassBridge: LibassBridge? = null, + private val presentation: AndroidSubtitlePresentation = + AndroidSubtitlePresentation.Television, ) { private val videoRectSyncs = WeakHashMap() + /** Test-only execution observer; null in production. */ + internal var postLayoutReconciliationObserver: (() -> Unit)? = null var letterbox: LetterboxInsets = LetterboxInsets.NONE set(value) { @@ -60,6 +75,19 @@ class SubtitleManager( videoRectSyncs.values.forEach { it.titleSafeFraction = value } } + /** + * The appearance last handed to [applyAppearance], kept so the cue + * forwarding can remap BITMAP cues — Media3's `SubtitlePainter` positions + * and sizes those from the cue's own fields alone, so `setStyle` / + * `setFixedTextSize` / `setBottomPaddingFraction` never reach them. + */ + private var appearance: SubtitleAppearance = SubtitleAppearance.DEFAULT + set(value) { + if (field == value) return + field = value + videoRectSyncs.values.forEach { it.appearance = value } + } + /** * Builds MediaItem.SubtitleConfiguration entries for external subtitle tracks. * @@ -224,18 +252,24 @@ class SubtitleManager( * Applies the user's [SubtitleAppearance] to the [PlayerView]'s subtitle layer. * * Maps onto Media3 via [CaptionStyleCompat] (colors + edge style + typeface), - * [androidx.media3.ui.SubtitleView.setFractionalTextSize] (relative-to-view-height - * font scale), and [androidx.media3.ui.SubtitleView.setBottomPaddingFraction] - * (vertical position within the surface). + * [androidx.media3.ui.SubtitleView.setFractionalTextSize] for phone + * relative-to-view-height sizing, [androidx.media3.ui.SubtitleView.setFixedTextSize] + * for television SP sizing, and + * [androidx.media3.ui.SubtitleView.setBottomPaddingFraction] (vertical position + * within the surface). * * Media3-rendered text uses the user's appearance. ASS/SSA is rendered by * libass and deliberately preserves the script's authored typesetting, * animation, positioning, and embedded fonts, matching the Apple player. + * + * Bitmap cues (PGS/DVB) take Position and Size only, and not through this + * view-level API at all — see [remapBitmapCue]. */ fun applyAppearance(playerView: PlayerView, appearance: SubtitleAppearance) { val subtitleView = playerView.subtitleView ?: return libassBridge?.attachTo(subtitleView) val safe = appearance.sanitized() + this.appearance = safe val captionStyle = try { buildCaptionStyle(safe) @@ -254,10 +288,15 @@ class SubtitleManager( subtitleView.setApplyEmbeddedStyles(false) subtitleView.setApplyEmbeddedFontSizes(false) subtitleView.setStyle(captionStyle) - subtitleView.setFractionalTextSize( - fractionalSizeFor(safe.fontSize), - /* fractionalRelativeToTextSize = */ false, + applyAndroidSubtitleTextSize( + subtitleView, + androidSubtitleTextSize(presentation, safe.fontSize), ) + // The picture-relative fraction, which is the answer whenever the canvas + // is the picture and the starting point before there is any geometry to + // measure. The sync overwrites it from the placed canvas — the + // screen-anchored Bottom preset depends on how far that canvas extends + // past the picture, which only the sync knows. subtitleView.setBottomPaddingFraction(bottomPaddingFor(safe.position)) syncSubtitleVideoBounds(playerView) } @@ -271,15 +310,21 @@ class SubtitleManager( playerView.subtitleView?.let { libassBridge?.attachTo(it) } val existing = videoRectSyncs[playerView] val sync = if (existing?.isDisposed == true || existing == null) { - SubtitleVideoRectSync(playerView).also { + SubtitleVideoRectSync( + playerView = playerView, + presentation = presentation, + libassBridge = libassBridge, + onPostLayoutReconciled = { postLayoutReconciliationObserver?.invoke() }, + ).also { it.letterbox = letterbox it.titleSafeFraction = titleSafeFraction + it.appearance = appearance videoRectSyncs[playerView] = it } } else { existing } - sync.update() + sync.updateAndReconcileAfterLayout() } private fun buildCaptionStyle(appearance: SubtitleAppearance): CaptionStyleCompat { @@ -325,24 +370,8 @@ class SubtitleManager( } } - private fun fractionalSizeFor(preset: SubtitleFontSizePreset): Float { - return when (preset) { - SubtitleFontSizePreset.Small -> 20f / 720f - SubtitleFontSizePreset.Medium -> 26f / 720f - SubtitleFontSizePreset.Large -> 32f / 720f - SubtitleFontSizePreset.XLarge -> 40f / 720f - SubtitleFontSizePreset.XXLarge -> 48f / 720f - } - } - - private fun bottomPaddingFor(position: SubtitlePositionPreset): Float { - val base = when (position) { - SubtitlePositionPreset.Bottom -> 0.09f - SubtitlePositionPreset.LowerThird -> 0.18f - SubtitlePositionPreset.Top -> 0.74f - } - return (base - titleSafeFraction).coerceAtLeast(0.02f) - } + private fun bottomPaddingFor(position: SubtitlePositionPreset): Float = + subtitleBottomPaddingFraction(position, titleSafeFraction) private fun parseHexColor(hex: String, alpha: Int = 255): Int { val cleaned = if (hex.startsWith("#")) hex.drop(1) else hex @@ -487,6 +516,37 @@ internal fun displayedSubtitleVideoRect( ) } +/** + * Picks the rect the subtitle layer is laid out in. + * + * The one thing that governs this is a coordinate space. `SubtitleView` is a + * child of `exo_content_frame` — NOT of the PlayerView — so whatever comes back + * here is applied as margins inside the CONTENT FRAME. + * [displayedSubtitleContentFrameRect] already speaks that space: it intersects + * the frame with the view and returns the result relative to the frame's own + * origin, which is both the visible-region clamp and the right anchor. + * + * [displayedVideoRect] does not speak it — it is measured from the PlayerView's + * top-left. It survives only as the fallback for having no content frame at + * all, where there is nothing better to say. + * + * This used to prefer [displayedVideoRect] for ZOOM and FILL whenever the + * frame's visible size did not equal the view's, meaning to say "the zoomed + * video covers the whole view, so the captions should too". The intent was + * right and the arithmetic was in the wrong space: that mismatch happens + * exactly when the frame is OFFSET inside the view (a resize mode changed and + * the frame has not been laid out again yet), and applying a view-space rect at + * frame-relative margins then shifts the captions by the offset — 127px off + * centre on a 3120px display, for one layout pass, which is what "the + * subtitles aren't centred" was. Anchoring to the frame is correct in that + * moment too: the captions track whatever the video is actually rendered at + * right now, mid-transition included. + */ +internal fun selectSubtitleCanvasRect( + contentFrameRect: SubtitleVideoRect?, + displayedVideoRect: SubtitleVideoRect, +): SubtitleVideoRect = contentFrameRect ?: displayedVideoRect + internal fun displayedSubtitleContentFrameRect( viewWidth: Int, viewHeight: Int, @@ -518,7 +578,7 @@ internal fun displayedSubtitleContentFrameRect( * across SRT-as-VTT, native SRT, positioned WebVTT, and PGS. * * WebvttCueParser defaults every cue to `size == 1.0` (full width), and the - * Prairie server serves all sidecar text subs (including converted SRT) as .vtt — + * Silo server serves all sidecar text subs (including converted SRT) as .vtt — * so nearly every sidecar cue would otherwise trigger a full-width opaque band * behind the text under the Box style. Only the full-width default is stripped: * @@ -542,6 +602,346 @@ internal fun neutralizeFullWidthCueSize(cue: Cue): Cue { return cue } +/** + * Where the TOP edge of a Top-preset caption sits, as a fraction of the caption + * canvas. + * + * Top is anchored from the top rather than expressed as a bottom padding: a + * bottom padding places the BOTTOM of the text block, so a two-line cue starts + * lower than a one-line cue and neither lands where "top" means. The canvas is + * already inset by the title-safe fraction on television, so 0.01 of it is + * about 6% down the picture — the tvOS client's ~70px on 1080. + */ +internal const val SUBTITLE_TOP_LINE_FRACTION = 0.01f + +/** + * The smallest gap the bottom-anchored presets may leave below the caption, as + * a fraction of the canvas. Mirrors [SUBTITLE_TOP_LINE_FRACTION] at the other + * edge: enough that the text never touches the picture edge. + */ +internal const val MIN_SUBTITLE_BOTTOM_PADDING = 0.01f + +/** + * Where the Bottom preset puts the caption, as a fraction of the PLAYER VIEW's + * height above the player view's bottom edge — the screen, not the picture. + * + * Bottom is the only screen-anchored preset, matching the Apple client: tvOS + * enables libass `use_margins` for it so regular events render across the full + * overlay frame at `primaryMarginV` 60 on a 1080 frame, and its own comment + * says the preset "can sit in the letterbox bar below the picture when the + * overlay extends past the video rect". Lower Third and Top stay anchored to + * the picture, where the author's framing is what matters. + * + * 6% of 1080 is 65px, the tvOS ~60px reference, and on 16:9 content — where the + * picture fills the screen — it is exactly where the old picture-anchored 6% + * already landed. The two only diverge when the content letterboxes. + */ +internal const val SUBTITLE_BOTTOM_SCREEN_FRACTION = 0.06f + +/** + * Re-places a text cue that carries the parser's DEFAULT vertical placement, so + * the user's Position preset decides where it lands. + * + * Bottom and Lower Third are applied as `SubtitleView.setBottomPaddingFraction`, + * and Media3 1.10.1's `SubtitlePainter.setupTextLayout` only consults that + * fraction when `cue.line == DIMEN_UNSET` — any explicit line wins outright. + * Every cue from a streamed SRT/VTT sidecar carries WebVTT's default "auto" + * placement, which the parser materializes as `line = -1` with + * `LINE_TYPE_NUMBER` ("one line up from the bottom"), so all three presets were + * drawn in the same place. Clearing the line hands those two back to the + * padding; Top instead gets an explicit top-anchored line + * ([SUBTITLE_TOP_LINE_FRACTION]). + * + * Only that exact default is re-placed. An authored placement — a fraction + * line, any other line number — is the author positioning the caption around + * the picture and is left alone, as are bitmap cues (see [remapBitmapCue]) and + * ASS, which libass renders and never reaches this path. + */ +@UnstableApi +internal fun remapDefaultTextCuePlacement( + cue: Cue, + position: SubtitlePositionPreset, +): Cue { + if (cue.bitmap != null || cue.text == null) return cue + if (cue.lineType != Cue.LINE_TYPE_NUMBER || cue.line != -1f) return cue + // The parser emits the default with no meaningful line anchor; a cue that + // anchors its line elsewhere is expressing a real placement. + if (cue.lineAnchor != Cue.TYPE_UNSET && cue.lineAnchor != Cue.ANCHOR_TYPE_START) return cue + if (position == SubtitlePositionPreset.Top) { + return cue.buildUpon() + .setLine(SUBTITLE_TOP_LINE_FRACTION, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_START) + .build() + } + return cue.buildUpon().setLine(Cue.DIMEN_UNSET, Cue.TYPE_UNSET).build() +} + +@UnstableApi +internal fun remapDefaultTextCuePlacements( + cueGroup: CueGroup, + position: SubtitlePositionPreset, +): CueGroup { + if (cueGroup.cues.isEmpty()) return cueGroup + var changed = false + val mapped = cueGroup.cues.map { original -> + val next = remapDefaultTextCuePlacement(original, position) + if (next !== original) changed = true + next + } + return if (changed) CueGroup(mapped, cueGroup.presentationTimeUs) else cueGroup +} + +/** + * Vertical placement of the caption block, expressed the way Media3 wants it: + * the fraction of the subtitle surface left free BELOW the caption. + * + * Shared by the text path ([androidx.media3.ui.SubtitleView.setBottomPaddingFraction]) + * and the bitmap path ([remapBitmapCue]) so both presets land in the same place. + * + * The bases are the broadcast/Apple references measured from the frame edge: + * Bottom sits just inside the picture (SMPTE ST 2046-1 title-safe is 90% of the + * frame, and the tvOS client places captions ~60px above the bottom on 1080), + * Lower Third about a fifth up. Bottom's frame-relative answer holds while the + * canvas IS the picture; once the canvas extends into the letterbox bar, + * [subtitleBottomPaddingFractionForCanvas] restates it against the screen. + * Top does not belong here at all — it is + * top-anchored per [SUBTITLE_TOP_LINE_FRACTION] — and its value is inert: the + * only text cues that read this fraction are the ones the Top branch of + * [remapDefaultTextCuePlacement] has given an explicit line instead. + */ +internal fun subtitleBottomPaddingFraction( + position: SubtitlePositionPreset, + titleSafeFraction: Float, +): Float { + // PHYSICAL fractions of the frame height, measured from the frame edge — + // the same on every presentation. Bottom ~6% (tvOS client: ~60px on + // 1080; SMPTE ST 2046-1 title-safe is 5%), Lower Third ~18%. + val base = when (position) { + SubtitlePositionPreset.Bottom -> SUBTITLE_BOTTOM_SCREEN_FRACTION + SubtitlePositionPreset.LowerThird -> 0.18f + SubtitlePositionPreset.Top -> 0.74f + } + // The title-safe inset moves the subtitle surface in by f on both + // edges, leaving a height of (1 - 2f). Preserve the physical preset by + // solving f + p(1 - 2f) = base for the padding p inside the canvas. On + // the phone f is 0 and the base applies raw; on television (f = 0.05) + // Bottom becomes ~1% of the canvas, which lands the text ~6% up the frame. + val remainingScale = 1f - 2f * titleSafeFraction + if (remainingScale <= 0f) return base + return ((base - titleSafeFraction) / remainingScale).coerceAtLeast(MIN_SUBTITLE_BOTTOM_PADDING) +} + +/** + * The caption canvas for [position], given the picture-anchored canvas the + * letterbox and title-safe insets produce. + * + * Everything here is in the SubtitleView's parent space (the content frame), + * which is the one space the rect sync works in — the canvas stays a single + * rect written as margins on the same parent, and only its bottom edge moves. + * + * Bottom extends that bottom edge down to the PLAYER VIEW's bottom, so the + * caption drops into the letterbox bar when the picture does not reach the + * screen edge. This covers both ways a bar can appear: an + * `AspectRatioFrameLayout` frame shorter than the view (2.39:1 in a 16:9 + * PlayerView) and encoded bars inside a 16:9 frame, because + * [insetByLetterbox]'s bottom inset is simply discarded by the extension. + * The rect is never taken PAST the player view, so the canvas still lives + * inside the PlayerView's own bounds and only the content frame has to stop + * clipping it. + * + * Lower Third and Top are returned untouched: they are anchored to the picture + * by definition, and Top does not read this edge at all. + */ +internal fun subtitleCanvasRectFor( + position: SubtitlePositionPreset, + pictureRect: SubtitleVideoRect, + playerBottomInParentSpace: Int, +): SubtitleVideoRect { + if (position != SubtitlePositionPreset.Bottom) return pictureRect + if (pictureRect.height <= 0) return pictureRect + val extendedHeight = playerBottomInParentSpace - pictureRect.top + if (extendedHeight <= pictureRect.height) return pictureRect + return pictureRect.copy(height = extendedHeight) +} + +/** + * The bottom padding fraction for a canvas that may extend past the picture. + * + * Media3 reads the fraction against the SubtitleView's own height, so the + * screen-anchored Bottom preset has to be converted into the canvas' space: + * put the caption's bottom edge [SUBTITLE_BOTTOM_SCREEN_FRACTION] of the player + * height above the player's bottom, whatever the canvas happens to span. Stated + * against the canvas bottom's real position rather than assuming the extension + * succeeded, so a canvas that reaches past the player view (zoom overhang) or + * falls short of it (degenerate geometry) still lands the text in the same + * place on screen. + * + * Lower Third and Top keep the picture-relative + * [subtitleBottomPaddingFraction], which is what "anchored to the picture" + * means once the canvas is the picture. + */ +internal fun subtitleBottomPaddingFractionForCanvas( + position: SubtitlePositionPreset, + titleSafeFraction: Float, + canvasHeight: Int, + canvasBottomInPlayerSpace: Int, + playerHeight: Int, +): Float { + if ( + position != SubtitlePositionPreset.Bottom || + canvasHeight <= 0 || + playerHeight <= 0 + ) { + return subtitleBottomPaddingFraction(position, titleSafeFraction) + } + val targetBottom = playerHeight * (1f - SUBTITLE_BOTTOM_SCREEN_FRACTION) + return ((canvasBottomInPlayerSpace - targetBottom) / canvasHeight) + .coerceIn(MIN_SUBTITLE_BOTTOM_PADDING, 1f) +} + +/** + * Size ladder for bitmap cues, as a multiplier on the AUTHORED cue size. + * + * Medium is 1.0 — the disc's own typesetting — and the rest follow the shape of + * the television text ladder in `AndroidSubtitleTextSizePolicy` without its full + * reach: a PGS cue is a fixed-resolution image, so every step above 1.0 is + * upscaling real pixels and the text ladder's 1.8x top end would visibly smear. + */ +internal fun bitmapCueScaleFor(preset: SubtitleFontSizePreset): Float = when (preset) { + SubtitleFontSizePreset.Small -> 0.85f + SubtitleFontSizePreset.Medium -> 1f + SubtitleFontSizePreset.Large -> 1.15f + SubtitleFontSizePreset.XLarge -> 1.3f + SubtitleFontSizePreset.XXLarge -> 1.5f +} + +/** + * Applies the user's Position and Size to a BITMAP cue by rewriting the cue's + * own geometry. + * + * Media3 1.10.1's `SubtitlePainter.setupBitmapLayout()` derives the destination + * rect purely from `position`/`positionAnchor`/`line`/`lineAnchor`/`size`/ + * `bitmapHeight` — the caption style, the fixed text size and the bottom-padding + * fraction are all read only by the text branch. So for PGS/DVB the appearance + * has to be baked into the cue before `SubtitleView.setCues`, or it has no + * effect at all. + * + * `PgsParser` and `DvbParser` both emit `position` = left fraction with + * `ANCHOR_TYPE_START`, `line` = top fraction (`LINE_TYPE_FRACTION`) with + * `ANCHOR_TYPE_START`, `size` = width fraction and `bitmapHeight` = height + * fraction (verified against the 1.10.1 sources). This preserves whatever + * anchors the cue carries and re-expresses the same edges through them. + * + * Every bitmap cue is re-anchored to the preset, exactly as the text path + * re-anchors every text cue: the user picked a position and disc subtitles are + * authored at the bottom regardless. + * + * [bottomPaddingFraction] is the SAME fraction the text path writes to + * `SubtitleView.setBottomPaddingFraction`, so both kinds of cue land on the + * same line — including the screen-anchored Bottom preset, whose fraction only + * the sync can work out because it depends on how far the canvas extends past + * the picture. It defaults to the picture-relative value for callers with no + * canvas in hand. + * + * A cue whose `size`/`bitmapHeight`/`position` are missing or out of range is + * returned untouched — without a height fraction the painter falls back to the + * bitmap's own aspect against the parent width, which is not knowable here. + * Text cues are never touched, and neither is ASS (libass renders that itself). + */ +@UnstableApi +internal fun remapBitmapCue( + cue: Cue, + appearance: SubtitleAppearance, + titleSafeFraction: Float, + bottomPaddingFraction: Float = + subtitleBottomPaddingFraction(appearance.position, titleSafeFraction), +): Cue { + if (cue.bitmap == null) return cue + val width = cue.size.takeIf(::isUsableCueFraction) ?: return cue + val height = cue.bitmapHeight.takeIf(::isUsableCueFraction) ?: return cue + val position = cue.position.takeIf { it.isFinite() && it >= 0f && it <= 1f } ?: return cue + + val left = when (cue.positionAnchor) { + Cue.ANCHOR_TYPE_END -> position - width + Cue.ANCHOR_TYPE_MIDDLE -> position - width / 2f + // START, and TYPE_UNSET which the painter treats as START. + else -> position + } + + // Never let a scaled-up cue outgrow the surface it is drawn on. + val requested = bitmapCueScaleFor(appearance.fontSize) + val scale = requested.coerceAtMost(minOf(1f / width, 1f / height)) + val scaledWidth = (width * scale).coerceIn(0f, 1f) + val scaledHeight = (height * scale).coerceIn(0f, 1f) + + // Scale about the cue's own horizontal centre, then clamp on screen. The + // authored horizontal placement is preserved; only the preset moves it + // vertically. + val centerX = left + width / 2f + val scaledLeft = (centerX - scaledWidth / 2f) + .coerceIn(0f, (1f - scaledWidth).coerceAtLeast(0f)) + // Top is anchored from the top for the same reason the text path is: a + // bottom padding places the bottom of a block whose height varies. + val unclampedTop = if (appearance.position == SubtitlePositionPreset.Top) { + SUBTITLE_TOP_LINE_FRACTION + } else { + 1f - bottomPaddingFraction - scaledHeight + } + val scaledTop = unclampedTop.coerceIn(0f, (1f - scaledHeight).coerceAtLeast(0f)) + + val newPosition = when (cue.positionAnchor) { + Cue.ANCHOR_TYPE_END -> scaledLeft + scaledWidth + Cue.ANCHOR_TYPE_MIDDLE -> scaledLeft + scaledWidth / 2f + else -> scaledLeft + } + val newLine = when (cue.lineAnchor) { + Cue.ANCHOR_TYPE_END -> scaledTop + scaledHeight + Cue.ANCHOR_TYPE_MIDDLE -> scaledTop + scaledHeight / 2f + else -> scaledTop + } + + if ( + newPosition == cue.position && + newLine == cue.line && + cue.lineType == Cue.LINE_TYPE_FRACTION && + scaledWidth == cue.size && + scaledHeight == cue.bitmapHeight + ) { + // Identical geometry — hand back the same instance so SubtitlePainter + // keeps its cached layout. + return cue + } + + return cue.buildUpon() + .setPosition(newPosition) + .setLine(newLine, Cue.LINE_TYPE_FRACTION) + .setSize(scaledWidth) + .setBitmapHeight(scaledHeight) + .build() +} + +private fun isUsableCueFraction(value: Float): Boolean = + value.isFinite() && value > 0f && value <= 1f + +@UnstableApi +internal fun remapBitmapCues( + cueGroup: CueGroup, + appearance: SubtitleAppearance, + titleSafeFraction: Float, + bottomPaddingFraction: Float = + subtitleBottomPaddingFraction(appearance.position, titleSafeFraction), +): CueGroup { + if (cueGroup.cues.isEmpty()) return cueGroup + var changed = false + val mapped = cueGroup.cues.map { original -> + val next = remapBitmapCue(original, appearance, titleSafeFraction, bottomPaddingFraction) + if (next !== original) changed = true + next + } + return if (changed) CueGroup(mapped, cueGroup.presentationTimeUs) else cueGroup +} + +@UnstableApi internal fun neutralizeFullWidthCueSizes(cueGroup: CueGroup): CueGroup { if (cueGroup.cues.isEmpty()) return cueGroup var changed = false @@ -553,12 +953,44 @@ internal fun neutralizeFullWidthCueSizes(cueGroup: CueGroup): CueGroup { return if (changed) CueGroup(mapped, cueGroup.presentationTimeUs) else cueGroup } +/** + * How many times the sync may re-ask for a layout it has already written to the + * SubtitleView's params before accepting the answer. Three covers a dropped + * in-pass `requestLayout()` and the traversal that follows it; beyond that the + * parent is declining the geometry and retrying would only spin. + */ +private const val MAX_SUBTITLE_RELAYOUT_ATTEMPTS = 3 + @UnstableApi -private class SubtitleVideoRectSync(playerView: PlayerView) : +private class SubtitleVideoRectSync( + playerView: PlayerView, + private val presentation: AndroidSubtitlePresentation, + private val libassBridge: LibassBridge?, + private val onPostLayoutReconciled: () -> Unit, +) : View.OnLayoutChangeListener, View.OnAttachStateChangeListener, Player.Listener { + /** The canvas geometry last written to the SubtitleView's layout params. */ + private data class RequestedCanvas( + val width: Int, + val height: Int, + val leftMargin: Int, + val topMargin: Int, + val gravity: Int, + ) + + private data class LayoutSnapshot( + val resizeMode: Int, + val playerWidth: Int, + val playerHeight: Int, + val frameLeft: Int, + val frameTop: Int, + val frameWidth: Int, + val frameHeight: Int, + ) + private val playerViewRef = WeakReference(playerView) private val contentFrameRef = WeakReference( playerView.findViewById( @@ -566,6 +998,15 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : ) ) private var observedPlayer: Player? = null + private var reconciliationGeneration = 0L + private var pendingVerification: Runnable? = null + private var appliedPasses = 0 + private var requestedCanvas: RequestedCanvas? = null + private var relayoutAttempts = 0 + private var pendingLayoutRequest = false + private var pendingCanvasPlacement = false + /** Mirrors the content frame's `clipChildren`, which starts out enabled. */ + private var contentFrameClipped = true var letterbox: LetterboxInsets = LetterboxInsets.NONE set(value) { @@ -579,11 +1020,66 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : if (field == value) return field = value update() + reforwardLastCues() + } + + /** + * Drives [remapBitmapCue] and, through [SubtitlePositionPreset], the canvas + * itself: Bottom is screen-anchored and spans past the picture, the other + * two presets stop at it. Changing it re-places the canvas and re-forwards + * the last cue group, so a Position change lands on the caption currently + * on screen without waiting for the next cue or for a parent layout pass. + */ + var appearance: SubtitleAppearance = SubtitleAppearance.DEFAULT + set(value) { + if (field == value) return + val previous = field + field = value + if (previous.position != value.position) update() + reforwardLastCues() + } + + /** + * The bottom padding fraction [applyRect] resolved for the current canvas, + * shared by the text path (`SubtitleView.setBottomPaddingFraction`) and the + * bitmap path. Null until the canvas has been placed once; cues arriving + * before then fall back to the picture-relative fraction. + */ + private var canvasBottomPaddingFraction: Float? = null + set(value) { + if (field == value) return + field = value + reforwardLastCues() } + /** The last group received from the player, BEFORE any transformation. */ + private var lastCueGroup: CueGroup? = null + var isDisposed: Boolean = false private set + private var pendingPreDrawObserver: ViewTreeObserver? = null + private var pendingPreDrawGeneration = 0L + private val postLayoutUpdate = ViewTreeObserver.OnPreDrawListener { + val generation = pendingPreDrawGeneration + clearPendingPostLayoutUpdate() + if (!isDisposed && generation == reconciliationGeneration) { + update() + val currentPlayerView = playerViewRef.get() + val appliedSnapshot = currentPlayerView?.let(::currentSnapshot) + appliedPasses++ + onPostLayoutReconciled() + if (currentPlayerView != null && appliedSnapshot != null && appliedPasses < 2) { + postSnapshotVerification( + playerView = currentPlayerView, + generation = generation, + appliedSnapshot = appliedSnapshot, + ) + } + } + true + } + init { playerView.addOnLayoutChangeListener(this) playerView.addOnAttachStateChangeListener(this) @@ -611,6 +1107,71 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : applyRect(playerView) } + fun updateAndReconcileAfterLayout() { + update() + val playerView = playerViewRef.get() ?: return + if (isDisposed) return + reconciliationGeneration++ + appliedPasses = 0 + clearPendingVerification(playerView) + schedulePreDrawFor(reconciliationGeneration) + } + + private fun schedulePreDrawFor(generation: Long) { + val playerView = playerViewRef.get() ?: return dispose(null) + if (isDisposed || generation != reconciliationGeneration) return + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) { + pendingPreDrawGeneration = generation + return + } + pendingPreDrawObserver = null + } + val observer = playerView.viewTreeObserver + if (!observer.isAlive) return + pendingPreDrawGeneration = generation + pendingPreDrawObserver = observer + observer.addOnPreDrawListener(postLayoutUpdate) + } + + private fun postSnapshotVerification( + playerView: PlayerView, + generation: Long, + appliedSnapshot: LayoutSnapshot, + ) { + lateinit var verification: Runnable + verification = Runnable { + if (pendingVerification === verification) { + pendingVerification = null + } + val currentPlayerView = playerViewRef.get() + if ( + !isDisposed && + generation == reconciliationGeneration && + appliedPasses < 2 && + currentPlayerView != null && + currentSnapshot(currentPlayerView) != appliedSnapshot + ) { + schedulePreDrawFor(generation) + } + } + pendingVerification = verification + playerView.post(verification) + } + + private fun currentSnapshot(playerView: PlayerView): LayoutSnapshot { + val contentFrame = contentFrameRef.get() + return LayoutSnapshot( + resizeMode = playerView.resizeMode, + playerWidth = playerView.width, + playerHeight = playerView.height, + frameLeft = contentFrame?.left ?: 0, + frameTop = contentFrame?.top ?: 0, + frameWidth = contentFrame?.width ?: 0, + frameHeight = contentFrame?.height ?: 0, + ) + } + override fun onVideoSizeChanged(videoSize: VideoSize) { update() } @@ -627,8 +1188,27 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : } private fun forwardNeutralizedCues(playerView: PlayerView, cueGroup: CueGroup) { + lastCueGroup = cueGroup val subtitleView = playerView.subtitleView ?: return - subtitleView.setCues(neutralizeFullWidthCueSizes(cueGroup).cues) + val cues = remapBitmapCues( + cueGroup = remapDefaultTextCuePlacements( + cueGroup = neutralizeFullWidthCueSizes(cueGroup), + position = appearance.position, + ), + appearance = appearance, + titleSafeFraction = titleSafeFraction, + bottomPaddingFraction = canvasBottomPaddingFraction + ?: subtitleBottomPaddingFraction(appearance.position, titleSafeFraction), + ).cues + logSubtitleCueGeometry(cues) + subtitleView.setCues(cues) + } + + private fun reforwardLastCues() { + if (isDisposed) return + val playerView = playerViewRef.get() ?: return + val cueGroup = lastCueGroup ?: return + forwardNeutralizedCues(playerView, cueGroup) } override fun onLayoutChange( @@ -655,49 +1235,418 @@ private class SubtitleVideoRectSync(playerView: PlayerView) : private fun applyRect(playerView: PlayerView) { val subtitleView = playerView.subtitleView ?: return + val resizeMode = playerView.resizeMode + val gravity = Gravity.TOP or Gravity.START + val position = appearance.position + // The SubtitleView's parent is the content frame, so the player view's + // bottom edge — the anchor the Bottom preset needs — is that many + // pixels down in the space every rect here is written in. + val contentFrame = contentFrameRef.get() + val parentTop = contentFrame?.top ?: 0 + val parentHeight = contentFrame?.height ?: playerView.height + val playerBottomInParentSpace = playerView.height - parentTop + if ( + presentation == AndroidSubtitlePresentation.Phone && + ( + resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT || + resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FILL + ) && + !letterbox.isDetected && + titleSafeFraction <= 0f && + // MATCH_PARENT is the content frame, so it can only stand in for the + // screen-anchored canvas while the frame already reaches the + // player's bottom. A letterboxed phone frame falls through to the + // explicit rect, which is what puts Bottom in the bar. + !( + position == SubtitlePositionPreset.Bottom && + playerBottomInParentSpace > parentHeight + ) + ) { + applyLayoutParams( + subtitleView = subtitleView, + width = FrameLayout.LayoutParams.MATCH_PARENT, + height = FrameLayout.LayoutParams.MATCH_PARENT, + leftMargin = 0, + topMargin = 0, + gravity = gravity, + ) + setContentFrameClipping(clipped = true) + libassBridge?.constrainOverlayHeight(0) + applyBottomPadding( + subtitleView = subtitleView, + position = position, + canvasHeight = parentHeight, + canvasBottomInPlayerSpace = parentTop + parentHeight, + playerHeight = playerView.height, + ) + logSubtitleCanvasGeometry( + playerView = playerView, + subtitleView = subtitleView, + appliedLabel = "MATCH_PARENT", + resizeMode = resizeMode, + bottomPaddingFraction = canvasBottomPaddingFraction, + playerBottomInParentSpace = playerBottomInParentSpace, + ) + return + } + val videoSize = playerView.player?.videoSize ?: VideoSize.UNKNOWN - val rect = (playerView.contentFrameSubtitleRect() - ?: displayedSubtitleVideoRect( - viewWidth = playerView.width, - viewHeight = playerView.height, - videoWidth = videoSize.width, - videoHeight = videoSize.height, - videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, - resizeMode = playerView.resizeMode, - )).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) + val displayedVideoRect = displayedSubtitleVideoRect( + viewWidth = playerView.width, + viewHeight = playerView.height, + videoWidth = videoSize.width, + videoHeight = videoSize.height, + videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, + resizeMode = resizeMode, + ) + val pictureRect = selectSubtitleCanvasRect( + contentFrameRect = playerView.contentFrameSubtitleRect(), + displayedVideoRect = displayedVideoRect, + ).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) + val rect = subtitleCanvasRectFor( + position = position, + pictureRect = pictureRect, + playerBottomInParentSpace = playerBottomInParentSpace, + ) + // Only a canvas that actually reaches past the frame needs the frame to + // stop clipping, and only while it does: extending back over the + // title-safe bottom inset stays inside the frame and changes nothing. + // The canvas never leaves the PlayerView, so the frame is the one + // ancestor in the way. + setContentFrameClipping(clipped = rect.top + rect.height <= parentHeight) + // libass scales the script to the frame it is given, so the ASS overlay + // must NOT follow the canvas into the bar — authored typesetting keeps + // the picture on every preset. + libassBridge?.constrainOverlayHeight( + if (rect.height > pictureRect.height) pictureRect.height else 0, + ) + applyLayoutParams( + subtitleView = subtitleView, + width = rect.width, + height = rect.height, + leftMargin = rect.left, + topMargin = rect.top, + gravity = gravity, + ) + applyBottomPadding( + subtitleView = subtitleView, + position = position, + canvasHeight = rect.height, + canvasBottomInPlayerSpace = parentTop + rect.top + rect.height, + playerHeight = playerView.height, + ) + logSubtitleCanvasGeometry( + playerView = playerView, + subtitleView = subtitleView, + appliedLabel = "${rect.width}x${rect.height}@${rect.left},${rect.top}", + resizeMode = resizeMode, + bottomPaddingFraction = canvasBottomPaddingFraction, + playerBottomInParentSpace = playerBottomInParentSpace, + ) + } + + /** + * Writes the resolved bottom padding to both cue paths at once. Media3 + * reads the text one straight off the view; the bitmap one is baked into + * the cues, so a change has to re-forward whatever is on screen. + */ + private fun applyBottomPadding( + subtitleView: SubtitleView, + position: SubtitlePositionPreset, + canvasHeight: Int, + canvasBottomInPlayerSpace: Int, + playerHeight: Int, + ) { + val fraction = subtitleBottomPaddingFractionForCanvas( + position = position, + titleSafeFraction = titleSafeFraction, + canvasHeight = canvasHeight, + canvasBottomInPlayerSpace = canvasBottomInPlayerSpace, + playerHeight = playerHeight, + ) + subtitleView.setBottomPaddingFraction(fraction) + canvasBottomPaddingFraction = fraction + } + + /** + * The content frame clips its children, which is exactly right for the + * video surface and exactly wrong for a caption canvas that is meant to + * reach into the letterbox bar below the picture. Toggled rather than + * disabled once, so a preset or aspect change puts the clip back, and + * restored on [dispose] because the PlayerView outlives this sync. + */ + private fun setContentFrameClipping(clipped: Boolean) { + if (clipped == contentFrameClipped) return + val frame = contentFrameRef.get() ?: return + contentFrameClipped = clipped + frame.clipChildren = clipped + frame.clipToPadding = clipped + // A parent's clipChildren clips each CHILD'S drawing to that child's own + // bounds — so with the frame un-clipped, the PlayerView would still cut + // the frame's overflow (our canvas in the bar) at the picture's edge. + // Verified on a Shield: captions stopped exactly at the frame bottom + // until this was released too. The PlayerView's own bounds are never + // exceeded (the canvas ends at the PlayerView bottom), and its parent + // keeps clipping to it, so nothing can escape the player surface. + (frame.parent as? ViewGroup)?.let { host -> + host.clipChildren = clipped + host.clipToPadding = clipped + } + } + + /** + * Writes the caption canvas geometry, and keeps writing until the VIEW — + * not just its params object — has actually adopted it. + * + * The invariant this defends: `LayoutParams` are a request, `left/top/ + * width/height` are the answer, and the two can disagree indefinitely. + * Writing the params and calling `requestLayout()` does not settle it. When + * the video size arrives and `exo_content_frame` narrows to the letterboxed + * aspect, the frame measures its children FIRST and dispatches + * `onLayoutChange` after, so the canvas is measured at the outgoing aspect + * and the corrected params land a beat too late. Nothing re-measures the + * frame afterwards — see [requestSubtitleLayout] for why the follow-up + * request never gets serviced — and a params-only diff sees no change on + * every later pass and never asks again. Measured on a Shield: a 2.39:1 + * title after a 16:9 measurement left a 1728x972 canvas hanging 361px below + * an 803px frame, bottom-anchored cues drawn off screen, for the whole + * session. + * + * So: diff the params to decide what to WRITE, and diff the laid-out bounds + * to decide whether the canvas still needs PLACING — by request first, and + * by [placeSubtitleCanvas] when the request goes unanswered. + */ + private fun applyLayoutParams( + subtitleView: View, + width: Int, + height: Int, + leftMargin: Int, + topMargin: Int, + gravity: Int, + ) { + val requested = RequestedCanvas(width, height, leftMargin, topMargin, gravity) + if (requested != requestedCanvas) { + requestedCanvas = requested + relayoutAttempts = 0 + } val current = subtitleView.layoutParams as? FrameLayout.LayoutParams - val params = current ?: FrameLayout.LayoutParams(rect.width, rect.height) - val gravity = Gravity.TOP or Gravity.START + val params = current ?: FrameLayout.LayoutParams(width, height) if ( current == null || - params.width != rect.width || - params.height != rect.height || - params.leftMargin != rect.left || - params.topMargin != rect.top || + params.width != width || + params.height != height || + params.leftMargin != leftMargin || + params.topMargin != topMargin || params.gravity != gravity ) { - params.width = rect.width - params.height = rect.height - params.leftMargin = rect.left - params.topMargin = rect.top + params.width = width + params.height = height + params.leftMargin = leftMargin + params.topMargin = topMargin params.gravity = gravity subtitleView.layoutParams = params - subtitleView.requestLayout() + relayoutAttempts = 0 + requestSubtitleLayout(subtitleView) + return } + // Params already say the right thing, which is not the same as the view + // having been laid out that way. `isLayoutRequested` is deliberately NOT + // consulted: the stuck state IS a set flag that no ancestor acts on, so + // reading it as "a layout is coming" is what makes the wedge permanent. + // The laid-out bounds are the only honest signal. + if (subtitleLayoutMatches(subtitleView, width, height, leftMargin, topMargin)) return + requestSubtitleLayout(subtitleView) + placeSubtitleCanvas(subtitleView, width, height, leftMargin, topMargin) + } + + /** + * Asks the framework for a layout, bounded by [MAX_SUBTITLE_RELAYOUT_ATTEMPTS]. + * + * This is the polite path and it is not sufficient on its own, which is why + * [placeSubtitleCanvas] follows it. Measured on a Shield: the request does + * reach `exo_content_frame` (its `isLayoutRequested` flips to true), and the + * frame is then never laid out again — the PlayerView is hosted in a Compose + * `AndroidView`, whose holder answers a child's `requestLayout()` by + * invalidating its own Compose layout node rather than scheduling a View + * traversal, and with the node's constraints unchanged nothing re-measures + * the interop subtree. The frame keeps a pending request forever and the + * canvas keeps the geometry of whichever aspect ratio was measured first. + * + * A request issued while the tree is in layout, or while the parent has its + * own pending one, is posted instead: `View.requestLayout` is dropped + * outright by `ViewRootImpl` in the first case and stops walking up in the + * second. + */ + private fun requestSubtitleLayout(subtitleView: View) { + val parent = subtitleView.parent as? View + if (subtitleView.isInLayout || parent?.isLayoutRequested == true) { + if (pendingLayoutRequest) return + pendingLayoutRequest = true + subtitleView.post { + pendingLayoutRequest = false + if (!isDisposed && subtitleView.isAttachedToWindow) { + issueLayoutRequest(subtitleView) + } + } + return + } + issueLayoutRequest(subtitleView) + } + + private fun issueLayoutRequest(subtitleView: View) { + if (relayoutAttempts >= MAX_SUBTITLE_RELAYOUT_ATTEMPTS) return + relayoutAttempts++ + subtitleView.requestLayout() + } + + /** + * Measures and lays the caption canvas out directly, at the geometry this + * sync just computed. + * + * Doing a child's layout by hand is unusual and deliberate: the whole point + * of this class is that the subtitle canvas's bounds are ours to decide — + * they are derived from the content frame, not negotiated with it — and the + * hosting arrangement (see [requestSubtitleLayout]) provides no reliable way + * to have the parent do it. The measurement is EXACTLY the requested size, + * the same spec `FrameLayout` would produce from these params, so this is + * the layout the parent would have run, run at the only moment anyone is + * willing to run it. `SubtitleView.onLayout` still positions its own + * children from here, so the ASS overlay keeps matching. + * + * Only ever reached when the bounds already disagree, so it cannot fight a + * parent that is doing its job. Deferred out of an in-progress layout pass, + * where measuring another subtree is not safe. + */ + private fun placeSubtitleCanvas( + subtitleView: View, + width: Int, + height: Int, + leftMargin: Int, + topMargin: Int, + ) { + if (subtitleView.isInLayout) { + if (pendingCanvasPlacement) return + pendingCanvasPlacement = true + subtitleView.post { + pendingCanvasPlacement = false + if ( + !isDisposed && + subtitleView.isAttachedToWindow && + !subtitleLayoutMatches(subtitleView, width, height, leftMargin, topMargin) + ) { + measureAndLayoutSubtitleCanvas( + subtitleView, + width, + height, + leftMargin, + topMargin, + ) + } + } + return + } + measureAndLayoutSubtitleCanvas(subtitleView, width, height, leftMargin, topMargin) + } + + private fun measureAndLayoutSubtitleCanvas( + subtitleView: View, + width: Int, + height: Int, + leftMargin: Int, + topMargin: Int, + ) { + val parent = subtitleView.parent as? View + val resolvedWidth = if (width == FrameLayout.LayoutParams.MATCH_PARENT) { + parent?.width ?: return + } else { + width + } + val resolvedHeight = if (height == FrameLayout.LayoutParams.MATCH_PARENT) { + parent?.height ?: return + } else { + height + } + if (resolvedWidth <= 0 || resolvedHeight <= 0) return + subtitleView.measure( + View.MeasureSpec.makeMeasureSpec(resolvedWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(resolvedHeight, View.MeasureSpec.EXACTLY), + ) + subtitleView.layout( + leftMargin, + topMargin, + leftMargin + resolvedWidth, + topMargin + resolvedHeight, + ) + } + + /** + * Whether the view's laid-out bounds already are the requested canvas. + * + * A child with exact params is measured EXACTLY, and the sync always lays + * out TOP|START, so the margins are the expected origin inside the content + * frame. A view that has never been laid out counts as matching: its first + * layout is already on the way. + */ + private fun subtitleLayoutMatches( + subtitleView: View, + width: Int, + height: Int, + leftMargin: Int, + topMargin: Int, + ): Boolean { + if (subtitleView.width <= 0 && subtitleView.height <= 0) return true + val parent = subtitleView.parent as? View + val expectedWidth = if (width == FrameLayout.LayoutParams.MATCH_PARENT) { + parent?.width ?: return true + } else { + width + } + val expectedHeight = if (height == FrameLayout.LayoutParams.MATCH_PARENT) { + parent?.height ?: return true + } else { + height + } + return subtitleView.width == expectedWidth && + subtitleView.height == expectedHeight && + subtitleView.left == leftMargin && + subtitleView.top == topMargin } private fun dispose(view: View?) { if (isDisposed) return + val playerView = (view as? PlayerView) ?: playerViewRef.get() + setContentFrameClipping(clipped = true) + isDisposed = true + reconciliationGeneration++ + clearPendingPostLayoutUpdate() + clearPendingVerification(playerView) observedPlayer?.removeListener(this) observedPlayer = null - val playerView = (view as? PlayerView) ?: playerViewRef.get() + // Cues hold decoded bitmaps; do not outlive the view they were for. + lastCueGroup = null playerView?.removeOnLayoutChangeListener(this) playerView?.removeOnAttachStateChangeListener(this) contentFrameRef.get()?.removeOnLayoutChangeListener(this) - isDisposed = true + } + + private fun clearPendingPostLayoutUpdate() { + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) { + observer.removeOnPreDrawListener(postLayoutUpdate) + } + } + pendingPreDrawObserver = null + } + + private fun clearPendingVerification(playerView: PlayerView?) { + pendingVerification?.let { verification -> + playerView?.removeCallbacks(verification) + } + pendingVerification = null } } +@UnstableApi private fun PlayerView.contentFrameSubtitleRect(): SubtitleVideoRect? { val frame = findViewById( androidx.media3.ui.R.id.exo_content_frame @@ -798,18 +1747,6 @@ private fun Format.isHearingImpairedSubtitle(): Boolean = * (`MimeTypes.APPLICATION_PGS` / `APPLICATION_DVBSUBS`) all classify * identically — Apple parity with `ApplePlaybackRoutePlanner`'s token set. */ -fun isBitmapSubtitleCodecOrMime(codecOrMime: String?): Boolean { - val normalized = codecOrMime - ?.filter { it.isLetterOrDigit() } - ?.lowercase() - ?.takeIf { it.isNotEmpty() } - ?: return false - return normalized.contains("pgs") || - normalized.contains("dvd") || - normalized.contains("dvbsub") || - normalized.contains("vobsub") -} - private fun Format.subtitleCodecOrMime(): String? = if (sampleMimeType == MEDIA3_CUES_MIME_TYPE) { codecs ?: sampleMimeType @@ -834,3 +1771,74 @@ private fun Tracks.describeTextTracks(): String { private const val TAG = "PrairieSubtitles" private const val MEDIA3_CUES_MIME_TYPE = "application/x-media3-cues" + +/** + * Diagnostic tag for subtitle placement. Silent unless explicitly enabled: + * + * adb shell setprop log.tag.PrairieSubtitleGeom DEBUG + * + * Placement here spans three coordinate spaces — window, PlayerView, and the + * content frame the SubtitleView is actually a child of — and then the cue's + * own anchoring on top. A caption that lands in the wrong place looks identical + * whichever of those is at fault, and static reading has twice now produced a + * confident answer that the device disagreed with. These print the real numbers + * so the space at fault can be read off rather than deduced. + */ +private const val SUBTITLE_GEOM_TAG = "PrairieSubtitleGeom" + +/** + * Where the caption canvas ended up, in every space at once. Compare + * `subtitleView` (on screen, after layout) against `player` and `frame`: if the + * canvas is centred on screen and the text still is not, the cue is positioning + * itself and [logSubtitleCueGeometry] has the answer instead. + */ +@UnstableApi +private fun logSubtitleCanvasGeometry( + playerView: PlayerView, + subtitleView: View, + appliedLabel: String, + resizeMode: Int, + bottomPaddingFraction: Float? = null, + playerBottomInParentSpace: Int? = null, +) { + if (!Log.isLoggable(SUBTITLE_GEOM_TAG, Log.DEBUG)) return + val frame = playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ) + val playerLoc = IntArray(2).also(playerView::getLocationOnScreen) + val subtitleLoc = IntArray(2).also(subtitleView::getLocationOnScreen) + Log.d( + SUBTITLE_GEOM_TAG, + "resize=" + resizeMode + + " player=" + playerView.width + "x" + playerView.height + + "@" + playerLoc[0] + "," + playerLoc[1] + + " frame=" + frame?.width + "x" + frame?.height + + "@" + frame?.left + "," + frame?.top + + " frameClip=" + frame?.clipChildren + + " applied=" + appliedLabel + + " playerBottomInParent=" + playerBottomInParentSpace + + " bottomPad=" + bottomPaddingFraction + + " subtitleView=" + subtitleView.width + "x" + subtitleView.height + + "@" + subtitleLoc[0] + "," + subtitleLoc[1] + + " subtitleBottomOnScreen=" + (subtitleLoc[1] + subtitleView.height) + + " subtitleParent=" + (subtitleView.parent as? View)?.javaClass?.simpleName, + ) +} + +/** The cue's own anchoring, which positions text independently of the canvas. */ +private fun logSubtitleCueGeometry(cues: List) { + if (!Log.isLoggable(SUBTITLE_GEOM_TAG, Log.DEBUG)) return + val cue = cues.firstOrNull() ?: return + Log.d( + SUBTITLE_GEOM_TAG, + "cue bitmap=" + (cue.bitmap != null) + + " position=" + cue.position + + " positionAnchor=" + cue.positionAnchor + + " size=" + cue.size + + " line=" + cue.line + + " lineType=" + cue.lineType + + " lineAnchor=" + cue.lineAnchor + + " textAlignment=" + cue.textAlignment + + " text=" + cue.text?.toString()?.take(28), + ) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt index 372d5c640..b10eeb4b9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt @@ -3,13 +3,14 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId import org.prairieserver.prairie.playback.isTextSubtitleCodecFamily +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired -private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "prairie-subtitle:" -private const val DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = - "prairie-downloaded-subtitle:" +private const val SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = "silo-subtitle:" /** * Stable Media3 identity for a server-authored subtitle artifact. @@ -19,16 +20,12 @@ private const val DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = fun subtitleArtifactTrackId(serverIndex: Int): String = "$SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$serverIndex" -/** Stable Media3 identity derived only from the persistent downloaded-subtitle row ID. */ -fun downloadedSubtitleArtifactTrackId(downloadId: Int): String = - "$DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$downloadId" - /** * True when a mounted Media3 `Format.id` denotes [expected]. * * A sidecar merged with the primary stream comes back from Media3 carrying the - * MergingMediaSource child index: the id we authored as `prairie-subtitle:0` is - * reported as `1:prairie-subtitle:0`, while the primary stream's own tracks read + * MergingMediaSource child index: the id we authored as `silo-subtitle:0` is + * reported as `1:silo-subtitle:0`, while the primary stream's own tracks read * `0:3`, `0:4` and so on. Comparing with `==` therefore never matches a merged * sidecar, so the mount waits for a track that appears to be absent and the * whole subtitle transaction times out and rolls back. @@ -96,11 +93,25 @@ fun resolveMountedSubtitle( } else { typedMatches.filter { normalizedLabel(it.label) == targetLabel } } - return when { - labelMatches.size == 1 -> MountedSubtitleMatch(labelMatches.single()) - typedMatches.size == 1 -> MountedSubtitleMatch(typedMatches.single()) - else -> null + if (labelMatches.size == 1) return MountedSubtitleMatch(labelMatches.single()) + if (typedMatches.size == 1) return MountedSubtitleMatch(typedMatches.single()) + if (targetLabel != null) return null + + // An UNTITLED row among several same-language, same-family tracks. The + // catalog writes a codec-name placeholder ("SUBRIP", "PGS") for a stream + // that carries no title, and Media3 exposes that same stream with no label + // at all — so "untitled ↔ untitled" is the identity here, not a coincidence. + // A titled sibling ("Forced", "SDH") is a different track by definition, + // and when the row itself carries no SDH signal a track flagged SDH is + // not it either. Seen on a Shield: the plain English SubRip of a disc with + // Forced + plain + SDH resolved to nothing and the pick failed to apply. + val untitled = typedMatches.filter { it.isUntitled() } + val narrowed = if (media.hearingImpaired == true) { + untitled + } else { + untitled.filterNot { it.hearingImpaired == true } } + return narrowed.singleOrNull()?.let(::MountedSubtitleMatch) } /** @@ -242,30 +253,42 @@ private fun String?.normalizedNonServerTrackId(): String? = private fun String?.isReservedArtifactTrackId(): Boolean = this?.startsWith(SUBTITLE_ARTIFACT_TRACK_ID_PREFIX) == true || - this?.startsWith(DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX) == true + this?.startsWith( + org.prairieserver.prairie.playback.DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX, + ) == true internal fun PlayerSubtitleInfo.isDownloadedSubtitleArtifact(): Boolean = - source.normalizedValue().equals("downloaded", ignoreCase = true) || - catalogSource.normalizedValue().equals("downloaded", ignoreCase = true) + isLocalDownloadedSubtitle() private fun PlayerSubtitleInfo.effectiveSubtitleSource(): String? = source.normalizedValue() ?: catalogSource.normalizedValue() +/** + * A label that is only the stream's codec name is the catalog's placeholder + * for "no title" — it identifies nothing and must not be compared as a title. + */ +private val PLACEHOLDER_SUBTITLE_LABELS = setOf( + "subrip", "srt", "subtitle", "subtitles", "text", "utf8", "utf-8", "mov_text", + "ass", "ssa", "webvtt", "vtt", "pgs", "hdmv_pgs_subtitle", "pgssub", + "dvdsub", "dvd_subtitle", "vobsub", "dvbsub", "dvb_subtitle", +) + +/** + * A mounted track with no title of its own. Media3 leaves such a track's + * label empty, but the clients synthesise one from the language for display + * ("EN"), so a label that is only the language — code or canonical — is no + * title either. + */ +private fun MountedSubtitleTrack.isUntitled(): Boolean { + val label = normalizedLabel(this.label) ?: return true + val language = this.language.normalizedValue()?.lowercase() ?: return false + return label == language || + canonicalSubtitleLanguage(label) == canonicalSubtitleLanguage(language) +} + private fun normalizedLabel(label: String?): String? = - label.normalizedValue()?.lowercase() + label.normalizedValue()?.lowercase()?.takeUnless { it in PLACEHOLDER_SUBTITLE_LABELS } fun normalizedSubtitleCodecFamily(codecOrMime: String?): String? { return canonicalSubtitleCodecFamily(codecOrMime) } - -fun subtitleLabelIndicatesHearingImpaired(label: String?): Boolean { - val value = label?.lowercase() ?: return false - if ( - value.contains("closed caption") || - value.contains("hearing impaired") || - value.contains("hearing-impaired") - ) { - return true - } - return Regex("""(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""").containsMatchIn(value) -} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresets.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresets.kt index 1ac6f41e4..409268bed 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresets.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresets.kt @@ -46,6 +46,17 @@ object TrackSelectionPresets { * passthrough-capable routes because its `supportsFormat` score beats * FFmpeg's. TV route planning no longer advertises extension-only decode, * so FFmpeg remains here for forced-original and runtime recovery only. + * + * Deliberately NO preferred TEXT language. On TV the subtitle transaction + * adapter is the single owner of subtitle selection: it resolves a typed + * `SubtitleIdentity` and mounts it through `SubtitleManager`. A + * preferred-text hint here made `DefaultTrackSelector` a second, silent + * authority that enabled a text track on its own — playback obeyed the + * selector while the HUD reported the adapter's committed identity, so the + * two disagreed (subtitles on screen, "Off" in the HUD). The app decides; + * ExoPlayer executes. Text-track enablement is left untouched here so + * re-applying presets on a capability change cannot disturb a mounted + * subtitle either. */ fun buildTvParameters( context: Context, @@ -53,7 +64,6 @@ object TrackSelectionPresets { audioCaps: AudioPassthroughCapabilities, displayHdr: HdrCapabilities, preferredAudioLanguage: String?, - preferredTextLanguage: String?, allowHdr: Boolean = true, ffmpegAvailable: Boolean = FfmpegAudioSupport.isAvailable(), ): DefaultTrackSelector.Parameters { @@ -81,9 +91,6 @@ object TrackSelectionPresets { preferredAudioLanguage?.takeIf { it.isNotBlank() } ?.let { builder.setPreferredAudioLanguage(it) } - preferredTextLanguage?.takeIf { it.isNotBlank() } - ?.let { builder.setPreferredTextLanguage(it) } - return builder.build() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt index 1f4452543..c8be77268 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt @@ -2,13 +2,145 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle +import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired + +/** + * Subtitle artifacts attached to one Media3 media mount. + * + * Neutral v3 keeps the complete server inventory in UI state so the picker can + * display every choice. That inventory is not a preload list: attaching every + * inventory URL to the MediaItem makes Media3 open every sidecar eagerly and a + * slow duplicate request can block video preparation. The active plan owns the + * server artifact, while [subtitleIdentity] owns any client-local artifact. + * Exactly one external artifact may be attached: preloading every downloaded + * row recreates the same fan-out under different indexes. + * + * A missing plan is the legacy/offline path, where the supplied list remains + * the media-mount contract. + * + * [preferMuxedTracks]: protocol v3 types EVERY non-burn-in inventory row + * `delivery = sidecar`, including a row that merely describes a track muxed + * into the direct-play stream. Attaching the server-extracted artifact for such + * a row makes Media3 fetch and parse a whole SUP/SRT the stream already carries + * — the player stalls in BUFFERING while the sidecar loads and the cue backlog + * paints past the resume point. A caller whose selection path can resolve a + * server-row identity onto the muxed Media3 track (the TV mount latch does) + * passes true so that row mounts nothing and the in-stream track is used. + */ +fun subtitlesForVideoMediaMount( + subtitles: List, + playbackPlan: PlaybackExecutionPlan?, + subtitleIdentity: SubtitleIdentity, + preferMuxedTracks: Boolean = false, +): List { + if (playbackPlan == null) return subtitles + + val selected = when (subtitleIdentity) { + is SubtitleIdentity.ServerSidecar -> { + subtitleIdentity.serverIndex + .takeIf { it == playbackPlan.selectedTracks.subtitleIndex } + ?.let { serverIndex -> + subtitles.singleOrNull { subtitle -> + subtitle.index == serverIndex && !subtitle.isLocalDownloadedSubtitle() + } + } + ?.takeUnless { row -> + preferMuxedTracks && row.isMuxedInDirectPlayStream(playbackPlan) + } + } + is SubtitleIdentity.Downloaded -> subtitles.singleOrNull { subtitle -> + subtitle.isLocalDownloadedSubtitle() && + subtitle.downloadId == subtitleIdentity.downloadId + } + is SubtitleIdentity.LocalMedia3 -> subtitles.selectLocalMedia3Subtitle( + subtitleIdentity.media, + ) + SubtitleIdentity.Off, + is SubtitleIdentity.ServerBurnIn, + is SubtitleIdentity.Embedded, + -> null + } + return listOfNotNull(selected) +} + +/** + * True when this inventory row describes a track that is muxed into the + * stream Media3 is playing AND the client can render that track from the + * stream itself, so no server artifact needs attaching for it. + * + * Only the untouched original carries the file's own tracks; every remux / + * transcode delivery drops or rewrites them, and there the sidecar is the only + * way to get the subtitle. Bitmap families the client cannot decode in-stream + * are excluded too — for those the artifact (or burn-in) is the real path. + */ +internal fun PlayerSubtitleInfo.isMuxedInDirectPlayStream(plan: PlaybackExecutionPlan): Boolean { + if (plan.delivery != PlaybackDelivery.ORIGINAL_HTTP) return false + val embedded = catalogSource?.trim()?.equals("embedded", ignoreCase = true) == true || + (catalogSource == null && source?.trim()?.equals("embedded", ignoreCase = true) == true) + if (!embedded) return false + val family = canonicalSubtitleCodecFamily(codec ?: subtitleCodecFromUrl(url)) + return !isBitmapSubtitleCodecFamily(family) || isClientMountableBitmapCodecFamily(family) +} + +private fun List.selectLocalMedia3Subtitle( + identity: SubtitleMediaIdentity, +): PlayerSubtitleInfo? { + identity.trackId?.let { trackId -> + filter { subtitle -> + subtitle.serverTrackId == null && + subtitle.serverDelivery == null && + subtitle.mediaTrackId == trackId + }.singleOrNull()?.let { return it } + } + return filter { subtitle -> + subtitle.serverTrackId == null && + subtitle.serverDelivery == null && + subtitle.matchesLocalMediaIdentity(identity) + }.singleOrNull() +} + +private fun PlayerSubtitleInfo.matchesLocalMediaIdentity( + identity: SubtitleMediaIdentity, +): Boolean { + val comparisons = listOfNotNull( + identity.label?.let { expected -> + (catalogLabel ?: label)?.trim()?.equals(expected.trim(), ignoreCase = true) == true + }, + identity.language?.let { expected -> + canonicalSubtitleLanguage(language) == canonicalSubtitleLanguage(expected) + }, + identity.codecFamily?.let { expected -> + canonicalSubtitleCodecFamily(codec ?: subtitleCodecFromUrl(url)) == + canonicalSubtitleCodecFamily(expected) + }, + identity.forced?.let { expected -> forced == expected }, + identity.hearingImpaired?.let { expected -> + subtitleLabelIndicatesHearingImpaired(catalogLabel ?: label) == expected + }, + ) + return comparisons.isNotEmpty() && comparisons.all { it } +} + +private fun subtitleCodecFromUrl(url: String): String? = url + .substringBefore('?') + .substringBefore('#') + .substringAfterLast('/') + .substringAfterLast('.', "") + .takeIf(String::isNotBlank) data class VideoPlayerMediaSpec( /** - * Catalog identity of what is playing, carried onto the MediaItem so the - * playback service can resolve per-item preferences (subtitle sync) from - * the player alone rather than needing a side channel from the UI. + * Catalog identity of what is playing, carried onto the MediaItem for + * media-session identity and playback diagnostics. */ val contentId: String? = null, val streamUrl: String, diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessor.kt index ecab09cbb..b5c2e29f7 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessor.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessor.kt @@ -73,7 +73,10 @@ class DelayAudioProcessor : BaseAudioProcessor() { fun getActiveDelayMs(): Int = activeDelayMs + private var bytesPerFrame: Int = 0 + override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat { + bytesPerFrame = inputAudioFormat.bytesPerFrame // Output format == input; we don't resample or rechannel. // bytesPerFrame already accounts for channelCount * sampleSize, so // bytesPerSecond is simply sampleRate * bytesPerFrame. @@ -93,18 +96,34 @@ class DelayAudioProcessor : BaseAudioProcessor() { override fun queueInput(inputBuffer: ByteBuffer) { when { remainingHeadBytes > 0 -> { - // Positive delay: emit silence equal to remaining head bytes, - // then pass input through. + // Positive delay: emit ONLY silence until the head is paid off, + // leaving the input unconsumed so it is offered again. + // + // Emitting silence AND the input on the same pass was the bug: + // any delay longer than one decoder buffer produced + // silence, audio, silence, audio... — chopped, half-rate sound + // for the length of the offset, rather than a clean head delay. + // The old unit test used a single input buffer larger than the + // entire delay, so the streaming case it was meant to cover + // could not fail. val inputLen = inputBuffer.remaining() - val silenceLen = minOf(remainingHeadBytes, inputLen) - val outBuffer = replaceOutputBuffer(silenceLen + inputLen) + if (inputLen == 0) return + // Frame-aligned: a partial frame of silence would shift every + // channel by a fraction of a sample. + val alignedRemaining = if (bytesPerFrame > 0) { + remainingHeadBytes - (remainingHeadBytes % bytesPerFrame) + } else { + remainingHeadBytes + } + val silenceLen = minOf(alignedRemaining.coerceAtLeast(0), inputLen) + if (silenceLen <= 0) { + // Sub-frame remainder: drop it and start passing audio. + remainingHeadBytes = 0 + return + } + val outBuffer = replaceOutputBuffer(silenceLen) outBuffer.order(ByteOrder.nativeOrder()) - // Emit silence. - val silenceBytes = ByteArray(silenceLen) // zero-initialized - outBuffer.put(silenceBytes) - // Then emit input — via scratch to handle the self-aliased - // buffer case (see [scratch]). - copyThroughScratch(inputBuffer, outBuffer, inputLen) + outBuffer.put(ByteArray(silenceLen)) // zero-initialised outBuffer.flip() remainingHeadBytes -= silenceLen } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt index f94f6b5c9..be30f9184 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/audio/PassthroughSuppressionRegistry.kt @@ -8,12 +8,34 @@ data class PassthroughSuppressionSnapshot( val retryUsed: Boolean, ) +/** + * The write side of passthrough suppression, as a session manager sees it. + * + * It exists so a manager that does not drive a local audio sink can be handed + * [None] instead of the process-global registry. Cast preparation runs its own + * throwaway [org.prairieserver.prairie.common.player.PlaybackSessionManager], and its + * plan keys would otherwise reset the suppression set belonging to the phone's + * still-playing local session. + */ +interface PassthroughSuppressionScope { + fun beginAttempt(key: String) + + fun suppressForSinglePcmRetry(mime: String, channels: Int): Boolean + + /** Accepts and discards; for sessions whose audio never reaches a local sink. */ + object None : PassthroughSuppressionScope { + override fun beginAttempt(key: String) = Unit + + override fun suppressForSinglePcmRetry(mime: String, channels: Int): Boolean = false + } +} + /** * Attempt-scoped suppression for a passthrough encoding and channel layout. * A failed direct sink configuration gets one same-plan retry through a local * decoder/PCM renderer. New server plans clear the suppression set. */ -object PassthroughSuppressionRegistry { +object PassthroughSuppressionRegistry : PassthroughSuppressionScope { private data class Key(val mime: String, val channels: Int) private var attemptKey: String? = null @@ -21,7 +43,7 @@ object PassthroughSuppressionRegistry { private var retryUsed = false @Synchronized - fun beginAttempt(key: String) { + override fun beginAttempt(key: String) { if (attemptKey == key) return attemptKey = key blocked.clear() @@ -29,7 +51,7 @@ object PassthroughSuppressionRegistry { } @Synchronized - fun suppressForSinglePcmRetry(mime: String, channels: Int): Boolean { + override fun suppressForSinglePcmRetry(mime: String, channels: Int): Boolean { if (attemptKey == null || retryUsed || mime.isBlank()) return false retryUsed = true blocked += Key(mime.lowercase(), channels.coerceAtLeast(0)) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt index 66ef11df8..9f8c361b0 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt @@ -50,13 +50,15 @@ class Media3VideoPlaybackBackend( ) } - override fun selectSubtitle(track: VideoPlayerTrackEntry?): Boolean = - trackSelectionCoordinator.selectSubtitle( + override fun selectSubtitle(track: VideoPlayerTrackEntry?): Boolean { + if (track?.subtitle != null && mountedSpec == null) return false + return trackSelectionCoordinator.selectSubtitle( player = player, playerFactory = playerFactory, - mediaSpec = requireMediaSpecForExternalSubtitle(track), + mediaSpec = mountedSpec, selectedTrack = track, ) + } override fun selectMountedSubtitle( identity: SubtitleIdentity, @@ -88,7 +90,7 @@ class Media3VideoPlaybackBackend( preferredAudioLanguage: String?, preferredTextLanguage: String?, hdrEnabled: Boolean, - ) { + ): Boolean = playerFactory.applyTrackSelectionPresets( player = player, audioCaps = audioCaps, @@ -97,22 +99,9 @@ class Media3VideoPlaybackBackend( preferredTextLanguage = preferredTextLanguage, hdrEnabled = hdrEnabled, ) - } override fun release() { playerFactory.releasePlayer(player) } - private fun requireMediaSpecForExternalSubtitle(track: VideoPlayerTrackEntry?): VideoPlayerMediaSpec { - val spec = mountedSpec - if (spec != null) return spec - if (track?.subtitle == null) { - return VideoPlayerMediaSpec( - streamUrl = "", - playMethod = org.prairieserver.prairie.model.playback.PlayMethod.DIRECT, - serverUrl = "", - ) - } - error("Cannot select an external subtitle before video media has been mounted.") - } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/VideoPlaybackBackend.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/VideoPlaybackBackend.kt index 5683d935d..05b8e1904 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/VideoPlaybackBackend.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/VideoPlaybackBackend.kt @@ -15,6 +15,7 @@ interface VideoPlaybackBackend { val capabilities: VideoBackendCapabilities val player: Player + fun mount( spec: VideoPlayerMediaSpec, startPositionMs: Long = spec.startPositionMs, @@ -37,13 +38,14 @@ interface VideoPlaybackBackend { fun selectAudioTrack(track: VideoPlayerTrackEntry) + /** Returns whether presets were actually assigned; false = skipped. */ fun applyTrackSelection( audioCaps: AudioPassthroughCapabilities, displayHdr: HdrCapabilities = HdrCapabilities(), preferredAudioLanguage: String? = null, preferredTextLanguage: String? = null, hdrEnabled: Boolean = true, - ) + ): Boolean fun release() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparer.kt index f6c41f02b..8a90c123e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparer.kt @@ -2,25 +2,41 @@ package org.prairieserver.prairie.common.player.cast import android.util.Log import java.net.URLEncoder +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.common.player.PlaybackNetworkEvidenceProvider import org.prairieserver.prairie.common.player.PlaybackSessionManager -import org.prairieserver.prairie.common.player.mediaItemMimeType +import org.prairieserver.prairie.common.player.StagedVideoReplan +import org.prairieserver.prairie.common.player.VideoSessionStartV3 +import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionScope import org.prairieserver.prairie.common.player.resolvePlaybackStreamUrl +import org.prairieserver.prairie.common.player.seek.PlaybackSeekDecision +import org.prairieserver.prairie.common.player.seek.decideSeek +import org.prairieserver.prairie.common.player.seek.playerPositionForSource +import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer +import org.prairieserver.prairie.model.playback.CAPABILITY_EVIDENCE_DECLARED import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext -import org.prairieserver.prairie.model.playback.DETAILED_DECODE_CAPABILITIES_FEATURE -import org.prairieserver.prairie.model.playback.DEVICE_QUIRKS_V3_FEATURE -import org.prairieserver.prairie.model.playback.EngineCapabilityEnvelope -import org.prairieserver.prairie.model.playback.EngineSubtitleCapabilities -import org.prairieserver.prairie.model.playback.MEDIA3_ONLY_FEATURE -import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE +import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_HLS +import org.prairieserver.prairie.model.playback.DeliveryCapability +import org.prairieserver.prairie.model.playback.DeliverySubtitleCapabilities import org.prairieserver.prairie.model.playback.PlaybackDeviceContext -import org.prairieserver.prairie.model.playback.PlaybackEngineKind -import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackOutputContext -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse -import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_FEATURE +import org.prairieserver.prairie.model.playback.PlaybackPlanV3 +import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 +import org.prairieserver.prairie.model.playback.PlaybackTimeline +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR +import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily import org.prairieserver.prairie.model.playback.VideoDecodeCapability +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.PlaybackRepository @@ -32,17 +48,18 @@ import org.prairieserver.prairie.repository.PlaybackRepository * phone may be direct-playing an MKV/HEVC file the dongle cannot decode, and the * phone's stream URL is authenticated with an `Authorization` header that a * Cast receiver cannot send. So this helper opens a *separate* playback session - * that advertises a conservative Chromecast codec profile (H.264 / AAC / MP4), - * making the server return a cast-playable HLS or progressive MP4, then rewrites - * the resulting URL into a self-contained `?st=`-signed absolute URL the dongle - * can fetch on its own. + * that advertises a conservative Chromecast profile (H.264 / AAC / HLS-only), + * making the server return a cast-playable HLS manifest, then rewrites the + * resulting URL into a self-contained `?st=`-signed absolute URL the dongle can + * fetch on its own. * * It deliberately uses its OWN [PlaybackSessionManager] instance (never the DI * singleton) because that class holds a single `activeVideoAttempt` reference — * driving a cast session through the shared instance would clobber the phone's - * local playback session. The cast start path ([PlaybackSessionManager.startSessionV2]) - * is stateless with respect to that reference, so a throwaway instance is safe - * and cheap. + * local playback session. For the same reason it passes + * [PassthroughSuppressionScope.None]: the suppression registry is + * process-global, and a cast plan must not reset the suppression state of the + * phone's own audio sink. */ class CastPlaybackPreparer( private val playbackRepository: PlaybackRepository, @@ -56,35 +73,35 @@ class CastPlaybackPreparer( * fails — the caller should surface a "couldn't cast" notice. */ suspend fun prepareCastMedia(request: CastPrepareRequest): CastMediaSpec? { - val capabilities = chromecastCodecCapabilities() - val context = chromecastPlaybackContext(request.appVersion) - - // Separate manager instance so the phone's activeVideoAttempt is untouched. + // Separate manager instance so the phone's activeVideoAttempt and its + // passthrough suppression state are both untouched. val castSession = PlaybackSessionManager( playbackRepository = playbackRepository, tokenManager = tokenManager, networkEvidenceProvider = networkEvidenceProvider, + passthroughSuppression = PassthroughSuppressionScope.None, ) - val result = castSession.startSessionV2( + val result = castSession.startVideoSessionV3( fileId = request.fileId, profileId = request.profileId, - capabilities = capabilities, + capabilities = chromecastCodecCapabilities(), + clientPlaybackContext = chromecastPlaybackContext( + appVersion = request.appVersion, + buildIdentity = request.buildIdentity, + ), audioTrackIndex = request.audioTrackIndex, subtitleTrackIndex = request.subtitleTrackIndex, qualityPreference = "auto", startPosition = request.startPositionSeconds, - clientPlaybackContext = context, - preserveDirectAudioSelection = false, - // A Cast receiver can seek direct play (HTTP Range on the real - // file) and an encoded HLS transcode (full VOD manifest), but - // never the progressive remux pipe. The flag makes the server - // upgrade a would-be remux to a transcode session; direct-playable - // files stay direct at original quality for free. - seekableStreamsOnly = true, + // Cast subtitles ride as WebVTT text tracks, never burn-in and never + // libass. COMPATIBLE tells the server to convert an ASS track into + // WebVTT rather than treating a styling loss as a reason to burn it + // into the video (or to fail the plan outright). + subtitleFidelityPreference = SubtitleFidelityPreference.COMPATIBLE, ) - val startedSession = when (result) { + val started = when (result) { is ApiResult.Success -> result.data is ApiResult.Error -> { Log.w(TAG, "Cast session start failed: ${result.code} ${result.message}") @@ -96,126 +113,147 @@ class CastPlaybackPreparer( } } + val ready = when (started) { + is VideoSessionStartV3.Ready -> started + is VideoSessionStartV3.Terminal -> { + Log.w(TAG, "Cast session terminal: ${started.reason} ${started.message}") + return null + } + VideoSessionStartV3.ServerUpgradeRequired -> { + Log.w(TAG, "Cast session refused: server does not speak playback protocol v3") + return null + } + } + // From here on a server session exists. If the preparing coroutine is // cancelled before the spec is handed to the Cast SDK, stop the // session — otherwise it lingers, counts against the account's // concurrent-stream cap, and every later cast start 429s. + val sessionId = ready.session.sessionId + val handle = CastPlaybackSessionHandle( + sessionManager = castSession, + request = request, + initialReady = ready, + specFactory = { replacement, owner -> + buildCastMediaSpec(request, replacement, owner) + }, + ) + var handedOff = false try { - return buildCastMediaSpec(request, startedSession, castSession) - } catch (e: kotlinx.coroutines.CancellationException) { - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { - castSession.stopSession(startedSession.sessionId) + val spec = buildCastMediaSpec(request, ready, handle) + handedOff = true + return spec + } finally { + if (!handedOff) { + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + castSession.stopSession(sessionId) + } } - throw e } } private suspend fun buildCastMediaSpec( request: CastPrepareRequest, - startedSession: PlaybackSessionResponse, - castSession: PlaybackSessionManager, - ): CastMediaSpec? { - // A transcode start response only carries a placeholder manifest URL: - // the manifest goes live (and gains its full-recipe ?st= token) when - // the transcode is actually started. The phone drives that here — the - // receiver only ever sees the final signed manifest URL. - val session = if (startedSession.playMethod == PlayMethod.TRANSCODE) { - val seekSeconds = startedSession.position - .takeIf { it.isFinite() && it >= 0.0 } - ?: request.startPositionSeconds - val transcodeResult = castSession.startTranscodeFallback( - session = startedSession, - seekSeconds = seekSeconds, - resolution = CAST_TRANSCODE_RESOLUTION, - mode = PlaybackSessionManager.TranscodeMode.FULL, - audioTrackIndex = request.audioTrackIndex, - // Cast subtitles ride as WebVTT text tracks, never burn-in. - subtitleTrackIndex = null, - ) - when (transcodeResult) { - is ApiResult.Success -> transcodeResult.data - is ApiResult.Error -> { - Log.w(TAG, "Cast transcode start failed: ${transcodeResult.code} ${transcodeResult.message}") - castSession.stopSession(startedSession.sessionId) - return null - } - is ApiResult.NetworkError -> { - Log.w(TAG, "Cast transcode start network error: ${transcodeResult.exception}") - castSession.stopSession(startedSession.sessionId) - return null - } - } - } else { - startedSession - } - + ready: VideoSessionStartV3.Ready, + handle: CastPlaybackSessionHandle, + ): CastMediaSpec { + val plan = ready.plan val serverUrl = tokenManager.getServerUrl() val token = tokenManager.getAccessToken() - val plan = session.playbackPlan - val castUrl = signStreamUrl( - resolvePlaybackStreamUrl(serverUrl, session.streamUrl), - token, - ) + // Unlike the legacy two-step start, a v3 plan's stream URL is already + // live and fully signed when the plan is returned — the server prepares + // the transport inside the start handler — so there is nothing to + // "start" before handing the URL to the receiver. + val castUrl = signStreamUrl(resolvePlaybackStreamUrl(serverUrl, plan.stream.url), token) + // The receiver must know whether the URL is an HLS manifest or a - // progressive stream. The legacy plan often fails tolerant parsing when - // the server answers with a V3-shaped plan, so a null plan is the NORMAL - // case here — and guessing "HLS" from playMethod alone told the receiver - // to parse a progressive fMP4 remux stream as an m3u8 playlist. Trust the - // URL shape instead: only the transcode manifest is HLS. - val mimeType = if (plan?.delivery != null) { - mediaItemMimeType(session.playMethod, plan.source?.container, plan.delivery) - ?: castFallbackMimeType(session.streamUrl) - } else { - castFallbackMimeType(session.streamUrl) - } + // progressive stream. The plan states it outright, so guessing from the + // URL shape is only a last resort for a plan that omitted the mime. + val mimeType = plan.stream.mimeType?.takeIf { it.isNotBlank() } + ?: castFallbackMimeType(plan) + + return CastMediaSpec( + fileId = plan.effectiveMediaFileId ?: request.fileId, + streamUrl = castUrl, + mimeType = mimeType, + title = request.title, + posterUrl = request.posterUrl, + positionSeconds = castPlayerStartPosition(plan, request.startPositionSeconds), + durationSeconds = plan.source.durationSeconds ?: 0.0, + subtitles = castSubtitleTracks(plan, serverUrl, token, castUrl), + playbackSession = handle, + ) + } + /** + * The receiver's full subtitle menu, built from `plan.subtitle.inventory`. + * + * The inventory is the contract's authoritative track list, and it is the + * only source that carries every track: the session response projects just + * the one artifact the plan selected, so a cast started with subtitles off — + * or with one track chosen — would otherwise reach the receiver with an + * empty or single-entry CC menu. + */ + private fun castSubtitleTracks( + plan: PlaybackPlanV3, + serverUrl: String, + token: String?, + castUrl: String, + ): List { // Subtitle URLs are signed with the STREAM token from the cast URL, not // the account access token: the server stamps a session-scoped ?st= on // the stream URL only, and the subtitle route rejects anything else // (observed: all subtitle fetches 401'd with the access token). val streamToken = STREAM_TOKEN_VALUE_REGEX.find(castUrl)?.groupValues?.get(1) - val usableSubs = session.subtitleUrls.orEmpty().filter { it.url.isNotBlank() } - val labels = castSubtitleLabels(usableSubs) - val subtitles = usableSubs.mapIndexed { subIndex, sub -> - val base = forceVttFormat(resolvePlaybackStreamUrl(serverUrl, sub.url)) + val inventory = castSubtitleInventory(plan) + val labels = castSubtitleLabels(inventory) + val selectedIndex = plan.resolvedSelectedSubtitleIndex() + return inventory.mapIndexed { index, item -> + val receiverUrl = item.takeIf { it.isCastableAsVtt() }?.let { + forceVttExtension(resolvePlaybackStreamUrl(serverUrl, it.url.orEmpty())) + } CastSubtitleTrack( - url = if (streamToken != null && !STREAM_TOKEN_REGEX.containsMatchIn(base)) { - val sep = if (base.contains('?')) '&' else '?' - "$base${sep}st=$streamToken" - } else { - signStreamUrl(base, token) + trackId = item.trackId, + combinedIndex = item.combinedIndex, + receiverUrl = receiverUrl?.let { base -> + if (streamToken != null && !STREAM_TOKEN_REGEX.containsMatchIn(base)) { + val sep = if (base.contains('?')) '&' else '?' + "$base${sep}st=$streamToken" + } else { + signStreamUrl(base, token) + } }, - language = sub.language, - label = labels[subIndex], - // Activate the track the user selected on the phone. - selected = request.subtitleTrackIndex != null && - request.subtitleTrackIndex >= 0 && - sub.index == request.subtitleTrackIndex, + language = item.language, + label = labels[index], + // The returned plan owns selection. The original request's + // ordinal belongs to another file when the server adapts. + selected = item.combinedIndex == selectedIndex, ) } - - return CastMediaSpec( - fileId = request.fileId, - streamUrl = castUrl, - mimeType = mimeType, - title = request.title, - posterUrl = request.posterUrl, - positionSeconds = session.position.takeIf { it.isFinite() && it >= 0.0 } - ?: request.startPositionSeconds, - durationSeconds = session.durationSeconds ?: 0.0, - subtitles = subtitles, - ) } + /** + * Whether an inventory entry can reach the Cast Default Media Receiver, + * which renders WebVTT text tracks and nothing else. + * + * `burn_in_only` entries carry no URL at all. Bitmap tracks that DO get a + * sidecar URL — embedded PGS is published as `.sup` — are excluded too: the + * subtitle route answers 415 for a bitmap track requested as `.vtt`, so + * offering one would put a permanently-failing row in the CC menu. + */ + private fun PlaybackSubtitleInventoryItemV3.isCastableAsVtt(): Boolean = + delivery == SUBTITLE_DELIVERY_SIDECAR && + !url.isNullOrBlank() && + !isBitmapSubtitleCodecFamily(codec) + /** * Makes a stream URL self-contained for a Cast receiver by carrying the * session/access token in a `?st=` query parameter (the receiver can't send * the `Authorization` header the phone normally relies on). * * If the server already stamped an `st=` token onto the URL (transcode - * plans do this today), it is used as-is. The server is being updated - * separately to accept `?st=` as auth on stream routes. + * plans do this today), it is used as-is. */ private fun signStreamUrl(url: String, token: String?): String { if (token.isNullOrBlank()) return url @@ -233,11 +271,9 @@ class CastPlaybackPreparer( * "Danish"), forced tracks are marked, and same-language duplicates get a * counter ("English 2") so every menu row is distinct. */ - private fun castSubtitleLabels( - subs: List, - ): List { - val bases = subs.map { sub -> - val code = sub.language?.trim()?.takeIf { it.isNotBlank() } + private fun castSubtitleLabels(items: List): List { + val bases = items.map { item -> + val code = item.language?.trim()?.takeIf { it.isNotBlank() } val display = code?.let { c -> val locale = java.util.Locale.forLanguageTag(c.replace('_', '-').lowercase()) locale.displayLanguage @@ -246,7 +282,7 @@ class CastPlaybackPreparer( val base = display ?: code?.replaceFirstChar { it.uppercase() } ?: "Subtitle" - if (sub.forced == true) "$base (Forced)" else base + if (item.forced) "$base (Forced)" else base } val totals = bases.groupingBy { it }.eachCount() val seen = mutableMapOf() @@ -258,45 +294,59 @@ class CastPlaybackPreparer( } /** - * The Cast Default Media Receiver renders only WebVTT text tracks. The - * server defaults subtitle extraction to VTT but honors an explicit - * `format=` (the phone requests `ass` for libass rendering), so any - * format param is rewritten to `vtt` for the receiver. + * Rewrites a subtitle URL's PATH EXTENSION to `.vtt`, mirroring the server's + * own `forceSubtitleExtensionV3`. + * + * The extension is the whole request: the subtitle route parses the output + * format from the last `.` of the path segment and has no `format=` query + * parameter. An ASS track therefore has to be asked for as `.vtt` or it + * arrives as raw SSA that the receiver cannot render. */ - private fun forceVttFormat(url: String): String = - if (FORMAT_PARAM_REGEX.containsMatchIn(url)) { - url.replace(FORMAT_PARAM_REGEX) { "${it.groupValues[1]}format=vtt" } - } else { - val separator = if (url.contains('?')) '&' else '?' - "$url${separator}format=vtt" - } + private fun forceVttExtension(url: String): String { + if (url.isBlank()) return url + val queryStart = url.indexOf('?') + val path = if (queryStart >= 0) url.substring(0, queryStart) else url + val query = if (queryStart >= 0) url.substring(queryStart) else "" + val lastSlash = path.lastIndexOf('/') + val lastDot = path.lastIndexOf('.') + val stem = if (lastDot > lastSlash) path.substring(0, lastDot) else path + return "$stem$VTT_EXTENSION$query" + } private companion object { private const val TAG = "CastPlaybackPreparer" + private const val VTT_EXTENSION = ".vtt" - // Matches the conservative Chromecast codec profile: 1080p is the - // highest rung every generation of the hardware decodes. - private const val CAST_TRANSCODE_RESOLUTION = "1080p" private val STREAM_TOKEN_REGEX = Regex("[?&]st=") private val STREAM_TOKEN_VALUE_REGEX = Regex("[?&]st=([^&]+)") - private val FORMAT_PARAM_REGEX = Regex("([?&])format=[^&]*") /** - * Mime for a cast URL when the plan's delivery is unknown. The server's - * HLS delivery is always a `.m3u8` manifest URL; every other stream URL - * is progressive, and under the cast capability profile (mp4-only - * containers, progressive remux outputs fragmented MP4) that is always - * `video/mp4`. + * Mime for a cast URL when the plan did not state one. HLS is the only + * delivery this profile advertises, so anything else is a progressive + * stream — and under the cast capability profile (mp4-only containers) + * that is always `video/mp4`. */ - fun castFallbackMimeType(streamUrl: String): String = - if (streamUrl.substringBefore('?').contains(".m3u8")) { - "application/x-mpegURL" - } else { - "video/mp4" + fun castFallbackMimeType(plan: PlaybackPlanV3): String = + when (plan.stream.protocol) { + PlaybackStreamProtocol.HLS -> "application/x-mpegURL" + PlaybackStreamProtocol.HTTP_PROGRESSIVE -> "video/mp4" } } } +/** Cast receiver seeks use stream-local player time, never source time. */ +internal fun castPlayerStartPosition(plan: PlaybackPlanV3, requested: Double): Double = + plan.timeline.playerStartSeconds + .takeIf { it.isFinite() && it >= 0.0 } + ?: requested + +/** Uses the authoritative inventory exactly; an empty inventory means no tracks. */ +internal fun castSubtitleInventory(plan: PlaybackPlanV3): List = + plan.subtitle.inventory + +/** Stable phone/receiver id for an authoritative combined subtitle ordinal. */ +fun castReceiverTrackId(combinedIndex: Int): Long = combinedIndex.toLong() + 1L + /** Inputs captured from the live player state when the user starts casting. */ data class CastPrepareRequest( val fileId: Int, @@ -307,6 +357,7 @@ data class CastPrepareRequest( val title: String, val posterUrl: String?, val appVersion: String, + val buildIdentity: PrairieClientBuildIdentity, ) /** Self-contained media descriptor handed to the Cast receiver. */ @@ -319,10 +370,334 @@ data class CastMediaSpec( val positionSeconds: Double, val durationSeconds: Double, val subtitles: List, + /** Retained protocol-v3 owner for Cast progress, recovery and teardown. */ + val playbackSession: CastPlaybackSessionHandle, ) +/** Result of translating a source-time Cast seek against the active plan. */ +sealed interface CastSeekResult { + data class Native(val playerPositionSeconds: Double) : CastSeekResult + data class Replanned(val spec: CastMediaSpec) : CastSeekResult + data object Failed : CastSeekResult +} + +sealed interface CastSubtitleChangeResult { + data class Staged(val change: CastStagedSubtitleChange) : CastSubtitleChangeResult + data object Failed : CastSubtitleChangeResult +} + +/** + * A subtitle plan that the receiver may try without replacing the rendered + * server plan. The Cast owner commits it only after the receiver accepts the + * media load; a failed or stale load discards it and keeps the predecessor. + */ +class CastStagedSubtitleChange internal constructor( + val spec: CastMediaSpec, + internal val staged: StagedVideoReplan, + private val owner: CastPlaybackSessionHandle, +) { + suspend fun commit(): CastMediaSpec? = owner.commitSubtitleChange(this, staged) + + suspend fun discard() = owner.discardSubtitleChange(this, staged) +} + +/** + * Retains the Cast-only [PlaybackSessionManager] beyond preparation. + * + * The Cast SDK reports player-local time, while protocol v3 progress and + * replans use source time. This owner keeps the active timeline and translates + * every boundary. It also serializes Cast recovery so one failed load cannot + * create competing replacement sessions. + */ +class CastPlaybackSessionHandle internal constructor( + private val sessionManager: PlaybackSessionManager, + private val request: CastPrepareRequest, + initialReady: VideoSessionStartV3.Ready, + private val specFactory: suspend ( + VideoSessionStartV3.Ready, + CastPlaybackSessionHandle, + ) -> CastMediaSpec, +) { + private val ready = AtomicReference(initialReady) + private val terminal = AtomicBoolean(false) + private val recoveryMutex = Mutex() + private val pendingSubtitleChange = AtomicReference(null) + private val loadFailureRecoveryBudget = CastLoadRecoveryBudget( + maxAttempts = MAX_LOAD_FAILURE_RECOVERY_ATTEMPTS, + ) + + val sessionId: String + get() = ready.get().session.sessionId + + fun sourcePositionForPlayer(playerPositionSeconds: Double): Double { + val snapshot = ready.get() + return sourcePositionForPlayer(snapshot, playerPositionSeconds) + } + + fun playerPositionForSource(sourcePositionSeconds: Double): Double? { + val snapshot = ready.get() + return timeline(snapshot).playerPositionForSource(sourcePositionSeconds) + } + + fun sourceDurationSeconds(): Double = ready.get().plan.source.durationSeconds + ?.takeIf { it.isFinite() && it >= 0.0 } + ?: 0.0 + + suspend fun reportProgress(playerPositionSeconds: Double, isPaused: Boolean) { + if (terminal.get()) return + val snapshot = ready.get() + sessionManager.reportProgress( + sessionId = snapshot.session.sessionId, + position = sourcePositionForPlayer(snapshot, playerPositionSeconds), + isPaused = isPaused, + ) + } + + suspend fun recoverFromLoadFailure( + playerPositionSeconds: Double, + message: String, + ): CastMediaSpec? = recoveryMutex.withLock { + if (terminal.get() || pendingSubtitleChange.get() != null) return@withLock null + if (!loadFailureRecoveryBudget.tryConsume()) { + stopLocked(playerPositionSeconds, isPaused = true, reason = "load_recovery_exhausted") + return@withLock null + } + val snapshot = ready.get() + val sourcePosition = sourcePositionForPlayer(snapshot, playerPositionSeconds) + when ( + val result = sessionManager.replanActiveVideoSession( + classification = "cast_load_failed", + message = message, + positionSeconds = sourcePosition, + audioTrackIndex = snapshot.plan.selectedTracks.audio?.index ?: request.audioTrackIndex, + subtitleTrackIndex = snapshot.plan.resolvedSelectedSubtitleIndex() ?: -1, + diagnostics = mapOf("surface" to "google_cast"), + ) + ) { + is ApiResult.Success -> when (val replacement = result.data) { + is VideoSessionStartV3.Ready -> { + ready.set(replacement) + try { + specFactory(replacement, this) + } catch (failure: Throwable) { + stopLocked(playerPositionSeconds, isPaused = true, reason = "plan_mount_failed") + throw failure + } + } + else -> { + stopLocked(playerPositionSeconds, isPaused = true, reason = "plan_terminal") + null + } + } + else -> { + stopLocked(playerPositionSeconds, isPaused = true, reason = "plan_failed") + null + } + } + } + + /** A receiver-acknowledged load ends the current consecutive-failure run. */ + fun confirmReceiverLoadSucceeded() { + loadFailureRecoveryBudget.resetAfterSuccess() + } + + suspend fun selectSubtitleTrack( + playerPositionSeconds: Double, + combinedIndex: Int?, + ): CastSubtitleChangeResult = recoveryMutex.withLock { + if (terminal.get() || pendingSubtitleChange.get() != null) { + return@withLock CastSubtitleChangeResult.Failed + } + val snapshot = ready.get() + val sourcePosition = sourcePositionForPlayer(snapshot, playerPositionSeconds) + when ( + val result = sessionManager.stageActiveVideoSessionReplan( + classification = "subtitle_track_changed", + message = "Applying Cast subtitle selection.", + positionSeconds = sourcePosition, + audioTrackIndex = snapshot.plan.selectedTracks.audio?.index ?: request.audioTrackIndex, + subtitleTrackIndex = combinedIndex ?: -1, + diagnostics = mapOf("surface" to "google_cast"), + ) + ) { + is ApiResult.Success -> { + val staged = result.data + try { + val change = CastStagedSubtitleChange( + spec = specFactory(staged.candidate, this), + staged = staged, + owner = this, + ) + pendingSubtitleChange.set(change) + CastSubtitleChangeResult.Staged(change) + } catch (failure: Throwable) { + withContext(NonCancellable) { + sessionManager.discardStagedVideoReplan(staged) + } + throw failure + } + } + else -> CastSubtitleChangeResult.Failed + } + } + + internal suspend fun commitSubtitleChange( + change: CastStagedSubtitleChange, + staged: StagedVideoReplan, + ): CastMediaSpec? = recoveryMutex.withLock { + if (terminal.get() || !pendingSubtitleChange.compareAndSet(change, null)) { + return@withLock null + } + when (val committed = sessionManager.commitStagedVideoReplan(staged)) { + is ApiResult.Success -> { + ready.set(committed.data) + change.spec + } + else -> null + } + } + + internal suspend fun discardSubtitleChange( + change: CastStagedSubtitleChange, + staged: StagedVideoReplan, + ) = recoveryMutex.withLock { + if (!pendingSubtitleChange.compareAndSet(change, null)) return@withLock + withContext(NonCancellable) { + sessionManager.discardStagedVideoReplan(staged) + } + } + + suspend fun seekToSource(sourcePositionSeconds: Double): CastSeekResult = + recoveryMutex.withLock { + if (terminal.get() || pendingSubtitleChange.get() != null) { + return@withLock CastSeekResult.Failed + } + val snapshot = ready.get() + when (val decision = timeline(snapshot).decideSeek(sourcePositionSeconds)) { + is PlaybackSeekDecision.NativeSeek -> + CastSeekResult.Native(decision.targetPlayerPositionSeconds) + is PlaybackSeekDecision.ServerReanchor -> when ( + val result = sessionManager.reanchorActiveVideoSession( + positionSeconds = decision.targetSourcePositionSeconds, + diagnostics = mapOf( + "surface" to "google_cast", + "reason" to decision.reason.name.lowercase(), + ), + ) + ) { + is ApiResult.Success -> when (val replacement = result.data) { + is VideoSessionStartV3.Ready -> { + ready.set(replacement) + try { + CastSeekResult.Replanned(specFactory(replacement, this)) + } catch (failure: Throwable) { + stopLocked( + playerPositionSeconds = replacement.plan.timeline.playerStartSeconds, + isPaused = true, + reason = "seek_mount_failed", + ) + throw failure + } + } + else -> { + stopLocked( + playerPositionSeconds = snapshot.plan.timeline.playerStartSeconds, + isPaused = true, + reason = "seek_terminal", + ) + CastSeekResult.Failed + } + } + else -> CastSeekResult.Failed + } + } + } + + suspend fun stop( + playerPositionSeconds: Double, + isPaused: Boolean, + reason: String = "stopped", + ) = recoveryMutex.withLock { + stopLocked(playerPositionSeconds, isPaused, reason) + } + + private suspend fun stopLocked( + playerPositionSeconds: Double, + isPaused: Boolean, + reason: String, + ) { + if (!terminal.compareAndSet(false, true)) return + val snapshot = ready.get() + withContext(NonCancellable) { + pendingSubtitleChange.getAndSet(null)?.let { change -> + sessionManager.discardStagedVideoReplan(change.staged) + } + sessionManager.reportActiveVideoEvent( + event = "stopped", + diagnostics = mapOf( + "surface" to "google_cast", + "reason" to reason, + ), + ) + runCatching { + sessionManager.reportProgress( + sessionId = snapshot.session.sessionId, + position = sourcePositionForPlayer(snapshot, playerPositionSeconds), + isPaused = isPaused, + ) + } + sessionManager.stopSession(snapshot.session.sessionId) + } + } + + private fun sourcePositionForPlayer( + snapshot: VideoSessionStartV3.Ready, + playerPositionSeconds: Double, + ): Double = timeline(snapshot).sourcePositionForPlayer(playerPositionSeconds) + ?: snapshot.plan.timeline.sourceStartSeconds.coerceAtLeast(0.0) + + private fun timeline(snapshot: VideoSessionStartV3.Ready): PlaybackTimeline = + snapshot.plan.timeline.let { value -> + PlaybackTimeline( + sourceStartSeconds = value.sourceStartSeconds, + playerStartSeconds = value.playerStartSeconds, + streamOriginSeconds = value.streamOriginSeconds, + timelineOffsetSeconds = value.timelineOffsetSeconds, + seekWindowStartSeconds = value.seekWindowStartSeconds, + seekWindowEndSeconds = value.seekWindowEndSeconds, + canSeekAnywhere = value.canSeekAnywhere, + seekRestoration = value.seekRestoration, + ) + } + + private companion object { + const val MAX_LOAD_FAILURE_RECOVERY_ATTEMPTS = 3 + } +} + +internal class CastLoadRecoveryBudget( + private val maxAttempts: Int, +) { + private val attempts = AtomicInteger(0) + + fun tryConsume(): Boolean { + while (true) { + val current = attempts.get() + if (current >= maxAttempts) return false + if (attempts.compareAndSet(current, current + 1)) return true + } + } + + fun resetAfterSuccess() { + attempts.set(0) + } +} + data class CastSubtitleTrack( - val url: String, + val trackId: String, + val combinedIndex: Int, + /** Null for burn-in-only/receiver-incompatible inventory rows. */ + val receiverUrl: String?, val language: String?, val label: String, val selected: Boolean, @@ -332,9 +707,13 @@ data class CastSubtitleTrack( * A static, conservative codec profile every Chromecast can decode. Unlike * [org.prairieserver.prairie.common.player.PlaybackCapabilityDetector.detect], this * NEVER probes the phone — the phone's decoders are irrelevant to what the - * dongle can play. + * dongle can play, which is also why the evidence tier is `declared`: nothing + * here was measured, and a `declared` profile is deliberately not eligible for + * audio passthrough. */ fun chromecastCodecCapabilities(): ClientCodecCapabilities = ClientCodecCapabilities( + videoEvidence = CAPABILITY_EVIDENCE_DECLARED, + audioEvidence = CAPABILITY_EVIDENCE_DECLARED, codecsVideo = listOf("h264"), codecsVideoHardware = listOf("h264"), codecsAudio = listOf("aac", "mp3"), @@ -359,27 +738,22 @@ fun chromecastCodecCapabilities(): ClientCodecCapabilities = ClientCodecCapabili /** * Mirrors the shape [org.prairieserver.prairie.common.player.PlaybackCapabilityDetector.detectPlaybackContext] - * produces, but declares the Chromecast-oriented engine set (HLS + direct MP4) - * instead of the phone's probed engines. Progressive remux is deliberately - * absent — see the inline note. + * produces, but declares the Chromecast-oriented delivery set instead of the + * phone's probed one. Progressive delivery is deliberately absent — see the + * inline note. */ -fun chromecastPlaybackContext(appVersion: String): ClientPlaybackContext { - val videoCodecs = listOf("h264") - val audioCodecs = listOf("aac", "mp3") - val plainTextSubtitles = EngineSubtitleCapabilities( - embeddedText = true, - sidecarText = true, - ) - return ClientPlaybackContext( - features = listOf( - PLAYBACK_PLAN_V3_FEATURE, - SEEK_REANCHOR_V3_FEATURE, - MEDIA3_ONLY_FEATURE, - DETAILED_DECODE_CAPABILITIES_FEATURE, - DEVICE_QUIRKS_V3_FEATURE, - ), +fun chromecastPlaybackContext( + appVersion: String, + buildIdentity: PrairieClientBuildIdentity, +): ClientPlaybackContext = + ClientPlaybackContext( formFactor = "mobile", appVersion = appVersion, + // Required rather than defaulted: this context describes the phone + // driving the cast, so a caller that forgot the identity would report + // a session with no build at all. + appBuild = buildIdentity.reportedBuildNumber, + appChannel = buildIdentity.reportedChannel, device = PlaybackDeviceContext(), output = PlaybackOutputContext( hdrDetails = null, @@ -387,24 +761,25 @@ fun chromecastPlaybackContext(appVersion: String): ClientPlaybackContext { currentSink = "cast_receiver", sinkType = "cast", ), - engines = mapOf( - PlaybackEngineKind.MEDIA3_HLS to EngineCapabilityEnvelope( + // HLS is deliberately the ONLY delivery class. Any progressive delivery + // (original or remux) reaches the receiver as a chunked/range-less + // stream it cannot seek — the slider and ±30s skips restarted playback + // from zero, and the receiver reported a live, growing duration. + // Advertising original_http was enough for the planner to keep choosing + // progressive remux, so it goes too: with HLS alone the server must + // serve a VOD manifest, which the receiver seeks natively via segments. + deliveries = mapOf( + DELIVERY_CLASS_HLS to DeliveryCapability( enabled = true, supportedOnDevice = true, containers = listOf("m3u8", "hls"), - videoCodecs = videoCodecs, - audioDecodeCodecs = audioCodecs, - subtitles = plainTextSubtitles, + videoCodecs = listOf("h264"), + audioDecodeCodecs = listOf("aac", "mp3"), + subtitles = DeliverySubtitleCapabilities( + embeddedText = true, + sidecarText = true, + ), features = listOf("hls", "buffer_reporting"), ), - // HLS is deliberately the ONLY engine. Any progressive delivery - // (direct or remux) reaches the receiver as a chunked/range-less - // stream it cannot seek — the slider and ±30s skips restarted - // playback from zero, and the receiver reported a live, growing - // duration. Advertising MEDIA3_DIRECT (mp4) was enough for the - // planner to keep choosing progressive remux, so it goes too: with - // HLS alone the server must serve a VOD manifest, which the - // receiver seeks natively via segments. ), ) -} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/route/PlaybackRoute.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/route/PlaybackRoute.kt index abcca0c92..77320c211 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/route/PlaybackRoute.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/route/PlaybackRoute.kt @@ -2,7 +2,7 @@ package org.prairieserver.prairie.common.player.route /** * What kind of playback engine + media source combination is in use. - * Mirrors Apple's tvOS route taxonomy (see `/opt/prairie-apple/docs/tvos-player/05-route-capability-matrix.md`). + * Mirrors Apple's tvOS route taxonomy (see `/opt/silo-apple/docs/tvos-player/05-route-capability-matrix.md`). * * Today's Android player decides MIME-driven via DefaultMediaSourceFactory. * This enum is observational — it labels what's actually running so the HUD @@ -10,7 +10,13 @@ package org.prairieserver.prairie.common.player.route * client-side route selector. */ enum class PlaybackRoute(val displayName: String) { - /** ProgressiveMediaSource + RenderersFactory with FFmpeg audio extension (`EXTENSION_RENDERER_MODE_PREFER`). */ + /** + * ProgressiveMediaSource + RenderersFactory with the FFmpeg audio extension + * enabled (`EXTENSION_RENDERER_MODE_ON`). The platform renderer is ordered + * first, but order only breaks ties: the track selector takes whichever + * renderer reports the greatest format support, so FFmpeg still wins a + * format the platform decoder reports as exceeding its capabilities. + */ PrairiePlayer("PrairiePlayer"), /** ProgressiveMediaSource + platform-only renderers (`EXTENSION_RENDERER_MODE_OFF`). Narrower codec breadth. */ diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicy.kt index 9a7cd984f..2eef76d56 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicy.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.common.player.seek import org.prairieserver.prairie.model.playback.PlaybackTimeline +import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 /** * Coordinate to retain when a player item is replaced or the server must @@ -46,6 +47,16 @@ sealed interface PlaybackSeekDecision { ) : PlaybackSeekDecision } +/** + * Coordinates to use when a protocol-v3 replan replaces the mounted Media3 + * item. The replan request carries a source/movie position, while Media3 + * seeks within the returned plan's local timeline. + */ +data class PlaybackReplanMountPosition( + val playerPositionSeconds: Double, + val sourcePositionSeconds: Double, +) + /** Maps a Media3-local position onto the source/movie timeline. */ fun PlaybackTimeline.sourcePositionForPlayer(playerPositionSeconds: Double): Double? = mapNonNegativePosition(playerPositionSeconds, timelineOffsetSeconds, Double::plus) @@ -54,6 +65,69 @@ fun PlaybackTimeline.sourcePositionForPlayer(playerPositionSeconds: Double): Dou fun PlaybackTimeline.playerPositionForSource(sourcePositionSeconds: Double): Double? = mapNonNegativePosition(sourcePositionSeconds, timelineOffsetSeconds, Double::minus) +/** + * Restores the source position sent with a replan on the returned timeline. + * + * A subtitle-only replan may deliberately reuse an append-only HLS transport + * whose origin predates the current playhead. In that case + * [playerStartSeconds] describes the transport's default entry point, not the + * viewer's requested position. Falling back to it unconditionally rewinds the + * movie to the beginning of the retained manifest window. + */ +fun PlaybackTimeline.replanMountPositionForSource( + sourcePositionSeconds: Double, +): PlaybackReplanMountPosition = resolveReplanMountPosition( + requestedSourcePositionSeconds = sourcePositionSeconds, + sourceStartSeconds = sourceStartSeconds, + playerStartSeconds = playerStartSeconds, + timelineOffsetSeconds = timelineOffsetSeconds, +) + +/** Protocol-v3 wire-timeline counterpart used before the plan is projected. */ +fun PlaybackTimelineV3.replanMountPositionForSource( + sourcePositionSeconds: Double, +): PlaybackReplanMountPosition = resolveReplanMountPosition( + requestedSourcePositionSeconds = sourcePositionSeconds, + sourceStartSeconds = sourceStartSeconds, + playerStartSeconds = playerStartSeconds, + timelineOffsetSeconds = timelineOffsetSeconds, +) + +private fun resolveReplanMountPosition( + requestedSourcePositionSeconds: Double, + sourceStartSeconds: Double, + playerStartSeconds: Double, + timelineOffsetSeconds: Double, +): PlaybackReplanMountPosition { + val restoredPlayerPosition = mapNonNegativePosition( + requestedSourcePositionSeconds, + timelineOffsetSeconds, + Double::minus, + ) + if (restoredPlayerPosition != null) { + return PlaybackReplanMountPosition( + playerPositionSeconds = restoredPlayerPosition, + sourcePositionSeconds = requestedSourcePositionSeconds, + ) + } + + val fallbackPlayerPosition = playerStartSeconds + .takeIf { it.isFinite() && it >= 0.0 } + ?: 0.0 + val fallbackSourcePosition = sourceStartSeconds + .takeIf { it.isFinite() && it >= 0.0 } + ?: mapNonNegativePosition( + fallbackPlayerPosition, + timelineOffsetSeconds, + Double::plus, + ) + ?: 0.0 + return PlaybackReplanMountPosition( + playerPositionSeconds = fallbackPlayerPosition, + sourcePositionSeconds = fallbackSourcePosition, + ) +} + /** Parses the closed protocol-V3 restoration vocabulary conservatively. */ fun PlaybackTimeline.seekRestorationMode(): PlaybackSeekRestoration = when (seekRestoration) { "player_position" -> PlaybackSeekRestoration.PlayerPosition diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt index 97f539120..3513f5444 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractor.kt @@ -8,8 +8,8 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.IndexSeekMap import androidx.media3.extractor.PositionHolder -import androidx.media3.extractor.SeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.text.CueEncoder import androidx.media3.extractor.text.SubtitleParser @@ -51,6 +51,14 @@ class PgsSupExtractor( * track then appears as `mounted=[1:]` and the pick dies on its deadline. */ private val sourceFormat: Format, + /** + * The live player-timeline position, in microseconds, when the sidecar is + * mounted through [SidecarSubtitleMediaSource]. The video is allowed to + * run ahead of this download, so a set can arrive after the playhead has + * already passed it; the guard in [flushDisplaySet] uses whichever is + * later — the seek point or this — so such a set is history too. + */ + private val playbackFloorUsProvider: () -> Long = { 0L }, ) : Extractor { private val cueEncoder = CueEncoder() @@ -62,10 +70,45 @@ class PgsSupExtractor( /** Segments of the display set being accumulated, already prefix-stripped. */ private var displaySet = ByteArrayBuilder() private var displaySetTimeUs = C.TIME_UNSET + private var displaySetPosition: Long = C.POSITION_UNSET.toLong() private var displaySetSegmentCount = 0 private var failedClosed = false private var emittedSets = 0 + /** Display sets dropped because the parser could not survive them. */ + private var malformedSets = 0 private var emittedCues = 0 + private var lastIndexedTimeUs = 0L + private var lastIndexedPosition = 0L + + /** + * The player-timeline position this read started from — Media3's own seek + * target for a resume or scrub, or the reset seek at first load. Anything + * that lands before it is history and never becomes a sample; see + * [flushDisplaySet]. + */ + private var seekTimeUs = 0L + + /** + * The last history display set seen, still framed. PGS ends a caption + * only with the next set, so the newest set at or before the seek point + * IS the caption on screen at that moment; it is decoded and published + * once — at the seek point — before the first in-window set. + */ + private var carriedSet: ByteArray? = null + private var carriedSetTimeUs = C.TIME_UNSET + private var skippedHistorySets = 0 + + // ProgressiveMediaPeriod coerces every seek to zero when an extractor + // advertises an unseekable map. In a MergingMediaSource that makes a PGS + // child return 0 while the video child accepts the requested resume point, + // and Media3 fails the whole selection with "Children enabled at different + // positions." A raw SUP stream can always be restarted at byte zero and + // scanned forward, so advertise that truthful (if conservative) seek map. + private val seekMap = IndexSeekMap( + longArrayOf(0L), + longArrayOf(0L), + C.TIME_UNSET, + ) override fun sniff(input: ExtractorInput): Boolean { val probe = ByteArray(2) @@ -95,16 +138,20 @@ class PgsSupExtractor( output.endTracks() // The cues are held in the sample queue once read, so backward seeks are // served from memory; a seek before the read completes just restarts it. - output.seekMap(SeekMap.Unseekable(C.TIME_UNSET)) + output.seekMap(seekMap) } override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { if (failedClosed) return Extractor.RESULT_END_OF_INPUT val output = trackOutput ?: return Extractor.RESULT_END_OF_INPUT + val segmentPosition = input.position try { input.readFully(headerScratch, 0, SEGMENT_HEADER_SIZE) } catch (_: EOFException) { discardPendingDisplaySet() + // A resume past the last caption: whatever was in force there — + // usually the clear that ended it — is still the truth on screen. + parser?.let { publishCarriedSet(it, output) } return Extractor.RESULT_END_OF_INPUT } val header = ParsableByteArray(headerScratch) @@ -147,9 +194,25 @@ class PgsSupExtractor( return Extractor.RESULT_CONTINUE } + // Reject a hostile bitmap BEFORE Media3 sizes an allocation from it. + // The parser trusts the declared width and height, allocating + // IntArray(width * height) plus an ARGB bitmap, so a few bytes can ask + // for hundreds of megabytes. Catching the failure afterwards is too + // late — the memory pressure has already happened, and on a small box + // the process simply goes. + if (segmentType == SEGMENT_TYPE_OBJECT && !isPgsObjectWithinBudget(payload)) { + malformedSets++ + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP object rejected: declared bitmap outside budget", + ) + discardPendingDisplaySet() + return Extractor.RESULT_CONTINUE + } + // First segment of a set carries the time the whole set is shown at. if (displaySet.isEmpty()) { displaySetTimeUs = pts90kHz * C.MICROS_PER_SECOND / PTS_CLOCK_HZ + displaySetPosition = segmentPosition } appendSegment(segmentType, segmentLength, payload) return Extractor.RESULT_CONTINUE @@ -172,6 +235,7 @@ class PgsSupExtractor( private fun discardPendingDisplaySet() { displaySet = ByteArrayBuilder() displaySetTimeUs = C.TIME_UNSET + displaySetPosition = C.POSITION_UNSET.toLong() displaySetSegmentCount = 0 } @@ -184,18 +248,144 @@ class PgsSupExtractor( if (displaySet.isEmpty()) return val bytes = displaySet.toByteArray() val timeUs = displaySetTimeUs + val position = displaySetPosition displaySet = ByteArrayBuilder() displaySetTimeUs = C.TIME_UNSET + displaySetPosition = C.POSITION_UNSET.toLong() displaySetSegmentCount = 0 val activeParser = parser ?: return + val output = trackOutput ?: return if (timeUs == C.TIME_UNSET) return + // A SUP is always read from byte zero (or from a coarse indexed point + // below the target), so every set before the seek point streams + // through here first. PGS is CUE_REPLACEMENT_BEHAVIOR_REPLACE with no + // duration: were those history sets published — clamped to zero on a + // re-anchored timeline, or simply timestamped in the past — each one + // would be "the newest cue at or before the position" for as long as + // it took the next to download, and the viewer would watch the film's + // entire caption history replay while the video buffered at the resume + // point. Hold only the newest history set and let the rest go. + // + // The same applies past the seek point once the video has been let + // run ahead of this download: a set the playhead has already passed + // would flash for one render tick if published, so the floor is the + // later of the seek point and the live position. + val adjustedTimeUs = timeUs + offsetUsProvider() + val floorUs = maxOf(seekTimeUs, playbackFloorUsProvider()) + if (adjustedTimeUs < floorUs) { + carriedSet = bytes + carriedSetTimeUs = timeUs + skippedHistorySets++ + if (skippedHistorySets == 1 || skippedHistorySets % 500 == 0) { + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP history skipped=$skippedHistorySets t=${timeUs / 1000}ms " + + "floor=${floorUs / 1000}ms", + ) + } + return + } + publishCarriedSet(activeParser, output) + emittedSets++ if (emittedSets <= 3 || emittedSets % 200 == 0) { org.prairieserver.prairie.common.player.SubDiag.log( "SUP set=$emittedSets t=${timeUs / 1000}ms bytes=${bytes.size}", ) } + val decoded = decodeGuarded(activeParser, bytes, timeUs) ?: return + publishDisplaySet(output, decoded, adjustedTimeUs.coerceAtLeast(0L)) + val indexedTimeUs = adjustedTimeUs.coerceAtLeast(0L) + if ( + position > lastIndexedPosition && + indexedTimeUs > lastIndexedTimeUs + ) { + seekMap.addSeekPoint(indexedTimeUs, position) + lastIndexedTimeUs = indexedTimeUs + lastIndexedPosition = position + } + } + + /** + * Publish the caption in force at the point the read caught up, if one was + * carried past it. It is timestamped at its own time, or at the seek point + * if that is later — the player's queue starts there, and on a re-anchored + * timeline its own time may well be negative. Every set that follows it + * lies at or after the floor, which is at or after the seek point, so it + * never pre-empts one; and until then it is "the newest cue at or before + * the position", which for REPLACE is exactly what shows. + */ + private fun publishCarriedSet(activeParser: SubtitleParser, output: TrackOutput) { + val bytes = carriedSet ?: return + val timeUs = carriedSetTimeUs + carriedSet = null + carriedSetTimeUs = C.TIME_UNSET + val decoded = decodeGuarded(activeParser, bytes, timeUs) ?: return + val sampleTimeUs = maxOf(timeUs + offsetUsProvider(), seekTimeUs).coerceAtLeast(0L) + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP carried caption t=${timeUs / 1000}ms -> ${sampleTimeUs / 1000}ms " + + "after skipping $skippedHistorySets", + ) + publishDisplaySet(output, decoded, sampleTimeUs) + } + + /** + * The bundled Media3 PGS parser trusts the display set's own 16-bit + * width/height: it allocates IntArray(width * height) and applies RLE + * runs with no pixel bound of its own. A corrupt or hostile set can + * therefore throw NegativeArraySizeException, an oversized-run + * IllegalArgumentException, or ask for an allocation large enough to + * take the process down on a low-memory box. + * + * Bounding the byte length upstream does not help — a handful of bytes + * can declare an enormous bitmap. So the parse is contained here, and a + * damaged caption costs one missing subtitle rather than the film. + * + * OutOfMemoryError is caught deliberately. It is not an error this + * process caused by being unhealthy; it is one specific allocation + * sized by untrusted input, and refusing to catch it on principle means + * a bad caption kills playback. Narrow by construction: the block + * contains only parsing and cue encoding, both sized by the display + * set's own declared dimensions — the sample queue is not inside it. + */ + private fun decodeGuarded( + activeParser: SubtitleParser, + bytes: ByteArray, + timeUs: Long, + ): List? = try { + decodeDisplaySet(activeParser, bytes, timeUs) + } catch (e: Exception) { + malformedSets++ + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP set $emittedSets rejected: ${e::class.simpleName}: ${e.message}", + ) + null + } catch (e: OutOfMemoryError) { + malformedSets++ + org.prairieserver.prairie.common.player.SubDiag.log( + "SUP set $emittedSets exhausted memory and was dropped", + ) + null + } + + /** + * Decode a display set WITHOUT touching the sample queue. + * + * Parsing and publication are separated on purpose. Writing samples from + * inside the parse callback means a failure partway through — after + * sampleData and before sampleMetadata — leaves uncommitted bytes in the + * queue, and the next sample then lands on a boundary the queue disagrees + * about. A dropped caption is recoverable; a corrupt queue is not. + * + * So everything untrusted happens here and produces plain byte arrays, and + * the caller publishes only if this returned normally. + */ + private fun decodeDisplaySet( + activeParser: SubtitleParser, + bytes: ByteArray, + timeUs: Long, + ): List { + val encodedSamples = mutableListOf() activeParser.parse( bytes, 0, @@ -211,11 +401,24 @@ class PgsSupExtractor( // Duration stays unset: PGS ends a caption with the next display // set, and the parser's REPLACE behaviour already means a new // sample supersedes the last one. - val encoded = cueEncoder.encode(cues.cues, C.TIME_UNSET) - val data = ParsableByteArray(encoded) - output.sampleData(data, encoded.size) + encodedSamples += cueEncoder.encode(cues.cues, C.TIME_UNSET) + } + return encodedSamples + } + + /** + * Publish decoded samples at [sampleTimeUs], already on the player + * timeline. Nothing here can throw on untrusted input. + */ + private fun publishDisplaySet( + output: TrackOutput, + samples: List, + sampleTimeUs: Long, + ) { + samples.forEach { encoded -> + output.sampleData(ParsableByteArray(encoded), encoded.size) output.sampleMetadata( - (timeUs + offsetUsProvider()).coerceAtLeast(0L), + sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, encoded.size, 0, @@ -227,8 +430,13 @@ class PgsSupExtractor( override fun seek(position: Long, timeUs: Long) { displaySet = ByteArrayBuilder() displaySetTimeUs = C.TIME_UNSET + displaySetPosition = C.POSITION_UNSET.toLong() displaySetSegmentCount = 0 failedClosed = false + seekTimeUs = timeUs + carriedSet = null + carriedSetTimeUs = C.TIME_UNSET + skippedHistorySets = 0 parser?.reset() } @@ -274,6 +482,8 @@ class PgsSupExtractor( /** `PG` magic, 4-byte PTS, 4-byte DTS, type, 2-byte length. */ const val SEGMENT_HEADER_SIZE = 13 const val SEGMENT_TYPE_END = 0x80 + /** ODS — the only segment that declares bitmap dimensions. */ + const val SEGMENT_TYPE_OBJECT = 0x15 private const val CONTAINER_SEGMENT_HEADER_SIZE = 3 private const val MAX_DISPLAY_SET_BYTES = 16 * 1024 * 1024 private const val MAX_DISPLAY_SET_SEGMENTS = 512 @@ -284,3 +494,47 @@ class PgsSupExtractor( private const val MAGIC_G = MAGIC_G_INT.toByte() } } + +/** + * Whether an ODS payload declares a bitmap this device should attempt. + * + * Reads only the object header, which is not a second PGS parser: two 16-bit + * fields at a fixed offset. RLE correctness stays Media3's problem; this exists + * solely so the allocation it performs is one we chose to allow. + * + * Layout, first-sequence object: + * 0..1 object id + * 2 version + * 3 sequence descriptor (bit 7 set = first/base sequence) + * 4..6 object data length, 24-bit (present only on a first sequence) + * 7..8 width, 16-bit + * 9..10 height, 16-bit + * + * Continuation segments carry no dimensions and are passed through: the base + * sequence they belong to was already judged. + */ +private fun isPgsObjectWithinBudget(payload: ByteArray): Boolean { + if (payload.size < 4) return false + val isFirstSequence = (payload[3].toInt() and 0x80) != 0 + if (!isFirstSequence) return true + if (payload.size < 11) return false + + fun u8(i: Int) = payload[i].toInt() and 0xFF + val objectDataLength = (u8(4) shl 16) or (u8(5) shl 8) or u8(6) + val width = (u8(7) shl 8) or u8(8) + val height = (u8(9) shl 8) or u8(10) + + // object_data_length counts the four width/height bytes, so anything below + // them is malformed rather than merely small. + if (objectDataLength < 4) return false + if (width <= 0 || height <= 0) return false + if (width > MAX_PGS_DIMENSION || height > MAX_PGS_DIMENSION) return false + if (width.toLong() * height.toLong() > MAX_PGS_BITMAP_PIXELS) return false + if (objectDataLength.toLong() - 4L > MAX_PGS_OBJECT_DATA_BYTES) return false + return true +} + +/** A full-frame 1080p caption is allowed; a 4K one is not, on TV memory. */ +private const val MAX_PGS_BITMAP_PIXELS = 1920L * 1080L +private const val MAX_PGS_OBJECT_DATA_BYTES = 8L * 1024L * 1024L +private const val MAX_PGS_DIMENSION = 4096 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt new file mode 100644 index 000000000..2d6519038 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSource.kt @@ -0,0 +1,185 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.C +import androidx.media3.common.StreamKey +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.LoadingInfo +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.source.MediaPeriod +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.SampleStream +import androidx.media3.exoplayer.source.TrackGroupArray +import androidx.media3.exoplayer.source.WrappingMediaSource +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.exoplayer.upstream.Allocator +import java.util.concurrent.atomic.AtomicLong + +/** + * The player-timeline position the sidecar's period last saw, in microseconds. + * + * Written by [SidecarSubtitleMediaSource] from `reevaluateBuffer` (every + * playback tick while its period is the loading period) and from every seek; + * read by an extractor on the loader thread as the floor below which a cue is + * already history and must not be published. Shared per sidecar. + */ +class SidecarPlaybackFloor { + private val positionUs = AtomicLong(0L) + + fun get(): Long = positionUs.get() + + fun set(value: Long) = positionUs.set(value) +} + +/** + * Takes a text sidecar out of the [androidx.media3.exoplayer.source.MergingMediaSource] + * loading gate. + * + * Media3 drives a merged period through one `CompositeSequenceableLoader`, + * which only ever continues the child with the smallest next-load position + * (or one behind the playhead). A sidecar is read from byte zero, so on a + * resume its next-load position sits at the seek point until the download + * reaches it — and for that whole time it is the child that gets continued. + * The video child fetches its first chunk and then starves. Seen on an onn + * box: 20 s in BUFFERING with 406 ms of video buffered while a 4K film's + * SDH `.sup` streamed, until the startup-stall detector gave up and fell back + * to a transcode (which then dropped the subtitle). + * + * This wrapper reports the sidecar's period as having nothing to load and + * nothing buffered, so the composite ignores it and the video decides when + * playback starts and what loads next. The sidecar's own load keeps running: + * `ProgressiveMediaPeriod` starts loading in `prepare()`, parks itself every + * `continueLoadingCheckIntervalBytes` (and after a seek cancels a load in + * flight) by asking its callback to continue, and that callback is this + * wrapper — which continues it directly instead of waiting for a composite + * that will never ask. + * + * Captions that arrive after the playhead has passed them are the extractor's + * problem, not this class's: it publishes the live position through + * [SidecarPlaybackFloor] so a REPLACE-behaviour extractor can drop them. + */ +@UnstableApi +class SidecarSubtitleMediaSource( + child: MediaSource, + private val floor: SidecarPlaybackFloor, +) : WrappingMediaSource(child) { + + override fun createPeriod( + id: MediaSource.MediaPeriodId, + allocator: Allocator, + startPositionUs: Long, + ): MediaPeriod = NonGatingSidecarPeriod( + mediaSource.createPeriod(id, allocator, startPositionUs), + floor, + ) + + override fun releasePeriod(mediaPeriod: MediaPeriod) { + mediaSource.releasePeriod((mediaPeriod as NonGatingSidecarPeriod).delegate) + } +} + +@UnstableApi +internal class NonGatingSidecarPeriod( + val delegate: MediaPeriod, + private val floor: SidecarPlaybackFloor, +) : MediaPeriod, MediaPeriod.Callback { + + private var callback: MediaPeriod.Callback? = null + + override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { + this.callback = callback + floor.set(positionUs) + delegate.prepare(this, positionUs) + } + + override fun onPrepared(mediaPeriod: MediaPeriod) { + callback?.onPrepared(this) + } + + override fun onContinueLoadingRequested(source: MediaPeriod) { + // The delegate parked its loader (interval reached, or a cancelled load + // finished unwinding). Nobody upstream will continue a child that + // reports nothing to load, so do it here. + kickDelegate("requested") + callback?.onContinueLoadingRequested(this) + } + + /** + * `ProgressiveMediaPeriod` never restarts itself: after `prepare()` it + * reads until its check interval and parks; `selectTracks` that enables a + * track, and `seekToUs`, both leave it parked or cancelled and wait for + * `continueLoading`. Every one of those funnels here. The delegate declines + * on its own when it has finished, has a fatal error, is mid-cancel, or has + * no enabled track — so calling it eagerly is safe. + */ + private fun kickDelegate(reason: String) { + if (delegate.isLoading) return + val continued = delegate.continueLoading( + LoadingInfo.Builder() + .setPlaybackPositionUs(floor.get()) + .setPlaybackSpeed(1f) + .setLastRebufferRealtimeMs(C.TIME_UNSET) + .build(), + ) + org.prairieserver.prairie.common.player.SubDiag.log( + "sidecar kick($reason) continued=$continued floor=${floor.get() / 1000}ms", + ) + } + + override fun maybeThrowPrepareError() = delegate.maybeThrowPrepareError() + + override fun getTrackGroups(): TrackGroupArray = delegate.trackGroups + + override fun getStreamKeys(trackSelections: List): List = + delegate.getStreamKeys(trackSelections) + + override fun selectTracks( + selections: Array, + mayRetainStreamFlags: BooleanArray, + streams: Array, + streamResetFlags: BooleanArray, + positionUs: Long, + ): Long { + val result = delegate.selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs) + // Enabling the text track (a subtitle pick after start, or the first + // selection once prepared) is what makes the delegate willing to load. + if (selections.any { it != null }) kickDelegate("select") + return result + } + + override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) = + delegate.discardBuffer(positionUs, toKeyframe) + + override fun readDiscontinuity(): Long = delegate.readDiscontinuity() + + override fun seekToUs(positionUs: Long): Long { + floor.set(positionUs) + val result = delegate.seekToUs(positionUs) + // An idle delegate is left reset-but-parked by a seek; a loading one is + // cancelled and comes back through onContinueLoadingRequested. + kickDelegate("seek") + return result + } + + override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long = + delegate.getAdjustedSeekPositionUs(positionUs, seekParameters) + + /** Not a participant: the audio/video children decide when playback may start. */ + override fun getBufferedPositionUs(): Long = C.TIME_END_OF_SOURCE + + /** Not a participant: the audio/video children decide what loads next. */ + override fun getNextLoadPositionUs(): Long = C.TIME_END_OF_SOURCE + + override fun continueLoading(loadingInfo: LoadingInfo): Boolean { + floor.set(maxOf(floor.get(), loadingInfo.playbackPositionUs)) + return delegate.continueLoading(loadingInfo) + } + + override fun isLoading(): Boolean = delegate.isLoading + + override fun reevaluateBuffer(positionUs: Long) { + // Called on the loading period every playback tick with the current + // period position — the live floor for "this cue is already history". + floor.set(positionUs) + delegate.reevaluateBuffer(positionUs) + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractor.kt new file mode 100644 index 000000000..f965fc159 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractor.kt @@ -0,0 +1,245 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.IndexSeekMap +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.text.CueEncoder +import androidx.media3.extractor.text.SubtitleParser +import java.io.ByteArrayOutputStream + +/** + * Incrementally turns a WebVTT response into Media3 cue samples. + * + * Silo can expose an embedded text track as a bounded, streaming WebVTT + * extract. The first cues arrive quickly, but the HTTP response stays open + * while FFmpeg scans the rest of the window. Media3's stock [androidx.media3.extractor.text.SubtitleExtractor] + * buffers that response to EOF before it publishes any cue. In a + * [androidx.media3.exoplayer.source.MergingMediaSource] that makes an otherwise + * reusable HLS stream buffer until the complete subtitle window is extracted. + * + * WebVTT cue blocks are independently parseable once the header is supplied. + * This extractor therefore emits each complete block immediately while the + * response continues loading. It keeps only one unfinished block in memory, + * preserves the configured parser (including Silo's timeline/user offset), + * and fails closed at the same total byte limit as other subtitle loaders. + */ +@UnstableApi +class StreamingWebvttExtractor( + private val subtitleParser: SubtitleParser, + private val sourceFormat: Format, + private val maxBytes: Long, +) : Extractor { + private val cueEncoder = CueEncoder() + private val readBuffer = ByteArray(READ_BUFFER_BYTES) + private val pending = ByteArrayOutputStream() + private val preambleBlocks = mutableListOf() + + private var trackOutput: TrackOutput? = null + private var seekTimeUs = C.TIME_UNSET + private var bytesRead = 0L + private var emittedSamples = 0 + private var sawCue = false + private var failedClosed = false + + private val seekMap = IndexSeekMap( + longArrayOf(0L), + longArrayOf(0L), + C.TIME_UNSET, + ) + + override fun sniff(input: ExtractorInput): Boolean = true + + override fun init(output: ExtractorOutput) { + val track = output.track(0, C.TRACK_TYPE_TEXT) + track.format( + sourceFormat.buildUpon() + .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) + .setCodecs(sourceFormat.sampleMimeType) + .setCueReplacementBehavior(subtitleParser.cueReplacementBehavior) + .build(), + ) + trackOutput = track + output.endTracks() + output.seekMap(seekMap) + } + + override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int { + if (failedClosed) return Extractor.RESULT_END_OF_INPUT + + val read = input.read(readBuffer, 0, readBuffer.size) + if (read == C.RESULT_END_OF_INPUT) { + processPending(final = true) + org.prairieserver.prairie.common.player.SubDiag.log( + "streaming VTT complete bytes=$bytesRead samples=$emittedSamples", + ) + return Extractor.RESULT_END_OF_INPUT + } + if (read <= 0) return Extractor.RESULT_CONTINUE + + bytesRead += read + if (bytesRead > maxBytes || pending.size() + read > MAX_PENDING_BLOCK_BYTES) { + failedClosed = true + pending.reset() + org.prairieserver.prairie.common.player.SubDiag.log( + "streaming VTT rejected: subtitle exceeded bounded input", + ) + return Extractor.RESULT_END_OF_INPUT + } + pending.write(readBuffer, 0, read) + processPending(final = false) + return Extractor.RESULT_CONTINUE + } + + private fun processPending(final: Boolean) { + var bytes = pending.toByteArray() + var consumed = 0 + while (consumed < bytes.size) { + val boundary = findBlockBoundary(bytes, consumed) + if (boundary == null) break + processBlock(bytes.copyOfRange(consumed, boundary.start)) + consumed = boundary.start + boundary.length + } + if (final && consumed < bytes.size) { + processBlock(bytes.copyOfRange(consumed, bytes.size)) + consumed = bytes.size + } + if (consumed == 0) return + + val remainder = bytes.copyOfRange(consumed, bytes.size) + pending.reset() + pending.write(remainder) + } + + private fun processBlock(rawBlock: ByteArray) { + val block = rawBlock.trimAsciiLineBreaks() + if (block.isEmpty()) return + + val text = block.decodeToString() + when { + text.removePrefix("\uFEFF").startsWith("WEBVTT") -> { + preambleBlocks.clear() + preambleBlocks += block + } + !sawCue && (text.startsWith("STYLE") || text.startsWith("REGION")) -> { + preambleBlocks += block + } + text.startsWith("NOTE") -> Unit + "-->" in text -> { + sawCue = true + parseAndPublish(block) + } + } + } + + private fun parseAndPublish(cueBlock: ByteArray) { + val document = ByteArrayOutputStream().apply { + if (preambleBlocks.isEmpty()) { + write(WEBVTT_HEADER) + } else { + preambleBlocks.forEach { block -> + write(block) + write(BLOCK_SEPARATOR) + } + } + write(cueBlock) + write(BLOCK_SEPARATOR) + }.toByteArray() + + try { + subtitleParser.parse( + document, + 0, + document.size, + SubtitleParser.OutputOptions.allCues(), + ) { cues -> + if (seekTimeUs != C.TIME_UNSET && cues.endTimeUs < seekTimeUs) return@parse + publish(cues.startTimeUs, cueEncoder.encode(cues.cues, cues.durationUs)) + } + } catch (error: RuntimeException) { + org.prairieserver.prairie.common.player.SubDiag.log( + "streaming VTT cue rejected: ${error::class.simpleName}: ${error.message}", + ) + } + } + + private fun publish(timeUs: Long, encoded: ByteArray) { + val output = trackOutput ?: return + output.sampleData(ParsableByteArray(encoded), encoded.size) + output.sampleMetadata( + timeUs.coerceAtLeast(0L), + C.BUFFER_FLAG_KEY_FRAME, + encoded.size, + 0, + null, + ) + emittedSamples++ + if (emittedSamples <= 3) { + org.prairieserver.prairie.common.player.SubDiag.log( + "streaming VTT sample=$emittedSamples at=${timeUs / 1000}ms", + ) + } + } + + override fun seek(position: Long, timeUs: Long) { + pending.reset() + preambleBlocks.clear() + seekTimeUs = timeUs + bytesRead = 0L + emittedSamples = 0 + sawCue = false + failedClosed = false + subtitleParser.reset() + } + + override fun release() { + pending.reset() + preambleBlocks.clear() + trackOutput = null + subtitleParser.reset() + } + + private data class BlockBoundary(val start: Int, val length: Int) + + private companion object { + val WEBVTT_HEADER = "WEBVTT\n\n".encodeToByteArray() + val BLOCK_SEPARATOR = "\n\n".encodeToByteArray() + const val READ_BUFFER_BYTES = 16 * 1024 + const val MAX_PENDING_BLOCK_BYTES = 1024 * 1024 + + fun findBlockBoundary(bytes: ByteArray, from: Int): BlockBoundary? { + var index = from + while (index < bytes.lastIndex) { + if (bytes[index] == '\n'.code.toByte() && bytes[index + 1] == '\n'.code.toByte()) { + return BlockBoundary(index, 2) + } + if ( + index + 3 < bytes.size && + bytes[index] == '\r'.code.toByte() && + bytes[index + 1] == '\n'.code.toByte() && + bytes[index + 2] == '\r'.code.toByte() && + bytes[index + 3] == '\n'.code.toByte() + ) { + return BlockBoundary(index, 4) + } + index++ + } + return null + } + + fun ByteArray.trimAsciiLineBreaks(): ByteArray { + var start = 0 + var end = size + while (start < end && (this[start] == '\r'.code.toByte() || this[start] == '\n'.code.toByte())) start++ + while (end > start && (this[end - 1] == '\r'.code.toByte() || this[end - 1] == '\n'.code.toByte())) end-- + return copyOfRange(start, end) + } + } +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizer.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizer.kt index adc6ff601..4660bc75e 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizer.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizer.kt @@ -11,7 +11,17 @@ internal fun normalizeSubripPayloadIfNeeded( offset: Int, length: Int, ): ByteArray? { - val text = data.decodeToString(offset, offset + length) + // Decoded STRICTLY, not leniently. decodeToString substitutes U+FFFD for + // anything that is not valid UTF-8 — and since a rewrite re-encodes what it + // decoded, those substitutions become permanent: a Windows-1252 subtitle + // comes back with "José" rendered as "Jos�" for the rest of its life. + // + // Legacy SRT files are commonly Windows-1252 or another single-byte + // regional encoding, so a failed UTF-8 decode is ordinary rather than + // exceptional. Falling back to Windows-1252 recovers the accents; if even + // that cannot be decoded, the payload is left exactly as it arrived, on + // the grounds that not normalising is better than corrupting. + val text = decodeSubtitleText(data, offset, length) ?: return null val normalized = normalizeSubripTextIfNeeded(text) return if (normalized == text) null else normalized.encodeToByteArray() } @@ -127,3 +137,28 @@ private fun nextNonBlankIndex(lines: List, startIndex: Int): Int? { } return null } + +private fun decodeSubtitleText(data: ByteArray, offset: Int, length: Int): String? = + decodeStrictly(data, offset, length, Charsets.UTF_8) + ?: decodeStrictly(data, offset, length, WINDOWS_1252) + +private fun decodeStrictly( + data: ByteArray, + offset: Int, + length: Int, + charset: java.nio.charset.Charset, +): String? = try { + charset.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(data, offset, length)) + .toString() +} catch (_: java.nio.charset.CharacterCodingException) { + null +} catch (_: IllegalArgumentException) { + null +} + +private val WINDOWS_1252: java.nio.charset.Charset = + runCatching { java.nio.charset.Charset.forName("windows-1252") } + .getOrElse { Charsets.ISO_8859_1 } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt new file mode 100644 index 000000000..ac83dadeb --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcile.kt @@ -0,0 +1,152 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack + +/** + * What the viewer wants the audio to be, as a CATALOG ordinal into + * `FileVersion.audioTracks`. + * + * [explicit] separates a fresh decision from a restore: a launch pick or a + * persisted fingerprint puts playback back where it was and must not be + * recorded as a new choice for episode carry-over, whereas a picker or remote + * selection must. + * + * [fileId] scopes it. Audio ordinals are per-file and the outgoing version can + * stay interactive while a replacement loads, so an intent made in that window + * must not be reconciled against a different file. + */ +data class DesiredAudio( + val generation: Long, + val catalogOrdinal: Int, + val explicit: Boolean, + val fileId: Int?, + val confirmed: Boolean = false, +) + +/** + * A pending local audio switch: select [targetOrdinal] on the player, and on + * confirmation commit [catalogOrdinal] as the viewer's choice. + */ +data class LocalAudioSelection( + val generation: Long, + val catalogOrdinal: Int, + /** Media3 audio-group ordinal — what AudioTrackManager expects. */ + val targetOrdinal: Int, + /** + * Distinct per issuance. StateFlow conflates equal values, so re-applying + * after a remount has to look different or the collector never fires. + */ + val attempt: Long, +) + +/** + * What to do about the desired audio, given one track snapshot. + * + * Extracted from the ViewModel so the decision is testable on its own: the + * ViewModel takes fourteen constructor dependencies, and every review round of + * this area has turned up a case that neither reasoning nor the pure-helper + * tests caught. The orchestration around it — generations, persistence, the + * request flow — stays in the ViewModel; only the decision lives here. + */ +sealed interface AudioReconcileAction { + /** Nothing to do with this snapshot. */ + data object None : AudioReconcileAction + + /** The intent belongs to a different file and must be abandoned. */ + data object DropForeignFile : AudioReconcileAction + + /** The player is on the wanted track. */ + data object Confirm : AudioReconcileAction + + /** Select this mounted ordinal on the player. */ + data class Apply(val targetOrdinal: Int) : AudioReconcileAction +} + +/** + * Decides what a snapshot means for [desired]. + * + * @param selectedOrdinal the Media3 ordinal currently selected, if any. + * @param planAudioOrdinal the catalog ordinal the server says it delivered. + */ +fun reconcileDesiredAudioAction( + desired: DesiredAudio?, + activeFileId: Int?, + catalog: List, + mounted: List, + selectedOrdinal: Int?, + planAudioOrdinal: Int?, +): AudioReconcileAction { + if (desired == null) return AudioReconcileAction.None + // An empty or partial snapshot is not evidence of anything. The intent must + // survive it: discarding on the first callback is what made a launch pick + // silently fail. + if (mounted.isEmpty()) return AudioReconcileAction.None + + // Audio ordinals are per-file, and the outgoing version stays interactive + // while a replacement loads, so an intent from that window would otherwise + // name a different track here. + if (desired.fileId != null && desired.fileId != activeFileId) { + return AudioReconcileAction.DropForeignFile + } + + val wanted = catalog.getOrNull(desired.catalogOrdinal) ?: return AudioReconcileAction.None + + // Resolved ONCE against the whole snapshot. Matching a one-element list + // asks a different question: the matcher stops as soon as one candidate + // remains, so a main mix and its commentary — same language, same codec — + // would confirm each other. + val target = matchMountedAudioTrack(wanted, mounted) + ?: return if (planAudioOrdinal == desired.catalogOrdinal) { + // Not in this stream, but the server says it delivered this row: a + // transcode's recoded output cannot identity-match its own source, + // so this is satisfied rather than retried forever. + AudioReconcileAction.Confirm + } else { + AudioReconcileAction.None + } + + // Both ordinals come from this same snapshot, and target was resolved by + // identity, so comparing them IS the identity comparison. + return if (selectedOrdinal == target.ordinal) { + AudioReconcileAction.Confirm + } else { + AudioReconcileAction.Apply(target.ordinal) + } +} + +/** + * The mounted audio tracks of a Media3 [androidx.media3.common.Tracks], in the + * ordinal space [org.prairieserver.prairie.common.player.AudioTrackManager] expects: + * position among audio groups, counting only audio groups. + */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +fun mountedAudioTracks(tracks: androidx.media3.common.Tracks): List { + val result = mutableListOf() + var ordinal = 0 + for (group in tracks.groups) { + if (group.type != androidx.media3.common.C.TRACK_TYPE_AUDIO) continue + val media = group.mediaTrackGroup + val format = if (media.length > 0) media.getFormat(0) else null + result += MountedAudioTrack( + ordinal = ordinal, + language = format?.language, + codecOrMime = format?.sampleMimeType ?: format?.codecs, + channelCount = format?.channelCount?.takeIf { it > 0 }, + label = format?.label, + ) + ordinal += 1 + } + return result +} + +/** Ordinal of the currently selected audio group, if any. */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +fun selectedMountedAudioOrdinal(tracks: androidx.media3.common.Tracks): Int? { + var ordinal = 0 + for (group in tracks.groups) { + if (group.type != androidx.media3.common.C.TRACK_TYPE_AUDIO) continue + if (group.isSelected) return ordinal + ordinal += 1 + } + return null +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt new file mode 100644 index 000000000..bed833e4f --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt @@ -0,0 +1,396 @@ +package org.prairieserver.prairie.common.player.video + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prairieserver.prairie.common.player.normalizedSubtitleCodecFamily +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.player.DolbyVisionDetection +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired + +/** + * Session-only, cross-episode playback intent. It deliberately carries no + * episode-local identity: target IDs and subtitle indexes are resolved only + * after the next episode's catalog detail is available. + */ +@Serializable +data class EpisodeSelectionHandoff( + val source: EpisodeSourceIntent? = null, + val subtitle: EpisodeSubtitleIntent = EpisodeSubtitleIntent.auto(), + /** + * The audio the viewer chose, carried the same way subtitles are. + * + * Nothing carried audio before, so a household watching a dub or a + * commentary track was returned to the server default at every automatic + * episode change — a choice they had to make again all evening. + * + * Described by metadata rather than index for the same reason subtitles + * are: the next episode's track list is a different list, and position + * three in one file has nothing to do with position three in the next. + */ + val audio: EpisodeAudioIntent = EpisodeAudioIntent.auto(), +) + +@Serializable +enum class EpisodeAudioMode { AUTO, TRACK } + +@Serializable +data class EpisodeAudioIntent( + val mode: EpisodeAudioMode, + val language: String? = null, + val codecFamily: String? = null, + val channelCount: Int? = null, + val title: String? = null, +) { + companion object { + fun auto() = EpisodeAudioIntent(EpisodeAudioMode.AUTO) + } +} + +/** + * Pick the track in [candidates] that best answers [intent]. + * + * Language is canonicalised first, because "eng" and "en" are the same choice + * spelled two ways and the catalog and the player disagree about which to use. + * Codec is reduced to a family for the same reason: the player reports MIME + * types like `audio/eac3` where the catalog says `eac3`. + * + * Title carries real weight rather than being decoration. A commentary track + * routinely shares language, codec AND channel count with the main mix, so + * those three cannot tell them apart — the name is the only thing that can. + * + * An unresolved tie returns null. Guessing between two tracks that both match + * everything known about the choice is how a viewer ends up in a director's + * commentary they never asked for, and the server default is a better answer + * than a coin toss. + */ +fun resolveEpisodeAudioIntent( + intent: EpisodeAudioIntent, + candidates: List, +): Int? { + if (intent.mode != EpisodeAudioMode.TRACK) return null + if (candidates.isEmpty()) return null + + val language = canonicalEpisodeLanguage(intent.language) + // A track with no language at all is still a choice a viewer made, so an + // intent without one narrows on the other fields rather than giving up. + var pool = if (language == null) { + candidates + } else { + candidates.filter { canonicalEpisodeLanguage(it.language) == language } + } + if (pool.isEmpty()) return null + if (pool.size == 1) return pool.single().index + + val title = normalizedEpisodeToken(intent.title) + if (title != null) { + val byTitle = pool.filter { normalizedEpisodeToken(it.title) == title } + if (byTitle.size == 1) return byTitle.single().index + if (byTitle.isNotEmpty()) pool = byTitle + } + + val codec = episodeAudioCodecFamily(intent.codecFamily) + if (codec != null) { + val byCodec = pool.filter { episodeAudioCodecFamily(it.codecFamily) == codec } + if (byCodec.size == 1) return byCodec.single().index + if (byCodec.isNotEmpty()) pool = byCodec + } + + val channels = intent.channelCount + if (channels != null) { + val byChannels = pool.filter { it.channelCount == channels } + if (byChannels.size == 1) return byChannels.single().index + if (byChannels.isNotEmpty()) pool = byChannels + } + + // Still ambiguous: say so rather than pick. + return pool.singleOrNull()?.index +} + +/** + * The one canonicaliser, shared with subtitles. + * + * An earlier version here rolled its own and was wrong three ways: it ran the + * token through a normaliser that strips '-', so `en-US` became `enus` before + * any locale lookup; it passed three-letter codes straight through, so `fre` + * and `fra` never met; and `Locale.isO3Language` throws on unrecognised input, + * turning odd metadata into a failed playback start. + * + * canonicalSubtitleLanguage already handles all of that and treats `und` as + * absent. Audio and subtitles have no reason to disagree about what a language + * is. + */ +private fun canonicalEpisodeLanguage(raw: String?): String? = + canonicalSubtitleLanguage(raw) + +/** `audio/eac3` from the player and `eac3` from the catalog are one codec. */ +private fun episodeAudioCodecFamily(raw: String?): String? = + normalizedEpisodeToken(raw?.substringAfterLast('/')) + +data class EpisodeAudioCandidate( + val index: Int, + val language: String?, + val codecFamily: String?, + val channelCount: Int?, + /** Often the only thing separating a commentary track from the main mix. */ + val title: String? = null, +) + +@Serializable +data class EpisodeSourceIntent( + val resolution: String, + val videoCodec: String? = null, + val dynamicRange: EpisodeDynamicRange? = null, + val container: String? = null, +) + +@Serializable +enum class EpisodeDynamicRange { SDR, HDR, DOLBY_VISION } + +@Serializable +enum class EpisodeSubtitleMode { AUTO, OFF, TRACK } + +@Serializable +data class EpisodeSubtitleIntent( + val mode: EpisodeSubtitleMode, + val language: String? = null, + val codecFamily: String? = null, + val forced: Boolean? = null, + val hearingImpaired: Boolean? = null, + val external: Boolean? = null, +) { + companion object { + fun auto() = EpisodeSubtitleIntent(EpisodeSubtitleMode.AUTO) + fun off() = EpisodeSubtitleIntent(EpisodeSubtitleMode.OFF) + } +} + +data class ResolvedEpisodeSubtitle( + val trackIndex: Int?, + val intentSpecified: Boolean, +) + +data class ResolvedEpisodeSelection( + val fileId: Int?, + val subtitleTrackIndex: Int?, + val subtitleIntentSpecified: Boolean, + /** Null keeps the server default, which is what AUTO means. */ + val audioTrackIndex: Int? = null, +) + +fun captureEpisodeSourceIntent(version: FileVersion?): EpisodeSourceIntent? { + val version = version ?: return null + val resolution = normalizedEpisodeResolution( + version.resolution ?: version.videoTracks?.firstOrNull()?.resolution, + ) ?: return null + return EpisodeSourceIntent( + resolution = resolution, + videoCodec = normalizedEpisodeToken( + version.codecVideo ?: version.videoTracks?.firstOrNull()?.codec, + ), + dynamicRange = version.episodeDynamicRange(), + container = normalizedEpisodeToken(version.container), + ) +} + +fun captureEpisodeSubtitleIntent( + selectedTrackIndex: Int?, + subtitles: List, +): EpisodeSubtitleIntent = when (selectedTrackIndex) { + null -> EpisodeSubtitleIntent.auto() + -1 -> EpisodeSubtitleIntent.off() + else -> subtitles + .singleOrNull { it.index == selectedTrackIndex } + ?.toEpisodeSubtitleIntent() + ?: EpisodeSubtitleIntent.auto() +} + +fun resolveEpisodeSourceIntent( + intent: EpisodeSourceIntent?, + targetVersions: List, +): Int? { + val intent = intent ?: return null + val candidates = targetVersions.filter { + normalizedEpisodeResolution(it.resolution ?: it.videoTracks?.firstOrNull()?.resolution) == intent.resolution + } + if (candidates.isEmpty()) return null + + val priority = compareBy( + { it.matchesEpisodeVideoCodec(intent) }, + { it.matchesEpisodeDynamicRange(intent) }, + { it.matchesEpisodeContainer(intent) }, + ) + val best = candidates.maxWithOrNull(priority) ?: return null + return candidates + .filter { priority.compare(it, best) == 0 } + .singleOrNull() + ?.fileId +} + +fun resolveEpisodeSubtitleIntent( + intent: EpisodeSubtitleIntent, + targetSubtitles: List, +): ResolvedEpisodeSubtitle = when (intent.mode) { + EpisodeSubtitleMode.AUTO -> ResolvedEpisodeSubtitle( + trackIndex = null, + intentSpecified = false, + ) + EpisodeSubtitleMode.OFF -> ResolvedEpisodeSubtitle( + trackIndex = -1, + intentSpecified = true, + ) + EpisodeSubtitleMode.TRACK -> ResolvedEpisodeSubtitle( + trackIndex = targetSubtitles + .map { track -> EpisodeSubtitleMatch(track, track.episodeSubtitleMatchScore(intent)) } + .filter { it.score.isMeaningfulFor(intent) } + .let { matches -> + val best = matches.maxWithOrNull(episodeSubtitleMatchComparator) ?: return@let null + matches + .filter { episodeSubtitleMatchComparator.compare(it, best) == 0 } + .singleOrNull() + ?.track + ?.index + }, + intentSpecified = true, + ) +} + +fun resolveEpisodeSelectionHandoff( + handoff: EpisodeSelectionHandoff?, + targetVersions: List, + targetSubtitles: List, +): ResolvedEpisodeSelection { + val subtitle = resolveEpisodeSubtitleIntent( + handoff?.subtitle ?: EpisodeSubtitleIntent.auto(), + targetSubtitles, + ) + return ResolvedEpisodeSelection( + fileId = resolveEpisodeSourceIntent(handoff?.source, targetVersions), + subtitleTrackIndex = subtitle.trackIndex, + subtitleIntentSpecified = subtitle.intentSpecified, + ) +} + +fun encodeEpisodeSelectionHandoff(handoff: EpisodeSelectionHandoff): String = + episodeSelectionHandoffJson.encodeToString(handoff) + +fun decodeEpisodeSelectionHandoff(value: String?): EpisodeSelectionHandoff? = + value?.takeIf { it.isNotBlank() }?.let { encoded -> + runCatching { episodeSelectionHandoffJson.decodeFromString(encoded) } + .getOrNull() + } + +private fun FileVersion.matchesEpisodeVideoCodec(intent: EpisodeSourceIntent): Boolean = + intent.videoCodec != null && + normalizedEpisodeToken(codecVideo ?: videoTracks?.firstOrNull()?.codec) == intent.videoCodec + +private fun FileVersion.matchesEpisodeDynamicRange(intent: EpisodeSourceIntent): Boolean = + intent.dynamicRange != null && episodeDynamicRange() == intent.dynamicRange + +private fun FileVersion.matchesEpisodeContainer(intent: EpisodeSourceIntent): Boolean = + intent.container != null && normalizedEpisodeToken(container) == intent.container + +private fun FileVersion.episodeDynamicRange(): EpisodeDynamicRange { + val tracks = videoTracks.orEmpty() + val isDolbyVision = tracks.any { track -> + DolbyVisionDetection.isDolbyVision( + dolbyVisionProfile = track.dolbyVisionProfile, + hdrFormat = track.hdrFormat, + videoCodec = track.codec, + ) + } || DolbyVisionDetection.isDolbyVision(videoCodec = codecVideo) + return when { + isDolbyVision -> EpisodeDynamicRange.DOLBY_VISION + hdr || tracks.any { it.hdr } -> EpisodeDynamicRange.HDR + else -> EpisodeDynamicRange.SDR + } +} + +private fun PlayerSubtitleInfo.toEpisodeSubtitleIntent(): EpisodeSubtitleIntent = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = canonicalSubtitleLanguage(language), + codecFamily = normalizedSubtitleCodecFamily(codec), + forced = forced, + hearingImpaired = subtitleLabelIndicatesHearingImpaired(catalogLabel ?: label), + external = episodeSubtitleExternal(), +) + +private data class EpisodeSubtitleMatch( + val track: PlayerSubtitleInfo, + val score: EpisodeSubtitleMatchScore, +) + +private data class EpisodeSubtitleMatchScore( + val language: Boolean, + val forced: Boolean, + val hearingImpaired: Boolean, + val external: Boolean, + val codecFamily: Boolean, +) { + fun isMeaningfulFor(intent: EpisodeSubtitleIntent): Boolean = when { + intent.language != null -> language + intent.forced == true -> forced + intent.hearingImpaired == true -> hearingImpaired + else -> external || codecFamily + } +} + +private val episodeSubtitleMatchComparator = compareBy( + { it.score.language }, + { it.score.forced }, + { it.score.hearingImpaired }, + { it.score.external }, + { it.score.codecFamily }, +) + +private fun PlayerSubtitleInfo.episodeSubtitleMatchScore( + intent: EpisodeSubtitleIntent, +): EpisodeSubtitleMatchScore = EpisodeSubtitleMatchScore( + language = intent.language != null && canonicalSubtitleLanguage(language) == intent.language, + forced = intent.forced != null && forced == intent.forced, + hearingImpaired = intent.hearingImpaired != null && + episodeSubtitleHearingImpaired() == intent.hearingImpaired, + external = intent.external != null && episodeSubtitleExternal() == intent.external, + codecFamily = intent.codecFamily != null && normalizedSubtitleCodecFamily(codec) == intent.codecFamily, +) + +private fun PlayerSubtitleInfo.episodeSubtitleHearingImpaired(): Boolean = + subtitleLabelIndicatesHearingImpaired(catalogLabel ?: label) + +private fun PlayerSubtitleInfo.episodeSubtitleExternal(): Boolean? = when ( + (catalogSource ?: source)?.trim()?.lowercase() +) { + "embedded" -> false + "external", "downloaded" -> true + else -> null +} + +private fun normalizedEpisodeResolution(value: String?): String? { + val normalized = value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null + return when { + normalized.contains("4320") || normalized.contains("8k") -> "4320p" + normalized.contains("2160") || normalized.contains("4k") || normalized.contains("uhd") -> "2160p" + normalized.contains("1440") || normalized.contains("qhd") -> "1440p" + normalized.contains("1080") || normalized.contains("fhd") -> "1080p" + normalized.contains("720") || normalized.contains("hd") -> "720p" + normalized.contains("576") -> "576p" + normalized.contains("480") || normalized.contains("sd") -> "480p" + else -> normalized + } +} + +private fun normalizedEpisodeToken(value: String?): String? = + value + ?.trim() + ?.lowercase() + ?.filter(Char::isLetterOrDigit) + ?.takeIf { it.isNotEmpty() } + +private val episodeSelectionHandoffJson = Json { + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatching.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatching.kt new file mode 100644 index 000000000..2a780cfa8 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatching.kt @@ -0,0 +1,122 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack + +/** + * A mounted audio track as the player reports it, reduced to the fields that + * can identify it. Client-agnostic so phone and TV can both feed their own + * track-entry type in without duplicating the matcher. + * + * [ordinal] is the Media3 audio-group ordinal — the argument + * `AudioTrackManager.selectAudioTrack` expects. It is NOT a catalog position. + */ +data class MountedAudioTrack( + val ordinal: Int, + val language: String?, + val codecOrMime: String?, + val channelCount: Int?, + val label: String? = null, +) + +/** + * Finds the mounted track that IS [catalog], or null. + * + * Audio selection on TV only ever staged a server replan, so when a stream + * direct-plays and carries several audio tracks, choosing a different one + * updated the plan while the player kept decoding the original. Switching + * locally needs the mounted track that corresponds to the chosen catalog row — + * and catalog ordinals are not Media3 ordinals, so that has to be established + * by identity. + * + * Deliberately conservative. A null result means "not present in this stream, + * ask the server to replan", which is the correct answer for a transcode: a + * DTS 5.1 source delivered as stereo AAC is not the same track, and matching it + * would silently pick the wrong audio and skip the replan that was needed. + * + * An unresolved tie also returns null rather than guessing, for the reason + * [resolveEpisodeAudioIntent] gives: two tracks that agree on everything known + * are how a viewer lands in a director's commentary they never asked for. + */ +fun matchMountedAudioTrack( + catalog: AudioTrack, + mounted: List, +): MountedAudioTrack? { + if (mounted.isEmpty()) return null + + val language = canonicalAudioLanguage(catalog.language) + var pool = if (language == null) { + mounted + } else { + mounted.filter { canonicalAudioLanguage(it.language) == language } + } + if (pool.isEmpty()) return null + + // Codec must agree when both sides state one. This is what keeps a + // transcoded representation from matching its own source. + val codec = canonicalAudioCodecFamily(catalog.codec) + if (codec != null) { + val stated = pool.filter { canonicalAudioCodecFamily(it.codecOrMime) != null } + if (stated.isNotEmpty()) { + pool = stated.filter { canonicalAudioCodecFamily(it.codecOrMime) == codec } + if (pool.isEmpty()) return null + } + } + if (pool.size == 1) return pool.single() + + val channels = catalog.channels?.takeIf { it > 0 } + if (channels != null) { + val stated = pool.filter { (it.channelCount ?: 0) > 0 } + if (stated.isNotEmpty()) { + val byChannels = stated.filter { it.channelCount == channels } + if (byChannels.isEmpty()) return null + pool = byChannels + } + } + if (pool.size == 1) return pool.single() + + // Language, codec and channel count are routinely identical between a main + // mix and its commentary; the name is the only thing left that separates + // them. + val title = normalizedAudioToken(catalog.title) + if (title != null) { + val byTitle = pool.filter { normalizedAudioToken(it.label) == title } + if (byTitle.size == 1) return byTitle.single() + } + + return null +} + +/** + * Canonical audio codec family across catalog spellings and Media3 identifiers. + * + * The catalog says `aac` where Media3 says `audio/mp4a-latm` or `mp4a.40.2`, + * and `dts` where Media3 says `audio/vnd.dts`. Stripping to the last path + * segment — which is all the episode-handoff normaliser does — leaves + * `mp4a-latm`, which matches nothing. + */ +fun canonicalAudioCodecFamily(raw: String?): String? { + val token = normalizedAudioToken(raw?.substringAfterLast('/')) ?: return null + return when { + token.startsWith("mp4a") || token == "aac" || token == "aacl" -> "aac" + token.startsWith("vnddts") || token.startsWith("dts") || token == "dca" -> "dts" + token.startsWith("ec3") || token == "eac3" || token == "ddp" -> "eac3" + token.startsWith("ac3") -> "ac3" + token.contains("truehd") || token == "mlp" -> "truehd" + token.startsWith("flac") -> "flac" + token.startsWith("opus") -> "opus" + token.startsWith("vorbis") -> "vorbis" + token.startsWith("mpeg") || token == "mp3" || token == "mp2" -> "mp3" + token.startsWith("pcm") || token.startsWith("raw") -> "pcm" + else -> token + } +} + +/** Shares subtitle's canonicaliser so "eng" and "en" are one answer. */ +private fun canonicalAudioLanguage(raw: String?): String? = + org.prairieserver.prairie.playback.canonicalSubtitleLanguage(raw) + +/** Lowercase, strip everything that is not a letter or digit. */ +private fun normalizedAudioToken(raw: String?): String? = raw + ?.lowercase() + ?.filter { it.isLetterOrDigit() } + ?.takeIf { it.isNotBlank() } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt index 9f7e45804..82f470a57 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackContainerPolicy.kt @@ -1,12 +1,41 @@ package org.prairieserver.prairie.common.player.video -val media3OriginalPlaybackContainers: List = +/** + * Containers Media3 opens directly for video sources. + */ +val media3OriginalVideoContainers: List = listOf( "mp4", "m4v", "mov", "qt", "webm", "mkv", "matroska", "avi", "ts", "mpegts", "mpeg-ts", "m2ts", "mts", ) +/** + * Bare audio containers Media3 opens directly, via its own extractors — Mp3, + * Flac, Wav, and Ogg are first-party. + * + * These are listed apart from the video containers because they only ever + * arrive as an audio-only source (audiobooks and music), never as a video + * file's container. Omitting them is not cosmetic under protocol v3: the + * audio-only planner gates its `original_http` route on the source container + * appearing in the advertised list, and the `progressive` delivery this client + * would otherwise fall back to is disabled pending a seekable transport. An + * `.mp3` audiobook with neither would plan to `adaptation_unavailable` — no + * playable route at all — despite Media3 being perfectly able to play it. + */ +val media3OriginalAudioContainers: List = + listOf( + "mp3", "m4a", "m4b", "aac", "flac", + "wav", "ogg", "oga", "opus", + ) + +/** + * The full direct-play container advertisement: what the client claims it can + * open without server adaptation, for any source. + */ +val media3OriginalPlaybackContainers: List = + media3OriginalVideoContainers + media3OriginalAudioContainers + fun normalizedPlaybackContainer(container: String?): String? = container ?.trim() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetector.kt index f7ee975d3..dec8f2efd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetector.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.common.player.video import org.prairieserver.prairie.common.player.Playability +import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_DV81 +import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_HDR10 import org.prairieserver.prairie.model.playback.PlayMethod /** @@ -15,6 +17,7 @@ import org.prairieserver.prairie.model.playback.PlayMethod class PlaybackStartupStallDetector( private val startupGraceMs: Long = DEFAULT_STARTUP_GRACE_MS, private val midStreamGraceMs: Long = DEFAULT_MID_STREAM_GRACE_MS, + private val clientTransformGraceMs: Long = DEFAULT_CLIENT_TRANSFORM_GRACE_MS, private val startedProgressMs: Long = DEFAULT_STARTED_PROGRESS_MS, private val bufferedProgressMs: Long = DEFAULT_BUFFERED_PROGRESS_MS, ) { @@ -24,6 +27,11 @@ class PlaybackStartupStallDetector( private var signaled = false private var firstFrameRendered = false private var decoderStartupAtMs: Long? = null + private var clientDolbyVisionTransform = false + private var clientTransformEvidenceAtMs: Long? = null + private var clientTransformPositionMs: Long = 0L + private var clientTransformDecoderOutputCount: Int = 0 + private var clientTransformProgressAtMs: Long = 0L private var paused = false // Last time playback made forward progress (or the mount time before it // starts). The stall is measured from here, so the same logic covers a @@ -37,6 +45,7 @@ class PlaybackStartupStallDetector( playMethod: PlayMethod, startPositionMs: Long, nowMs: Long, + clientTransformations: Collection = emptyList(), ) { if (this.sessionKey == sessionKey) return this.sessionKey = sessionKey @@ -44,13 +53,51 @@ class PlaybackStartupStallDetector( this.started = false this.signaled = false this.firstFrameRendered = false + // Only the startup deadline is cleared here — this is NOT a counter + // baseline, despite the shape of the check in sample(). Taking one was + // tried and reverted: Media3 creates fresh DecoderCounters when a + // renderer is enabled, so a baseline captured at mount can be compared + // against a counter that restarted at zero, and a healthy stream then + // looks frozen until it has rendered as many frames again. That trades + // a rare missed freeze for a common invented one. The residual — a + // reused player whose cumulative count makes the first sample look like + // this attempt already rendered — is accepted, and the real fix is + // AnalyticsListener.onRenderedFirstFrame(EventTime) carried through a + // mount key, which needs hardware to validate. this.decoderStartupAtMs = null + this.clientDolbyVisionTransform = clientTransformations.any { + it == CLIENT_DV7_TO_DV81 || it == CLIENT_DV7_TO_HDR10 + } + this.clientTransformEvidenceAtMs = null + this.clientTransformPositionMs = this.startPositionMs + this.clientTransformDecoderOutputCount = 0 + this.clientTransformProgressAtMs = nowMs this.paused = false this.lastProgressPositionMs = this.startPositionMs this.lastBufferedPositionMs = this.startPositionMs this.lastProgressAtMs = nowMs } + /** + * A frame rendered. Which stream rendered it is NOT known. + * + * Media3's callback carries no identity, so one from an outgoing stream can + * vouch for its replacement. That is a real defect and it is deliberately + * left in place: the two cheaper alternatives are both worse. + * + * Qualifying the callback with a key rebuilt from live state fails, because + * that key describes when the event was DELIVERED, not what rendered it. + * Comparing decoder counters against a mount baseline fails too, because + * Media3 creates fresh DecoderCounters when a renderer is enabled — so an + * outgoing count compared against a restarted counter would make a healthy + * stream look frozen until it had rendered as many frames again, which + * trades a rare missed freeze for a common false one. + * + * The correct fix is AnalyticsListener.onRenderedFirstFrame(EventTime), + * whose EventTime identifies the media period, carried through a mount key + * on the MediaItem tag. That is Media3 integration work whose failure modes + * are device-specific, and it is not being written blind. + */ fun onFirstFrameRendered() { firstFrameRendered = true decoderStartupAtMs = null @@ -82,6 +129,7 @@ class PlaybackStartupStallDetector( paused = true decoderStartupAtMs = null lastProgressAtMs = nowMs + clientTransformProgressAtMs = nowMs return null } if (paused) { @@ -90,6 +138,55 @@ class PlaybackStartupStallDetector( // paused is not evidence of a decoder or transport failure. decoderStartupAtMs = null lastProgressAtMs = nowMs + clientTransformProgressAtMs = nowMs + } + + val hasClientTransformDecodeEvidence = clientDolbyVisionTransform && + (firstFrameRendered || decoderInputBufferCount > 0 || decoderOutputCount > 0) + if (hasClientTransformDecodeEvidence && clientTransformEvidenceAtMs == null) { + clientTransformEvidenceAtMs = nowMs + clientTransformPositionMs = currentPositionMs + clientTransformDecoderOutputCount = decoderOutputCount + clientTransformProgressAtMs = nowMs + } + val clientTransformSeekedBackward = currentPositionMs < clientTransformPositionMs + clientTransformPositionMs = currentPositionMs + if (clientTransformSeekedBackward) { + // A seek or timeline replacement starts a fresh local-transform + // deadline just as it does for the transport progress clock. + clientTransformDecoderOutputCount = decoderOutputCount + clientTransformProgressAtMs = nowMs + } else if ( + hasClientTransformDecodeEvidence && + decoderOutputCount != clientTransformDecoderOutputCount + ) { + // Playback position can advance on audio alone while the video + // transform is wedged. Only decoded video output proves this local + // recipe is still making progress. A counter reset also starts a + // fresh deadline because Media3 may replace DecoderCounters with a + // new renderer instance during a timeline replacement. + clientTransformDecoderOutputCount = decoderOutputCount + clientTransformProgressAtMs = nowMs + } + + // A Profile 7 client transform can consume bytes and emit an initial + // frame before wedging locally. Decoder output then makes the generic + // classifier call this a transport stall, causing the client to reopen + // the same doomed route before it ever asks the server for another + // recipe. Once the transform has reached the decoder, use a separate + // bounded progress deadline and identify the failed local recipe. This + // deliberately does not cover a route with zero decoder evidence: a + // genuine no-input network stall keeps the normal transport retry. + if (!signaled && hasClientTransformDecodeEvidence && + (isBuffering || isPlaying) && + nowMs - clientTransformProgressAtMs > clientTransformGraceMs + ) { + signaled = true + return Playability.StartupStalled( + bufferedAheadMs = (bufferedPositionMs - currentPositionMs).coerceAtLeast(0L), + stalledForMs = nowMs - clientTransformProgressAtMs, + classification = DV7_TRANSFORM_STALL_CLASSIFICATION, + ) } // Audio may advance the position and set isPlaying=true while video is @@ -159,7 +256,9 @@ class PlaybackStartupStallDetector( companion object { const val DEFAULT_STARTUP_GRACE_MS: Long = 20_000L const val DEFAULT_MID_STREAM_GRACE_MS: Long = 20_000L + const val DEFAULT_CLIENT_TRANSFORM_GRACE_MS: Long = 10_000L const val DEFAULT_STARTED_PROGRESS_MS: Long = 1_500L const val DEFAULT_BUFFERED_PROGRESS_MS: Long = 250L + const val DV7_TRANSFORM_STALL_CLASSIFICATION = "dv7_transform_stall" } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetector.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetector.kt index af078aa8b..815866939 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetector.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetector.kt @@ -41,6 +41,11 @@ class PostResumeVideoStallDetector( baselineRenderedCount = 0 } + /** + * A frame rendered. Provenance unknown — see PlaybackStartupStallDetector + * for why neither a rebuilt key nor a counter baseline can supply it, and + * what the real fix is. + */ fun onFirstFrameRendered() { firstFrameRendered = true } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt index d9984a62d..f72f3c1e3 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackSessionCoordinator.kt @@ -42,10 +42,13 @@ class VideoPlaybackSessionCoordinator( showForcedSubtitles = result.showForcedSubtitles, intro = result.intro, credits = result.credits, + recap = result.recap, + preview = result.preview, chapters = result.chapters, seriesId = result.seriesId, seasonNumber = result.seasonNumber, episodeNumber = result.episodeNumber, + resolvedEpisodeSelection = result.resolvedEpisodeSelection, ) } is VideoPlaybackStartResult.Error -> { diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt index cc47d140d..d11f24c68 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.common.player.video +import org.prairieserver.prairie.common.player.StartParams + data class VideoPlaybackStartRequest( val contentId: String, val preferredFileId: Int?, @@ -29,4 +31,15 @@ data class VideoPlaybackStartRequest( * unreachable (issue #33). Default false = the gate applies. */ val force: Boolean = false, + /** + * Session-only intent captured from the preceding episode. TV resolves it + * against this request's target catalog only after loading its watch detail. + */ + val episodeSelectionHandoff: EpisodeSelectionHandoff? = null, + /** + * Exact adoption-time evidence for renewing a server session that vanished. + * A renewal is the same output route, so probing capabilities or rebuilding + * context here would silently turn it into a different playback decision. + */ + val recoveryStartParams: StartParams? = null, ) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt index ec350463a..6f6b14051 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt @@ -37,7 +37,8 @@ sealed interface VideoPlaybackStartResult { val accessToken: String = "", val mediaFileId: Int? = null, val audioTrackIndex: Int = 0, - val durationSeconds: Double = 0.0, + /** Full source duration; null when the V3 plan leaves it unknown. */ + val durationSeconds: Double? = null, val subtitleUrls: List = emptyList(), val preferredAudioLanguage: String? = null, val preferredTextLanguage: String? = null, @@ -45,11 +46,15 @@ sealed interface VideoPlaybackStartResult { val showForcedSubtitles: Boolean = true, val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, val chapters: List = emptyList(), // Episode context for next-episode auto-advance (null for movies). val seriesId: String? = null, val seasonNumber: Int? = null, val episodeNumber: Int? = null, + /** TV's target-catalog resolution of [VideoPlaybackStartRequest.episodeSelectionHandoff]. */ + val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, ) : VideoPlaybackStartResult data class Error( diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt index e162a0f48..ade9c7109 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerUiState.kt @@ -63,7 +63,8 @@ sealed interface VideoPlayerUiState { val accessToken: String = "", val mediaFileId: Int? = null, val audioTrackIndex: Int = 0, - val durationSeconds: Double = 0.0, + /** Full source duration; null when the V3 plan leaves it unknown. */ + val durationSeconds: Double? = null, val subtitleUrls: List = emptyList(), val preferredAudioLanguage: String? = null, val preferredTextLanguage: String? = null, @@ -71,11 +72,15 @@ sealed interface VideoPlayerUiState { val showForcedSubtitles: Boolean = true, val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, val chapters: List = emptyList(), // Episode context for next-episode auto-advance (null for movies). val seriesId: String? = null, val seasonNumber: Int? = null, val episodeNumber: Int? = null, + /** Target-catalog decision for the one-shot episode-selection handoff. */ + val resolvedEpisodeSelection: ResolvedEpisodeSelection? = null, ) : VideoPlayerUiState { override val hasPlayableMedia: Boolean = true diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinator.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinator.kt index 00b79536f..8fe873889 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinator.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinator.kt @@ -26,7 +26,7 @@ class VideoTrackSelectionCoordinator( fun selectSubtitle( player: Player, playerFactory: PrairiePlayerFactory, - mediaSpec: VideoPlayerMediaSpec, + mediaSpec: VideoPlayerMediaSpec?, selectedTrack: VideoPlayerTrackEntry?, ): Boolean { if (selectedTrack == null) { @@ -35,10 +35,11 @@ class VideoTrackSelectionCoordinator( val subtitle = selectedTrack.subtitle if (subtitle != null) { + val mountedMediaSpec = mediaSpec ?: return false refreshMountedVideoMedia( player = player, playerFactory = playerFactory, - spec = mediaSpec.copy(subtitles = listOf(subtitle)), + spec = mountedMediaSpec.copy(subtitles = listOf(subtitle)), ) return subtitleManager.selectSubtitle(player, listOf(subtitle), 0) } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt index dbb2db377..b9b478c0c 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt @@ -9,10 +9,16 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile -import org.prairieserver.prairie.model.settings.EffectiveSetting +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.download.DownloadQuality +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.LanguageOptions import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.model.settings.SubtitleAppearance +import org.prairieserver.prairie.model.settings.SubtitleAppearanceProjection import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.SettingsRepository import kotlinx.coroutines.CoroutineScope @@ -26,6 +32,13 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull @OptIn(ExperimentalCoroutinesApi::class) class AndroidPlayerSettingsStore( @@ -69,6 +82,14 @@ class AndroidPlayerSettingsStore( } val migrationSentinel: String = if (keyPrefix.isEmpty()) MIGRATION_SENTINEL_LEGACY else "migration_v2_$keyPrefix" + + /** + * Separate from [migrationSentinel] deliberately: that one is already + * marked on every install that has run a scoped build, so a rename + * pass gated on it would never run for the devices holding the + * orphaned pre-rename values. + */ + val renameSentinel: String = "migration_rename_v1_$keyPrefix" } // Re-derive scope on every (profile or server) change. @@ -95,6 +116,11 @@ class AndroidPlayerSettingsStore( } private suspend fun ensureMigrated(scope: Scope, store: DataStore) { + migrateLegacyCache(scope, store) + migrateRenamedKeys(scope, store) + } + + private suspend fun migrateLegacyCache(scope: Scope, store: DataStore) { val token = scope.profileId + "/" + scope.migrationSentinel if (synchronized(migrationDone) { token in migrationDone }) return val sentinelKey = booleanPreferencesKey(scope.migrationSentinel) @@ -114,6 +140,75 @@ class AndroidPlayerSettingsStore( synchronized(migrationDone) { migrationDone.add(token) } } + /** + * Copies the two slots the settings cutover renamed + * ([PlaybackSettingsKeys.RenamedLocalKeys]) into their current names. + * + * Carries its own sentinel rather than riding [migrateLegacyCache]'s: that + * one is already marked on every device that has run this app since the + * scoped-store change, so a pass gated on it would never execute for the + * installs that actually hold the orphaned values. Both reads are + * local-first — subtitle appearance drives downloaded playback with no + * server in the loop — so skipping the copy silently reverts a preference + * the user set until a canonical refresh happens to land. + */ + private suspend fun migrateRenamedKeys(scope: Scope, store: DataStore) { + val token = scope.profileId + "/" + scope.renameSentinel + if (synchronized(migrationDone) { token in migrationDone }) return + val sentinelKey = booleanPreferencesKey(scope.renameSentinel) + val current = store.data.first() + if (current[sentinelKey] != true) { + store.edit { prefs -> + for ((oldKey, newKey) in PlaybackSettingsKeys.RenamedLocalKeys) { + copyRenamedSlot(prefs, scope, oldKey = oldKey, newKey = newKey) + } + prefs[sentinelKey] = true + } + } + synchronized(migrationDone) { migrationDone.add(token) } + } + + /** + * Copies one renamed slot, typed by the *new* key's contract type. + * + * The old slot may sit under this scope's prefix (written after the scoped + * store landed) or unprefixed (written before it), so both are checked — + * the same order [scopedRead] uses. A value already present under the new + * name always wins: it is either a fresh edit or a canonical refresh, and + * either outranks whatever the pre-rename build left behind. + */ + private fun copyRenamedSlot( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + oldKey: String, + newKey: String, + ) { + val target = scope.keyPrefix + newKey + when { + isIntKey(newKey) -> { + if (prefs[intPreferencesKey(target)] != null) return + val legacy = prefs[intPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[intPreferencesKey(oldKey)] + ?: return + prefs[intPreferencesKey(target)] = legacy + } + isBooleanKey(newKey) -> { + if (prefs[booleanPreferencesKey(target)] != null) return + val legacy = prefs[booleanPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[booleanPreferencesKey(oldKey)] + ?: return + prefs[booleanPreferencesKey(target)] = legacy + } + else -> { + if (prefs[stringPreferencesKey(target)] != null) return + val legacy = prefs[stringPreferencesKey(scope.keyPrefix + oldKey)] + ?: prefs[stringPreferencesKey(oldKey)] + ?: return + prefs[stringPreferencesKey(target)] = legacy + } + } + } + private fun profileScopedFlow(default: T, read: (Preferences, Scope) -> T): Flow = currentScopeFlow.flatMapLatest { scope -> if (scope == null) { @@ -127,10 +222,30 @@ class AndroidPlayerSettingsStore( } } - // ---- Booleans ------------------------------------------------------ - override val autoSkipIntroFlow: Flow = - profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.AutoSkipIntro, false) } + /** + * The stored enum, falling back to the deprecated boolean when there is no + * enum to read. + * + * That fallback IS the "server contract revision < 7" case the spec asks + * for, without a second copy of the revision to keep in step: + * [applyEffectiveLocally] only writes keys the effective-values response + * actually answered, and a server older than revision 7 does not know + * `playback.intro_skip_mode` — so the slot stays empty and the boolean the + * same response *did* answer decides. A revision-7 server answers both and + * the enum wins, which is what the write mirror keeps consistent. + * + * [autoSkipIntroFlow] is not overridden: the interface projects it from + * here so the two can never disagree locally. + */ + override val introSkipModeFlow: Flow = + profileScopedFlow(IntroSkipMode.Default) { p, s -> + IntroSkipMode.fromWire(p.stringFor(s, PlaybackSettingsKeys.IntroSkipMode, "")) + ?: IntroSkipMode.fromLegacyBoolean( + p.boolFor(s, PlaybackSettingsKeys.AutoSkipIntro, false), + ) + } + // ---- Booleans ------------------------------------------------------ override val autoSkipCreditsFlow: Flow = profileScopedFlow(false) { p, s -> p.boolFor(s, PlaybackSettingsKeys.AutoSkipCredits, false) } @@ -155,6 +270,13 @@ class AndroidPlayerSettingsStore( override val pictureInPictureEnabledFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.PictureInPictureEnabled, true) } + override val letterboxExpansionFlow: Flow = + profileScopedFlow(LetterboxExpansion.Default) { p, s -> + p.stringFor(s, PlaybackSettingsKeys.LetterboxExpansion, LetterboxExpansion.Default) + .takeIf { it in LetterboxExpansion.Valid } + ?: LetterboxExpansion.Default + } + override val downloadsWifiOnlyFlow: Flow = profileScopedFlow(true) { p, s -> p.boolFor(s, PlaybackSettingsKeys.DownloadsWifiOnly, true) } @@ -186,18 +308,6 @@ class AndroidPlayerSettingsStore( override val subtitleSyncMsFlow: Flow = profileScopedFlow(0) { p, s -> p.intFor(s, PlaybackSettingsKeys.SubtitleSyncMs, 0) } - override fun subtitleSyncMsFor(contentId: String?): Flow = - if (contentId.isNullOrBlank()) { - subtitleSyncMsFlow - } else { - profileScopedFlow(0) { p, s -> - decodeSubtitleSyncOverrides( - p.stringFor(s, PlaybackSettingsKeys.SubtitleSyncMsByItem, ""), - )[contentId] - ?: p.intFor(s, PlaybackSettingsKeys.SubtitleSyncMs, 0) - } - } - override val nextUpPromptSecondsFlow: Flow = profileScopedFlow(30) { p, s -> p.intFor(s, PlaybackSettingsKeys.NextUpPromptSeconds, 30) } @@ -214,12 +324,35 @@ class AndroidPlayerSettingsStore( p.intFor(s, PlaybackSettingsKeys.PassOutThreshold, DEFAULT_PASSOUT_THRESHOLD) } + // Uncapped is spelled 0 locally (Preferences cannot hold a null) and + // translated to JSON null on the wire — 0 is outside the contract's + // 100..200000 range, so it can never collide with a real cap. + override val maxBitrateKbpsFlow: Flow = + profileScopedFlow(null) { p, s -> + p.intFor(s, PlaybackSettingsKeys.MaxBitrateKbps, 0).takeIf { it > 0 } + } + // ---- Strings ------------------------------------------------------- + // Legacy compound spellings ("1080p-high") are normalized on read: the + // bitrate they encoded lives on its own axis now, and handing a compound + // value to the player or back to the server would be refused. override val preferredQualityFlow: Flow = - profileScopedFlow("auto") { p, s -> p.stringFor(s, PlaybackSettingsKeys.PreferredQuality, "auto") } + profileScopedFlow(QualityPresets.RESOLUTION_AUTO) { p, s -> + QualityPresets.normalizeResolution( + p.stringFor(s, PlaybackSettingsKeys.PreferredQuality, QualityPresets.RESOLUTION_AUTO), + ) + } + // Older builds stored the display name ("English") here rather than a BCP 47 + // tag. Those values are rejected by the server and never matched a track, so + // they are translated on read instead of being handed to ExoPlayer or + // re-sent. Anything already a tag passes through untouched. override val audioLanguageFlow: Flow = - profileScopedFlow("") { p, s -> p.stringFor(s, PlaybackSettingsKeys.AudioLanguage, "") } + profileScopedFlow("") { p, s -> + LanguageOptions.migrateLegacyValue( + p.stringFor(s, PlaybackSettingsKeys.AudioLanguage, ""), + ) + } override val videoGravityFlow: Flow = profileScopedFlow("fit") { p, s -> p.stringFor(s, PlaybackSettingsKeys.VideoGravity, "fit") } @@ -227,17 +360,18 @@ class AndroidPlayerSettingsStore( override val orientationModeFlow: Flow = profileScopedFlow("auto") { p, s -> p.stringFor(s, PlaybackSettingsKeys.OrientationMode, "auto") } + // The composite is the stored truth, but the granular subtitle.* slots are + // a live overlay: the player's per-field controls write them directly, and + // a value there that the composite has not caught up with is the newer + // edit. Projecting on read (and again on flush) is what keeps a per-field + // change from being invisible to the server. override val subtitleAppearanceFlow: Flow = - profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> - SubtitleAppearance.decode(p.stringFor(s, PlaybackSettingsKeys.SubtitleAppearance, "")) - } + profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> p.projectedAppearance(s) } override val savedCustomSubtitleAppearanceFlow: Flow = profileScopedFlow(SubtitleAppearance.DEFAULT) { p, s -> - SubtitleAppearance.decode( - p[stringPreferencesKey(s.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] - ?: p.stringFor(s, PlaybackSettingsKeys.SubtitleAppearance, ""), - ) + val saved = p[stringPreferencesKey(s.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] + if (saved != null) SubtitleAppearance.decode(saved) else p.projectedAppearance(s) } override val subtitleMatchesDeviceFlow: Flow = @@ -264,8 +398,18 @@ class AndroidPlayerSettingsStore( writeBoolLocal(PlaybackSettingsKeys.NavShowAudiobooks, enabled) // ---- Setters (write to scoped key + enqueue server flush) --------- - override suspend fun setAutoSkipIntro(value: Boolean) = - writeBool(PlaybackSettingsKeys.AutoSkipIntro, value) + /** + * Writes only the enum, never the boolean beside it. + * + * The server mirrors the pair at write time, and its boolean -> enum + * direction is lossy (`false` means `ask`). Enqueueing both would let the + * boolean's mirror land second and rewrite a `never` the viewer just chose + * back to `ask`. On a server older than revision 7 this write is rejected + * per key — the flusher drops that one op rather than poisoning the rest — + * and the local value still stands. + */ + override suspend fun setIntroSkipMode(value: IntroSkipMode) = + writeString(PlaybackSettingsKeys.IntroSkipMode, value.wireValue) override suspend fun setAutoSkipCredits(value: Boolean) = writeBool(PlaybackSettingsKeys.AutoSkipCredits, value) @@ -288,6 +432,11 @@ class AndroidPlayerSettingsStore( override suspend fun setPictureInPictureEnabled(value: Boolean) = writeBoolLocal(PlaybackSettingsKeys.PictureInPictureEnabled, value) + override suspend fun setLetterboxExpansion(value: String) { + val safe = if (value in LetterboxExpansion.Valid) value else LetterboxExpansion.Default + writeStringLocal(PlaybackSettingsKeys.LetterboxExpansion, safe) + } + override suspend fun setDownloadsWifiOnly(value: Boolean) = writeBoolLocal(PlaybackSettingsKeys.DownloadsWifiOnly, value) @@ -301,7 +450,12 @@ class AndroidPlayerSettingsStore( val clamped = value.coerceIn(0.25, 4.0) withScope { scope, store -> store.edit { it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PlaybackSpeed)] = clamped.toString() } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.PlaybackSpeed, clamped.toString()) + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.PlaybackSpeed, + clamped.toString(), + scope.serverUrl, + ) } } @@ -311,28 +465,6 @@ class AndroidPlayerSettingsStore( override suspend fun setSubtitleSyncMs(value: Int) = writeInt(PlaybackSettingsKeys.SubtitleSyncMs, value.coerceIn(-10000, 10000)) - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) { - if (contentId.isBlank()) return - val clamped = value.coerceIn(-10000, 10000) - withScope { scope, store -> - store.edit { prefs -> - val globalKey = intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleSyncMs) - val mapKey = stringPreferencesKey( - scope.keyPrefix + PlaybackSettingsKeys.SubtitleSyncMsByItem, - ) - val global = prefs[globalKey] ?: 0 - val current = decodeSubtitleSyncOverrides(prefs[mapKey].orEmpty()) - val next = LinkedHashMap(current).apply { - remove(contentId) - // Matching the profile default needs no override; dropping it - // keeps the map from filling with no-op entries. - if (clamped != global) put(contentId, clamped) - } - prefs[mapKey] = encodeSubtitleSyncOverrides(next) - } - } - } - override suspend fun setNextUpPromptSeconds(value: Int) = writeInt(PlaybackSettingsKeys.NextUpPromptSeconds, value.coerceIn(0, 120)) @@ -346,7 +478,32 @@ class AndroidPlayerSettingsStore( writeInt(PlaybackSettingsKeys.SleepTimerDefaultMinutes, value.coerceIn(0, 240)) override suspend fun setPreferredQuality(value: String) = - writeString(PlaybackSettingsKeys.PreferredQuality, value) + writeString(PlaybackSettingsKeys.PreferredQuality, QualityPresets.normalizeResolution(value)) + + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) { + val normalized = QualityPresets.normalizeResolution(resolution) + // 0 is the local spelling of uncapped; the flusher turns it into the + // JSON null the contract means by "no cap". + val capped = bitrateKbps?.takeIf { it > 0 }?.coerceIn(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS) ?: 0 + withScope { scope, store -> + store.edit { + it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PreferredQuality)] = normalized + it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.MaxBitrateKbps)] = capped + } + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.PreferredQuality, + normalized, + scope.serverUrl, + ) + serverSettingsFlusher.enqueue( + scope.profileId, + PlaybackSettingsKeys.MaxBitrateKbps, + capped.toString(), + scope.serverUrl, + ) + } + } override suspend fun setAudioLanguage(value: String) = writeString(PlaybackSettingsKeys.AudioLanguage, value) @@ -360,16 +517,54 @@ class AndroidPlayerSettingsStore( writeString(PlaybackSettingsKeys.OrientationMode, value) override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - val json = value.sanitized().toJsonString() + val sanitized = value.sanitized() + val json = sanitized.toJsonString() withScope { scope, store -> - store.edit { - it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json - it[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + store.edit { prefs -> + prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + // The granular slots are rewritten from the composite rather + // than left behind: they are read back as an overlay, so a + // stale field would resurrect the value the user just replaced. + writeGranularAppearance(prefs, scope, sanitized) // Setting an explicit appearance implicitly enables the // device override (matches iOS `setSubtitleAppearance`). - it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true + } + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) + } + } + + /** + * Flushes the granular `subtitle.*` slots into the composite and enqueues + * it, so a per-field edit made anywhere (the player HUD, a TV picker) + * reaches the server as `playback.subtitle_appearance`. + * + * Callable on its own because the granular fields have no key of their own + * on the wire: without this projection a per-field write is device-local + * forever, which is the drift this exists to close. + */ + override suspend fun flushProjectedSubtitleAppearance() { + withScope { scope, store -> + val snapshot = store.data.first() + val projected = snapshot.projectedAppearance(scope) + val json = projected.toJsonString() + if (snapshot.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") == json) return@withScope + store.edit { prefs -> + prefs[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) + } + } + + private fun writeGranularAppearance( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + appearance: SubtitleAppearance, + ) { + for ((key, raw) in SubtitleAppearanceProjection.flatten(appearance)) { + writeRawString(prefs, scope, key, raw) } } @@ -377,10 +572,32 @@ class AndroidPlayerSettingsStore( override suspend fun refreshFromServer() { val repo = settingsRepository ?: return + // Push before pull. Local writes sit in the flusher's debounce for + // ~750ms; a refresh inside that window read the server's OLD value and + // wrote it back over the change the user had just made. The player + // refreshes at every load, so toggling Dolby Vision in the HUD and + // restarting the session in place reverted the toggle every time. + // Draining first makes the pull observe the write. Offline, both + // fail and the local value stands. + runCatching { serverSettingsFlusher.flushNow() } withScope { scope, store -> - val result = repo.getEffectiveSettings(PlaybackSettingsKeys.DeviceSettings) + // Batched canonical resolution: one request answers every + // device-relevant key, each with the scope it resolved from. + val result = repo.getEffectiveValues(RemoteDeviceSettings) if (result !is ApiResult.Success) return@withScope - applyEffectiveLocally(scope, store, result.data) + // Draining is not the same as landing: a write that failed + // transiently stays queued for retry and flushNow still returns + // normally, so this response was answered from the value the write + // has not reached yet. Applying it for those keys is the same + // clobber the push-first order exists to prevent — it put the + // server's old Dolby Vision value back and restarted the session on + // it. Every other key still hydrates from the canonical answer. + applyEffectiveLocally( + scope = scope, + store = store, + effective = result.data, + unlandedKeys = serverSettingsFlusher.pendingKeys(scope.profileId), + ) } } @@ -395,13 +612,19 @@ class AndroidPlayerSettingsStore( snapshot[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] ?: snapshot.stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, "") ) - val json = appearance.sanitized().toJsonString() + val sanitized = appearance.sanitized() + val json = sanitized.toJsonString() store.edit { it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = true it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleAppearance)] = json it[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = json + // The granular slots overlay the composite on read, so + // restoring the saved appearance has to restore them too — + // otherwise the fields left by whatever resolved while the + // override was off win right back over it. + writeGranularAppearance(it, scope, sanitized) } - serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json) + serverSettingsFlusher.enqueue(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, json, scope.serverUrl) serverSettingsFlusher.flushNow() } else { store.edit { @@ -413,7 +636,7 @@ class AndroidPlayerSettingsStore( } it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false } - serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance) + serverSettingsFlusher.enqueueDelete(scope.profileId, PlaybackSettingsKeys.SubtitleAppearance, scope.serverUrl) serverSettingsFlusher.flushNow() refreshFromServer() } @@ -422,7 +645,7 @@ class AndroidPlayerSettingsStore( override suspend fun resetDeviceSetting(key: String) { withScope { scope, _ -> - serverSettingsFlusher.enqueueDelete(scope.profileId, key) + serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl) serverSettingsFlusher.flushNow() refreshFromServer() } @@ -430,11 +653,22 @@ class AndroidPlayerSettingsStore( override suspend fun resetAllDeviceSettings() { withScope { scope, store -> - for (key in PlaybackSettingsKeys.DeviceSettings) { - serverSettingsFlusher.enqueueDelete(scope.profileId, key) + // Only the server-stored keys have rows to delete; the granular + // subtitle.* fields live inside playback.subtitle_appearance. + for (key in RemoteDeviceSettings) { + serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl) } store.edit { it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false + // The local-only playback keys have no server row to delete, so + // the refresh below can never restore their defaults. Removing + // the slot IS the reset: every reader falls back to the default + // it declares. Without this, the action would leave exactly the + // settings the user most associates with this device untouched. + it.remove(booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PictureInPictureEnabled)) + it.remove(stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.LetterboxExpansion)) + it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.ResumeRewindSeconds)) + it.remove(intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PassOutThreshold)) } serverSettingsFlusher.flushNow() refreshFromServer() @@ -448,26 +682,93 @@ class AndroidPlayerSettingsStore( private suspend fun applyEffectiveLocally( scope: Scope, store: DataStore, - effective: Map, + effective: Map, + // Keys whose local edit is still queued for the server: the response + // cannot describe them yet, so the local value stays authoritative + // until the queued write lands. + unlandedKeys: Set = emptySet(), ) { store.edit { prefs -> - for (key in PlaybackSettingsKeys.DeviceSettings) { + for (key in RemoteDeviceSettings) { + if (key in unlandedKeys) continue + // The canonical endpoint answers every known key, including + // ones with nothing stored anywhere — those come back with + // source "default" and the contract default as the value, so + // writing each entry hydrates defaults from the contract + // rather than from anything hardcoded here. A key absent + // from the response is one this server's contract does not + // know (older revision); the local value is kept rather than + // guessed at. val entry = effective[key] ?: continue - writeRawString(prefs, scope, key, entry.effectiveValue) + writeJsonValue(prefs, scope, key, entry.value) } - // Clear the override flag when the server reports no - // device-scoped subtitle_appearance — otherwise a previous - // session's flag could survive a server-side reset. + // An appearance whose write has not landed leaves the flag and the + // granular overlay alone too: they describe where the composite + // resolved from, and the response predates the queued edit. + if (PlaybackSettingsKeys.SubtitleAppearance in unlandedKeys) return@edit + // The override flag mirrors where the subtitle appearance + // actually resolved from. Clearing it when the value no longer + // comes from this device keeps a previous session's flag from + // surviving a server-side reset. val subtitleEntry = effective[PlaybackSettingsKeys.SubtitleAppearance] + val hasDeviceOverride = subtitleEntry?.scope == SettingScope.PROFILE_DEVICE.wire prefs[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = - subtitleEntry?.hasDeviceOverride ?: false - if (subtitleEntry?.hasDeviceOverride == true) { - prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = - subtitleEntry.effectiveValue + hasDeviceOverride + if (subtitleEntry != null) { + // The granular slots are an overlay on the composite, so a + // resolved appearance has to be flattened back into them. Left + // alone, the previous device's field values would win over the + // value the server just said applies. + writeGranularAppearance( + prefs, + scope, + SubtitleAppearance.decode(subtitleEntry.value.toString()), + ) + if (hasDeviceOverride) { + prefs[stringPreferencesKey(scope.keyPrefix + SAVED_CUSTOM_SUBTITLE_APPEARANCE)] = + subtitleEntry.value.toString() + } } } } + /** + * Writes one typed JSON value from the canonical effective response into + * the local slot each flow reads: booleans and ints natively, doubles as + * their string spelling, objects as their JSON document, JSON null as the + * empty string (the local spelling of "no preference"), and everything + * else as the primitive's content. + */ + private fun writeJsonValue( + prefs: androidx.datastore.preferences.core.MutablePreferences, + scope: Scope, + key: String, + value: JsonElement, + ) { + val scopedName = scope.keyPrefix + key + when { + isBooleanKey(key) -> (value as? JsonPrimitive)?.booleanOrNull?.let { + prefs[booleanPreferencesKey(scopedName)] = it + } + isIntKey(key) -> { + // A nullable int (max_bitrate_kbps) resolving to null means + // "no cap", which the store spells as 0. Skipping the write + // instead would leave a previous cap in place and quietly keep + // throttling playback the server just said to stop throttling. + val resolved = (value as? JsonPrimitive)?.intOrNull + ?: if (value is JsonNull && key in NULLABLE_INT_SETTINGS) 0 else null + resolved?.let { prefs[intPreferencesKey(scopedName)] = it } + } + isDoubleKey(key) -> (value as? JsonPrimitive)?.doubleOrNull?.let { + prefs[stringPreferencesKey(scopedName)] = it.toString() + } + value is JsonNull -> prefs[stringPreferencesKey(scopedName)] = "" + value is JsonObject -> prefs[stringPreferencesKey(scopedName)] = value.toString() + value is JsonPrimitive -> prefs[stringPreferencesKey(scopedName)] = value.content + else -> prefs[stringPreferencesKey(scopedName)] = value.toString() + } + } + private fun writeRawString(prefs: androidx.datastore.preferences.core.MutablePreferences, scope: Scope, key: String, raw: String) { val scopedName = scope.keyPrefix + key when { @@ -487,14 +788,14 @@ class AndroidPlayerSettingsStore( private suspend fun writeBool(key: String, value: Boolean) { withScope { scope, store -> store.edit { it[booleanPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value.toString()) + serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl) } } private suspend fun writeInt(key: String, value: Int) { withScope { scope, store -> store.edit { it[intPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value.toString()) + serverSettingsFlusher.enqueue(scope.profileId, key, value.toString(), scope.serverUrl) } } @@ -525,7 +826,7 @@ class AndroidPlayerSettingsStore( private suspend fun writeString(key: String, value: String) { withScope { scope, store -> store.edit { it[stringPreferencesKey(scope.keyPrefix + key)] = value } - serverSettingsFlusher.enqueue(scope.profileId, key, value) + serverSettingsFlusher.enqueue(scope.profileId, key, value, scope.serverUrl) } } @@ -564,6 +865,34 @@ class AndroidPlayerSettingsStore( private fun Preferences.stringFor(scope: Scope, baseKey: String, default: String): String = scopedRead(scope, baseKey, default, ::stringPreferencesKey) + /** + * The composite appearance with the granular, client-local `subtitle.*` + * slots merged over it. + * + * The contract has no definitions for the granular fields, so they never + * leave the device on their own — but a per-field edit still has to reach + * the server, and this is where the two representations are reconciled. + * Merging is sparse (an absent or unparseable field leaves the composite's + * value alone), matching the schema's own rule for a stored appearance. + */ + private fun Preferences.projectedAppearance(scope: Scope): SubtitleAppearance { + val base = SubtitleAppearance.decode( + stringFor(scope, PlaybackSettingsKeys.SubtitleAppearance, ""), + ) + val granular = SubtitleAppearanceProjection.GRANULAR_KEYS.associateWith { key -> + when { + isBooleanKey(key) -> + this[booleanPreferencesKey(scope.keyPrefix + key)]?.toString() + ?: this[booleanPreferencesKey(key)]?.toString() + isIntKey(key) -> + this[intPreferencesKey(scope.keyPrefix + key)]?.toString() + ?: this[intPreferencesKey(key)]?.toString() + else -> stringFor(scope, key, "").takeIf { it.isNotBlank() } + } + } + return SubtitleAppearanceProjection.project(granular, base) + } + private companion object { const val SAVED_CUSTOM_SUBTITLE_APPEARANCE = "subtitle_appearance.saved_custom" const val MIGRATION_SENTINEL_LEGACY = "migration_v1" @@ -572,37 +901,50 @@ class AndroidPlayerSettingsStore( // the previous hardcoded AutoPlayGuard threshold of 3). const val DEFAULT_RESUME_REWIND_SECONDS = 7 const val DEFAULT_PASSOUT_THRESHOLD = 3 + // The contract's playback.max_bitrate_kbps bounds; a value outside + // them is rejected as invalid_value and the flush would be dropped. + const val MIN_BITRATE_KBPS = 100 + const val MAX_BITRATE_KBPS = 200_000 val VALID_VIDEO_GRAVITY = setOf("fit", "fill", "stretch") - val BOOLEAN_KEYS: Set = setOf( - PlaybackSettingsKeys.AutoSkipIntro, - PlaybackSettingsKeys.AutoSkipCredits, - PlaybackSettingsKeys.AutoPlayNext, - PlaybackSettingsKeys.HdrEnabled, - PlaybackSettingsKeys.DvProfile7HDR10Fallback, - PlaybackSettingsKeys.DolbyVisionEnabled, - PlaybackSettingsKeys.MatchContentFrameRate, - PlaybackSettingsKeys.SubtitleTextOutline, - ) + // Type classification comes from the generated contract rather than a + // hand-kept list. This used to be a second table that had to agree with + // PlaybackSettingsKeys.DeviceSettings by discipline alone; a key added + // to one and missed in the other would flush as the wrong type and be + // silently dropped on read. + // + // The extras below are the granular subtitle appearance fields Android + // flattens locally. The contract carries them as one composite object + // (playback.subtitle_appearance), so they have no generated entry and + // are listed here as the local-only values they are. + /** + * The device-relevant keys the canonical batched endpoint can + * answer: the store's device set minus the granular subtitle.* + * fields Android flattens locally (the contract carries those as + * the one composite playback.subtitle_appearance object, so the + * resolver has no definitions for them). + */ + val RemoteDeviceSettings: List = SettingKeys.REMOTE.toSet().let { remote -> + PlaybackSettingsKeys.DeviceSettings.filter { it in remote } + } - val INT_KEYS: Set = setOf( - PlaybackSettingsKeys.AudioSyncMs, - PlaybackSettingsKeys.SubtitleSyncMs, - PlaybackSettingsKeys.NextUpPromptSeconds, - PlaybackSettingsKeys.SleepTimerDefaultMinutes, - PlaybackSettingsKeys.SubtitleBackgroundOpacity, - ) + val BOOLEAN_KEYS: Set = SettingKeys.BOOLEAN_KEYS + + setOf(PlaybackSettingsKeys.SubtitleTextOutline) - val DOUBLE_KEYS: Set = setOf( - PlaybackSettingsKeys.PlaybackSpeed, - ) + val INT_KEYS: Set = SettingKeys.INT_KEYS + + setOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity) + + val DOUBLE_KEYS: Set = SettingKeys.DOUBLE_KEYS + + /** Int keys whose contract null means "no cap", stored locally as 0. */ + val NULLABLE_INT_SETTINGS: Set = setOf(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS) fun isBooleanKey(key: String): Boolean = key in BOOLEAN_KEYS fun isIntKey(key: String): Boolean = key in INT_KEYS fun isDoubleKey(key: String): Boolean = key in DOUBLE_KEYS fun fileNameFor(profileId: String): String = - "prairie_player_settings_${profileHash(profileId)}" + "silo_player_settings_${profileHash(profileId)}" fun profileHash(profileId: String): String = sha256Hex(profileId).take(16) @@ -613,33 +955,3 @@ class AndroidPlayerSettingsStore( .joinToString(separator = "") { "%02x".format(it) } } } - -/** - * `contentId=ms` pairs separated by newlines. Deliberately not JSON: the values - * are a string id and an int, and this store already speaks plain preference - * strings, so a serializer dependency here would buy nothing. - * - * Ids containing the separators are dropped rather than escaped — no catalog id - * looks like that, and silently corrupting a neighbouring entry would be worse - * than losing an override the user can set again. - */ -internal fun decodeSubtitleSyncOverrides(raw: String): Map { - if (raw.isBlank()) return emptyMap() - val out = LinkedHashMap() - for (line in raw.lineSequence()) { - val id = line.substringBefore('=', "").trim() - val ms = line.substringAfter('=', "").trim().toIntOrNull() - if (id.isNotEmpty() && ms != null) out[id] = ms - } - return out -} - -internal fun encodeSubtitleSyncOverrides(overrides: Map): String = - overrides.entries - .filter { (id, _) -> id.isNotBlank() && '=' !in id && '\n' !in id } - // Bounded so a long viewing history cannot grow this preference without - // limit; the most recently written entries are the ones worth keeping. - .takeLast(MAX_SUBTITLE_SYNC_OVERRIDES) - .joinToString(separator = "\n") { (id, ms) -> "$id=$ms" } - -private const val MAX_SUBTITLE_SYNC_OVERRIDES = 200 diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt index 0e73fc92a..3fe2941a9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStore.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.overlays.CardOverlayPrefs import org.prairieserver.prairie.overlays.OverlaySchema @@ -14,6 +16,9 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull /** * Cached card-overlay configuration for the signed-in profile. Mirrors @@ -21,7 +26,7 @@ import kotlinx.coroutines.sync.withLock * * Resolves a single rendered [CardOverlayPrefs] from one of two sources, * in this priority: - * 1. The user's saved prefs (`GET /settings/card_overlays`) — if + * 1. The user's canonical profile value (`ui.card_overlays`) — if * present, this is the entire source of truth. * 2. Otherwise, the admin-configured baseline JSON from * `GET /settings/overlay-config` (`defaults` field). @@ -29,8 +34,9 @@ import kotlinx.coroutines.sync.withLock * * Winner-take-all, not layered merging — [setPrefs] always saves a full * document (not a diff), keeping the wire format compatible with web, - * iOS, and tvOS. Hydrated lazily on first read and refreshed after every - * save so card views always see the shape they just persisted. + * iOS, and tvOS. Hydrated lazily on first read; successful canonical writes + * become the confirmed local state so card views immediately see the shape + * they just persisted. */ interface OverlayPrefsStore { /** @@ -90,8 +96,35 @@ class DefaultOverlayPrefsStore( @Volatile private var adminDefaultsRaw: String? = null + // The last state confirmed by a successful canonical read or write. An + // optimistic edit rolls back here when its PUT fails, including when the + // network is unavailable and a follow-up refresh would fail as well. + @Volatile + private var confirmedPrefs: CardOverlayPrefs = _prefs.value + + @Volatile + private var confirmedHasUserOverride: Boolean = false + + // Refreshes run on a foreground IO coroutine while edits are drained on + // the application Main scope. Keep their short state commits atomic so a + // response captured before a newer edit cannot replace that edit after it + // succeeds. This is deliberately separate from [writeGeneration], whose + // only job is invalidating queued/in-flight drains at a session boundary. + private val stateLock = Any() + + // Only [clear] crosses an authenticated-session boundary. Resetting the + // profile value must not discard an otherwise valid admin-config response. + private var sessionStateEpoch: Long = 0L + + private var canonicalStateEpoch: Long = 0L + private val refreshLock = Mutex() + // A reset must finish its DELETE before a later edit is staged for PUT. + // The edit still updates [_prefs] synchronously, but its drain waits here + // so the wire order is always DELETE -> PUT and the later edit wins. + private val mutationBoundaryMutex = Mutex() + // Coalesced-write state. `writeMutex` guards the mutable bookkeeping // below; the actual PUT happens inside the drain coroutine. private val writeMutex = Mutex() @@ -117,13 +150,12 @@ class DefaultOverlayPrefsStore( } /** - * Re-fetch both the admin config and the user setting, then recompute + * Re-fetch both the admin config and the canonical profile value, then recompute * [prefs]. * * Failure semantics mirror iOS: - * - A 404 on the user setting means "not set yet" and is treated as - * success — `userRaw` stays null and we render from admin defaults - * or registry defaults. + * - A canonical null/default means "not set yet" and renders from admin + * defaults or registry defaults. * - Any other transport error on either endpoint leaves * [hasHydrated] false so the next [hydrateIfNeeded] retries. This is * critical for the admin kill-switch: if `/overlay-config` errors @@ -132,60 +164,127 @@ class DefaultOverlayPrefsStore( * toggle is silently ignored for the session. */ override suspend fun refresh() = refreshLock.withLock { - _isLoading.value = true - _lastError.value = null + val refreshState = synchronized(stateLock) { + _isLoading.value = true + _lastError.value = null + RefreshState( + sessionStateEpoch = sessionStateEpoch, + canonicalStateEpoch = canonicalStateEpoch, + adminDefaultsRaw = adminDefaultsRaw, + ) + } try { var resolvedEnabled = true var resolvedAdminDefaults: String? = null var configFetchFailed = false + var configError: String? = null when (val config = repository.overlayConfig()) { is ApiResult.Success -> { resolvedEnabled = config.data.enabled resolvedAdminDefaults = config.data.defaults } is ApiResult.Error -> { - _lastError.value = config.message + configError = config.message configFetchFailed = true } is ApiResult.NetworkError -> { - _lastError.value = config.exception.message + configError = config.exception.message configFetchFailed = true } } - var userRaw: String? = null + var userValue: JsonElement? = null var userFetchFailed = false - when (val entry = repository.getSetting(OVERLAY_SETTING_KEY)) { - is ApiResult.Success -> userRaw = entry.data - is ApiResult.Error -> - if (entry.code == 404) { - userRaw = null - } else { - _lastError.value = entry.message + var userError: String? = null + when (val result = repository.getEffectiveValues(listOf(OVERLAY_SETTING_KEY))) { + is ApiResult.Success -> { + val entry = result.data[OVERLAY_SETTING_KEY] + if (entry == null) { + userError = "The server did not resolve $OVERLAY_SETTING_KEY" userFetchFailed = true + } else if ( + entry.source == SettingScope.PROFILE.wire && + entry.value !is JsonNull + ) { + userValue = entry.value } + } + is ApiResult.Error -> { + userError = result.message + userFetchFailed = true + } is ApiResult.NetworkError -> { - _lastError.value = entry.exception.message + userError = result.exception.message userFetchFailed = true } } - // Preserve cached config state on transient failures. The - // sentinel `resolvedEnabled = true` is only valid when the - // fetch actually succeeded. - if (!configFetchFailed) { - _enabled.value = resolvedEnabled - adminDefaultsRaw = resolvedAdminDefaults + val resolvedPrefs = if (userFetchFailed) { + null + } else { + val defaults = if (configFetchFailed) { + refreshState.adminDefaultsRaw + } else { + resolvedAdminDefaults + } + OverlaySchema.parse(userValue?.toString() ?: defaults) } - if (!userFetchFailed) { - hasUserOverride = userRaw != null - val defaults = if (configFetchFailed) adminDefaultsRaw else resolvedAdminDefaults - _prefs.value = OverlaySchema.parse(userRaw ?: defaults) + val resolvedAdminPrefs = if (configFetchFailed) { + null + } else { + OverlaySchema.parse(resolvedAdminDefaults) } - // Only complete hydration when BOTH endpoints gave a - // definitive answer. - if (!configFetchFailed && !userFetchFailed) { - hasHydrated = true + + synchronized(stateLock) { + // Clear crosses a session boundary, so none of the old refresh + // may land. Profile edits and reset only invalidate the + // user-derived half: the independent admin kill-switch and + // baseline remain valid and must still update. + if (sessionStateEpoch == refreshState.sessionStateEpoch) { + val userResponseIsCurrent = + canonicalStateEpoch == refreshState.canonicalStateEpoch + // Preserve cached config state on transient failures. The + // sentinel `resolvedEnabled = true` is only valid when the + // fetch actually succeeded. + if (!configFetchFailed) { + _enabled.value = resolvedEnabled + adminDefaultsRaw = resolvedAdminDefaults + // When the current confirmed state has no user value, + // the admin baseline is independently authoritative. + // Keep an optimistic edit visible, but update its + // rollback target so a failed PUT cannot restore an + // obsolete baseline. + if ( + (!userResponseIsCurrent || userFetchFailed) && + !confirmedHasUserOverride && + resolvedAdminPrefs != null + ) { + val wasShowingConfirmedState = + _prefs.value == confirmedPrefs && + hasUserOverride == confirmedHasUserOverride + confirmedPrefs = resolvedAdminPrefs + if (wasShowingConfirmedState) { + _prefs.value = resolvedAdminPrefs + } + } + } + + if (userResponseIsCurrent) { + _lastError.value = userError ?: configError + if (!userFetchFailed && resolvedPrefs != null) { + val hasOverride = userValue != null + hasUserOverride = hasOverride + _prefs.value = resolvedPrefs + confirmedHasUserOverride = hasOverride + confirmedPrefs = resolvedPrefs + } + // Only complete hydration when BOTH endpoints gave a + // definitive answer. + if (!configFetchFailed && !userFetchFailed) { + hasHydrated = true + } + } + } } } finally { _isLoading.value = false @@ -200,14 +299,34 @@ class DefaultOverlayPrefsStore( * lands after a faster later one. */ override fun setPrefs(next: CardOverlayPrefs) { - _prefs.value = next - hasUserOverride = true + val generation = synchronized(stateLock) { + canonicalStateEpoch += 1 + _prefs.value = next + hasUserOverride = true + writeGeneration + } scope.launch { - writeMutex.withLock { - pendingSnapshot = next - if (pendingWrite?.isActive != true) { - val generation = writeGeneration - pendingWrite = scope.launch { flushPendingWrites(generation) } + mutationBoundaryMutex.withLock { + writeMutex.withLock { + val belongsToCurrentSession = synchronized(stateLock) { + if (writeGeneration != generation) { + false + } else { + // Re-assert the optimistic state while staging the + // snapshot. A preceding write may have completed + // between the immediate UI update above and this + // coroutine acquiring the mutex. + _prefs.value = next + hasUserOverride = true + true + } + } + if (!belongsToCurrentSession) return@withLock + + pendingSnapshot = next + if (pendingWrite?.isActive != true) { + pendingWrite = scope.launch { flushPendingWrites(generation) } + } } } } @@ -233,21 +352,64 @@ class DefaultOverlayPrefsStore( // for the cleared session reaches the wire. if (currentCoroutineContext()[Job]?.isActive != true) return if (writeGeneration != generation) return - val json = OverlaySchema.serialize(snapshot) + val json = Json.parseToJsonElement(OverlaySchema.serialize(snapshot)) if (currentCoroutineContext()[Job]?.isActive != true) return if (writeGeneration != generation) return - when (val result = repository.setSetting(OVERLAY_SETTING_KEY, json)) { - is ApiResult.Success -> Unit + when (val result = repository.setProfileValue(OVERLAY_SETTING_KEY, json)) { + is ApiResult.Success -> { + val applied = writeMutex.withLock { + synchronized(stateLock) { + if (writeGeneration != generation) { + false + } else { + // Every confirmed snapshot invalidates a GET + // that was captured before this PUT completed, + // even when a newer snapshot is already queued. + canonicalStateEpoch += 1 + confirmedPrefs = snapshot + confirmedHasUserOverride = true + // Do not paint an older successful snapshot + // over a newer edit that is already queued. + if (pendingSnapshot == null) { + _prefs.value = snapshot + hasUserOverride = true + _lastError.value = null + } + true + } + } + } + if (!applied) return + } is ApiResult.Error -> { if (writeGeneration != generation) return - _lastError.value = result.message - refresh() + reconcileFailedWrite(result.message, generation) } is ApiResult.NetworkError -> { if (writeGeneration != generation) return - _lastError.value = result.exception.message - refresh() + reconcileFailedWrite(result.exception.message, generation) + } + } + } + } + + private suspend fun reconcileFailedWrite(message: String?, generation: Int) { + writeMutex.withLock { + synchronized(stateLock) { + if (writeGeneration != generation) return@synchronized + // A refresh may have started after the optimistic edit but + // before this terminal failure. Make that response stale so + // it cannot erase the rejection or re-confirm old state. + canonicalStateEpoch += 1 + // If another edit is queued, it is still the optimistic state + // the user should see. Otherwise restore the last + // server-confirmed document instead of leaving a rejected + // value on screen. + if (pendingSnapshot == null) { + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride } + _lastError.value = message } } } @@ -258,28 +420,78 @@ class DefaultOverlayPrefsStore( * a slower earlier PUT can't land server-side after the DELETE and * recreate the document the user just asked us to drop. */ - override suspend fun resetToDefaults() { + override suspend fun resetToDefaults() = mutationBoundaryMutex.withLock { // Bump first so any drain that's mid-flight (already past its snapshot // grab) sees the generation change and bails before its PUT lands. - writeGeneration += 1 - writeMutex.withLock { + val resetState = synchronized(stateLock) { + writeGeneration += 1 + canonicalStateEpoch += 1 + ResetState( + writeGeneration = writeGeneration, + canonicalStateEpoch = canonicalStateEpoch, + ) + } + val inflight = writeMutex.withLock { pendingSnapshot = null - pendingWrite?.cancel() + pendingWrite.also { it?.cancel() } } - pendingWrite?.join() - writeMutex.withLock { pendingWrite = null } - - when (val result = repository.deleteSetting(OVERLAY_SETTING_KEY)) { - is ApiResult.Success -> hasUserOverride = false - is ApiResult.Error -> - if (result.code == 404) { - hasUserOverride = false - } else { - _lastError.value = result.message + inflight?.join() + writeMutex.withLock { + if (pendingWrite === inflight) pendingWrite = null + } + + when (val result = repository.clearProfileValue(OVERLAY_SETTING_KEY)) { + is ApiResult.Success -> { + val shouldRefresh = synchronized(stateLock) { + if (writeGeneration != resetState.writeGeneration) { + false + } else { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + // The DELETE is canonical even when a newer optimistic + // edit is waiting. If that later PUT fails, it must + // roll back to the now-cleared server state, not the + // override that the DELETE removed. + val fallback = OverlaySchema.parse(adminDefaultsRaw) + confirmedHasUserOverride = false + confirmedPrefs = fallback + if (isStillLatestMutation) { + hasUserOverride = false + _prefs.value = fallback + } + isStillLatestMutation + } + } + if (shouldRefresh) refresh() + } + is ApiResult.Error -> { + synchronized(stateLock) { + if (writeGeneration == resetState.writeGeneration) { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + if (!isStillLatestMutation) return@synchronized + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride + _lastError.value = result.message + } + } + } + is ApiResult.NetworkError -> { + synchronized(stateLock) { + if (writeGeneration == resetState.writeGeneration) { + val isStillLatestMutation = + canonicalStateEpoch == resetState.canonicalStateEpoch + canonicalStateEpoch += 1 + if (!isStillLatestMutation) return@synchronized + _prefs.value = confirmedPrefs + hasUserOverride = confirmedHasUserOverride + _lastError.value = result.exception.message + } } - is ApiResult.NetworkError -> _lastError.value = result.exception.message + } } - refresh() } override fun clear() { @@ -292,20 +504,42 @@ class DefaultOverlayPrefsStore( // and exits. Cancelling the Job + nulling `pendingWrite` still happens // under the mutex on the write coroutine, but correctness no longer // depends on that running before the session boundary. - writeGeneration += 1 - pendingSnapshot = null - val inflight = pendingWrite - pendingWrite = null + val inflight = synchronized(stateLock) { + writeGeneration += 1 + sessionStateEpoch += 1 + canonicalStateEpoch += 1 + pendingSnapshot = null + val activeWrite = pendingWrite + pendingWrite = null + _enabled.value = true + _prefs.value = OverlaySchema.buildDefaults() + confirmedPrefs = _prefs.value + adminDefaultsRaw = null + hasUserOverride = false + confirmedHasUserOverride = false + hasHydrated = false + // Let the next authenticated session queue its refresh behind any + // old in-flight request. Otherwise hydrateIfNeeded() observes the + // previous session's loading flag, returns, and never retries. + _isLoading.value = false + _lastError.value = null + activeWrite + } inflight?.cancel() - _enabled.value = true - _prefs.value = OverlaySchema.buildDefaults() - adminDefaultsRaw = null - hasUserOverride = false - hasHydrated = false - _lastError.value = null } + private data class RefreshState( + val sessionStateEpoch: Long, + val canonicalStateEpoch: Long, + val adminDefaultsRaw: String?, + ) + + private data class ResetState( + val writeGeneration: Int, + val canonicalStateEpoch: Long, + ) + companion object { - const val OVERLAY_SETTING_KEY = "card_overlays" + const val OVERLAY_SETTING_KEY = SettingKeys.UI_CARD_OVERLAYS } } diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt index 982d00814..4da926567 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt @@ -1,13 +1,59 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.settings.SubtitleAppearance import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import org.prairieserver.prairie.player.DolbyVisionPolicy +/** + * How far the phone player may expand a picture whose letterbox is encoded into + * the video (see the player's `LetterboxMatte`). Expansion never crops picture — + * it only eats black the file itself carries — so the question a user actually + * has is what to do about the camera cutout once the image reaches the edges. + */ +object LetterboxExpansion { + /** Expand, but keep the image clear of the display cutout. */ + const val ClearOfCamera = "clear_of_camera" + + /** Expand to the full display width; the camera overlaps the picture. */ + const val FullWidth = "full_width" + + /** Never expand — the coded frame is fitted whole, bars and all. */ + const val Off = "off" + + /** + * Biggest picture that is neither cropped nor covered. Expansion itself is + * free (the clip lands in encoded black), so it is on; stopping at the + * cutout costs width but is what keeps the whole image visible. + */ + const val Default = ClearOfCamera + + val Valid = setOf(ClearOfCamera, FullWidth, Off) +} + interface PlayerSettingsStore { + /** + * What the player does when an intro starts — `playback.intro_skip_mode`, + * contract revision 7. See the server's + * `docs/design/2026-08-16-intro-skip-mode.md`. + */ + val introSkipModeFlow: Flow + // Booleans + /** + * The deprecated boolean, projected from [introSkipModeFlow] rather than + * read separately so the two cannot disagree — `always` is the only mode + * the boolean's `true` ever meant, and `never` degrades to the same `false` + * an old client would have shown as "ask". + * + * Nothing in the app should read this: it exists for the compatibility + * window while the server still mirrors the two keys. + */ val autoSkipIntroFlow: Flow + get() = introSkipModeFlow.map { it == IntroSkipMode.ALWAYS } val autoSkipCreditsFlow: Flow val autoPlayNextFlow: Flow val hdrEnabledFlow: Flow @@ -15,6 +61,17 @@ interface PlayerSettingsStore { val dolbyVisionEnabledFlow: Flow val matchContentFrameRateFlow: Flow val pictureInPictureEnabledFlow: Flow + + /** + * How far to expand video whose black bars are encoded into the picture + * (a 2.39:1 film inside a 16:9 frame) — see [LetterboxExpansion]. + * + * Defaulted here rather than declared abstract so the existing fakes in the + * player tests keep compiling; the real store overrides it. + */ + val letterboxExpansionFlow: Flow + get() = flowOf(LetterboxExpansion.Default) + /** Per-profile preference for restricting downloads to unmetered (Wi-Fi) * networks. Default true. Consumed by [DownloadEnqueuer] at enqueue * time to set the WorkManager NetworkType constraint. */ @@ -27,15 +84,8 @@ interface PlayerSettingsStore { // Ints val audioSyncMsFlow: Flow + /** Canonical device-scoped subtitle offset (`player.subtitle_sync_ms`). */ val subtitleSyncMsFlow: Flow - - /** - * Subtitle sync for one catalog item: its own override when it has one, - * otherwise the profile-wide value. A badly timed release is a property of - * that release, so correcting it must not silently shift every other title - * — which is what a single global value did. - */ - fun subtitleSyncMsFor(contentId: String?): Flow val nextUpPromptSecondsFlow: Flow val sleepTimerDefaultMinutesFlow: Flow /** Seconds to skip back on resume (F1). Default 7; 0 = off. Local-only. */ @@ -43,6 +93,13 @@ interface PlayerSettingsStore { /** Consecutive auto-advances before the "Still watching?" prompt (F2). Default 3; 0 = off. Local-only. */ val passOutThresholdFlow: Flow + /** + * The bandwidth half of the quality choice, orthogonal to + * [preferredQualityFlow]. null is uncapped, which is the absence of a + * stored value rather than a sentinel. + */ + val maxBitrateKbpsFlow: Flow + // Strings val preferredQualityFlow: Flow val audioLanguageFlow: Flow @@ -70,7 +127,15 @@ interface PlayerSettingsStore { val effectiveSubtitleAppearanceFlow: Flow // Setters - suspend fun setAutoSkipIntro(value: Boolean) + suspend fun setIntroSkipMode(value: IntroSkipMode) + + /** + * Deprecated shim for the boolean. Routes to [setIntroSkipMode] so a caller + * that has not moved yet still writes the canonical key; the server mirrors + * the boolean back at the same identity. + */ + suspend fun setAutoSkipIntro(value: Boolean) = + setIntroSkipMode(IntroSkipMode.fromLegacyBoolean(value)) suspend fun setAutoSkipCredits(value: Boolean) suspend fun setAutoPlayNext(value: Boolean) suspend fun setHdrEnabled(value: Boolean) @@ -78,6 +143,7 @@ interface PlayerSettingsStore { suspend fun setDolbyVisionEnabled(value: Boolean) suspend fun setMatchContentFrameRate(value: Boolean) suspend fun setPictureInPictureEnabled(value: Boolean) + suspend fun setLetterboxExpansion(value: String) = Unit suspend fun setDownloadsWifiOnly(value: Boolean) suspend fun setKeepWatchedDownloads(value: Boolean) suspend fun setDefaultDownloadQuality(value: String) @@ -86,13 +152,6 @@ interface PlayerSettingsStore { suspend fun setAudioSyncMs(value: Int) suspend fun setSubtitleSyncMs(value: Int) - - /** - * Record sync for one item. Passing the profile-wide value clears the - * override instead of storing a redundant copy, so an item only carries an - * entry while it genuinely differs. - */ - suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) suspend fun setNextUpPromptSeconds(value: Int) suspend fun setSleepTimerDefaultMinutes(value: Int) /** Set resume skip-back seconds (clamped 0..30; 0 = off). */ @@ -101,12 +160,33 @@ interface PlayerSettingsStore { suspend fun setPassOutThreshold(value: Int) suspend fun setPreferredQuality(value: String) + + /** + * Set both quality axes at once — the two values one picker preset + * decomposes into. Writing them together is what keeps the pair + * consistent: a resolution stored without its bitrate is a combination no + * preset covers, which the picker then has to render as "custom". + * [bitrateKbps] null is uncapped. + */ + suspend fun setQuality(resolution: String, bitrateKbps: Int?) suspend fun setAudioLanguage(value: String) suspend fun setVideoGravity(value: String) suspend fun setOrientationMode(value: String) suspend fun setSubtitleAppearance(value: SubtitleAppearance) + /** + * Project the granular, client-local `subtitle.*` fields into the + * composite `playback.subtitle_appearance` and enqueue it. + * + * The contract carries subtitle appearance as one object and has no + * definitions for the individual fields, so a per-field edit is + * device-local until it is folded into the composite. Call this after + * editing fields individually (the player HUD, a per-field picker); a + * no-op when the projection already matches what is stored. + */ + suspend fun flushProjectedSubtitleAppearance() + /** * Pull every device-scoped setting from `/api/v1/settings/effective` * and write the resolved values into the local DataStore without @@ -136,9 +216,11 @@ interface PlayerSettingsStore { suspend fun resetDeviceSetting(key: String) /** - * Clear every server-side device override. Mirrors iOS - * `PlayerSettings.resetAllDeviceSettings()` — the user's "Reset - * Playback Overrides" action. + * Return this device's playback settings to their defaults — the user's + * "Reset playback settings" action. Clears every server-side device + * override (as iOS `PlayerSettings.resetAllDeviceSettings()` does) and the + * local-only playback keys that have no server row to clear, since those + * would otherwise survive an action whose whole promise is the defaults. */ suspend fun resetAllDeviceSettings() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt index c42591a38..d15b6b19a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt @@ -1,56 +1,164 @@ package org.prairieserver.prairie.common.settings -import android.util.Log +import org.prairieserver.prairie.common.diagnostics.PrairieLog +import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScopeIdentity import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.newSettingMutationId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive interface ServerSettingsFlusher { - fun enqueue(profileId: String, key: String, value: String) - fun enqueueDelete(profileId: String, key: String) + /** + * Queue one device-scoped write. Values arrive as the store's plain + * strings and are encoded to the contract's JSON type on the wire. + * + * Each logical write gets one idempotency id ([newSettingMutationId]) + * that is held for every retry of that write — the server replays the + * recorded receipt for a repeated id, so a retry after a dropped + * response cannot double-apply. Enqueueing a *different* value for the + * same key replaces the pending op and mints a fresh id, because + * reusing an id for different content is a 409 conflict by design. + * + * [serverUrl] is the server the value was authored against. This flusher + * is application-scoped and its requests are relative — they go to + * whichever server is active when they are sent — so an op that outlives + * a server switch has to be identified by its origin, not just by + * (profileId, key). See [ServerSettingsFlusher] implementations for what + * happens to an op whose origin is no longer active. + */ + fun enqueue(profileId: String, key: String, value: String, serverUrl: String) + + /** Queue clearing the device-scoped value, so the setting inherits again. */ + fun enqueueDelete(profileId: String, key: String, serverUrl: String) /** * Cancel any in-flight debounce, drain every pending op, and suspend * until each one has been ack'd (or errored). Mirrors the iOS * `PlayerSettings.flushPendingDeviceSettings()` semantics — * re-entrant calls coalesce, and the second caller waits for the - * first to finish. + * first to finish. Ops that fail transiently stay queued for retry. */ suspend fun flushNow() + + /** + * The setting keys [profileId] still has unsent (or failed-and-requeued) + * writes for, read after a [flushNow] has drained what it could. + * + * A caller that pulls server state right after pushing needs this: a + * transiently failed PUT stays queued and `flushNow` returns normally, so + * the server answers the following read from the value the write has not + * landed on yet. Applying that answer verbatim would put the old value back + * over the edit the user just made. + * + * Defaults to empty so an implementation that never queues anything need + * not answer. + */ + fun pendingKeys(profileId: String): Set = emptySet() } private sealed class PendingOp { - data class Set(val value: String) : PendingOp() - object Delete : PendingOp() + /** The server this op was authored against; it may not still be active. */ + abstract val serverUrl: String + + data class Set( + val value: String, + val mutationId: String, + override val serverUrl: String, + ) : PendingOp() + + data class Delete(override val serverUrl: String) : PendingOp() } +/** + * Debounced writer for the canonical settings API + * (`PUT/DELETE /api/v1/settings/values/{key}?scope=profile_device`). + * + * Failure handling is the point, not an afterthought: a write that fails + * for a transient reason (network, 5xx, 429/408/401) stays queued and is + * retried with the SAME mutation id, first on a capped backoff schedule and + * after that on the next enqueue/flushNow trigger — it is never silently + * dropped, which is how a server hiccup used to turn settings + * non-persistent. Only a response that proves retrying is pointless (the + * contract rejected the value or key, or the mutation id was reused for + * different content) drops the op, and every failure is logged at warning + * level with the key and status. + * + * That retention is bounded by the server the op was authored against. + * [SettingsApi] requests are relative and this flusher is application-scoped, + * so they address whichever server is active when they are sent — while a + * server switch is one `onSelect` away and clears nothing. A retained op + * whose origin is no longer active is therefore dropped rather than sent: + * replaying it would write one server's device setting to another (a + * restored or cloned server can hold the same profile id), and leaving it + * queued would let a later enqueue revive it against a third. Persistence is + * worth a lot, but not worth writing a value to a server the user never + * authored it against. + */ class DefaultServerSettingsFlusher( private val settingsApi: SettingsApi, private val scope: CoroutineScope, private val debounceMs: Long = 750, + /** + * The server requests currently address. Null (no active server, e.g. + * mid-logout) parks the queue rather than dropping it: there is nothing + * to compare against yet, and a switch has not been observed. + */ + private val getServerUrl: suspend () -> String? = { null }, ) : ServerSettingsFlusher { private val lock = Any() private val pending = mutableMapOf, PendingOp>() private var flushJob: Job? = null + private var retryJob: Job? = null + private var retryAttempts: Int = 0 private val flushMutex = Mutex() - override fun enqueue(profileId: String, key: String, value: String) { - scheduleDebounced(profileId, key, PendingOp.Set(value)) + override fun enqueue(profileId: String, key: String, value: String, serverUrl: String) { + scheduleDebounced(profileId, key) { existing -> + // Re-enqueueing the identical value keeps the pending op (and + // its mutation id): it is the same logical write, and the + // server treats a replayed id + content as already done. A match + // has to agree on the origin too — the same key and value bound + // for a different server is a different write. + if (existing is PendingOp.Set && + existing.value == value && + existing.serverUrl == serverUrl + ) { + existing + } else { + PendingOp.Set(value, newSettingMutationId(), serverUrl) + } + } } - override fun enqueueDelete(profileId: String, key: String) { - scheduleDebounced(profileId, key, PendingOp.Delete) + override fun enqueueDelete(profileId: String, key: String, serverUrl: String) { + scheduleDebounced(profileId, key) { PendingOp.Delete(serverUrl) } } - private fun scheduleDebounced(profileId: String, key: String, op: PendingOp) { + private fun scheduleDebounced( + profileId: String, + key: String, + op: (PendingOp?) -> PendingOp, + ) { synchronized(lock) { - pending[profileId to key] = op + val composite = profileId to key + pending[composite] = op(pending[composite]) + // Fresh user activity re-arms the retry budget. + retryAttempts = 0 + retryJob?.cancel() + retryJob = null flushJob?.cancel() flushJob = scope.launch { delay(debounceMs) @@ -59,50 +167,257 @@ class DefaultServerSettingsFlusher( } } + override fun pendingKeys(profileId: String): Set = synchronized(lock) { + pending.keys.filter { it.first == profileId }.map { it.second }.toSet() + } + override suspend fun flushNow() { synchronized(lock) { flushJob?.cancel() flushJob = null + retryJob?.cancel() + retryJob = null + retryAttempts = 0 } drainAndFlush() } private suspend fun drainAndFlush() { flushMutex.withLock { + // Ops that failed transiently in this drain. Kept out of + // `pending` until the loop below finishes, or the loop would + // retry them immediately and spin. + // + // Only the LATEST outcome per key may live here. A later pass of + // the drain loop that settles a newer op for the same key — + // whether it landed or the contract refused it — must evict the + // older failed entry, or the re-queue below would resurrect a + // superseded value and `scheduleRetry` would replay it over the + // newer one. The post-loop `composite !in pending` guard cannot + // catch that case: the very pass that sent the newer op already + // cleared `pending`. + val retryable = LinkedHashMap, PendingOp>() while (true) { val snapshot: Map, PendingOp> = synchronized(lock) { - if (pending.isEmpty()) return@withLock val copy = pending.toMap() pending.clear() copy } + if (snapshot.isEmpty()) break snapshot.forEach { (composite, op) -> val (profileId, key) = composite - flushOne(profileId, key, op) + if (flushOne(profileId, key, op)) { + retryable[composite] = op + } else { + retryable.remove(composite) + } + } + } + if (retryable.isEmpty()) { + synchronized(lock) { retryAttempts = 0 } + return + } + val attempt = synchronized(lock) { + for ((composite, op) in retryable) { + // A newer op enqueued during the flush wins over the + // failed one — it is newer content with its own id. + if (composite !in pending) pending[composite] = op + } + if (retryAttempts >= MAX_AUTO_RETRIES) { + // Out of automatic retries: the ops stay queued and the + // next enqueue or flushNow (app foreground, player exit) + // tries again with the same mutation ids. + null + } else { + ++retryAttempts } } + if (attempt != null) scheduleRetry(attempt) } } - private suspend fun flushOne(profileId: String, key: String, op: PendingOp) { - try { - val result = when (op) { - is PendingOp.Set -> - settingsApi.setDeviceSetting(key, op.value, profileId = profileId) - is PendingOp.Delete -> - settingsApi.deleteDeviceSetting(key) + private fun scheduleRetry(attempt: Int) { + synchronized(lock) { + retryJob?.cancel() + retryJob = scope.launch { + delay(retryDelayMs(attempt)) + drainAndFlush() } - if (result is ApiResult.Error) { - Log.w(TAG, "$op profile=$profileId key=$key code=${result.code}: ${result.message}") - } else if (result is ApiResult.NetworkError) { - Log.w(TAG, "$op profile=$profileId key=$key network error: ${result.exception}") + } + } + + /** + * Sends one op. Returns true when it must stay queued for retry — + * with its mutation id unchanged, so the retry is an idempotent replay + * rather than a second write. + */ + private suspend fun flushOne(profileId: String, key: String, op: PendingOp): Boolean { + val active = runCatching { getServerUrl() }.getOrNull() + if (active != null && active != op.serverUrl) { + // The user switched servers while this op was queued. Requests are + // relative, so sending it now would address the NEW server with a + // value authored for the old one — and a restored or cloned server + // can recognize the same profile id, so it would land rather than + // fail. Dropping it also keeps a stale op from being revived by a + // later enqueue once the original server is active again. + PrairieLog.w( + CATEGORY, TAG, + "dropping $key: queued for ${op.serverUrl}, active server is now $active", + ) + return false + } + if (key !in REMOTE_KEYS) { + // Local-only keys (granular subtitle.* fields, pre-contract + // strays) have no server row; the canonical API would refuse + // them as unknown_setting, so they never leave the device. + PrairieLog.w(CATEGORY, TAG, "dropping $key: not a server-stored key in the generated contract") + return false + } + return try { + when (op) { + is PendingOp.Set -> flushSet(profileId, key, op) + is PendingOp.Delete -> flushDelete(profileId, key) } } catch (t: Throwable) { - Log.w(TAG, "$op profile=$profileId key=$key threw: $t") + // Includes cancellation of a superseded flush: the op goes back + // into the queue and the next trigger replays it, so a torn-down + // flush never loses a write. + failed("flush", key, "threw: $t", retry = true) + } + } + + private suspend fun flushSet(profileId: String, key: String, op: PendingOp.Set): Boolean { + val encoded = encodeSettingWireValue(key, op.value) + if (encoded == null) { + PrairieLog.w(CATEGORY, TAG, "dropping $key: ${op.value} does not encode as the contract type") + return false + } + return when ( + val result = settingsApi.putValue( + key = key, + scope = SettingScopeIdentity.profileDevice(), + value = encoded, + mutationId = op.mutationId, + profileId = profileId, + ) + ) { + is ApiResult.Success -> false + is ApiResult.Error -> failed( + "put", key, "${result.code} ${result.error}: ${result.message}", + retry = isTransientHttp(result.code), + ) + is ApiResult.NetworkError -> + failed("put", key, "network error: ${result.exception}", retry = true) + } + } + + private suspend fun flushDelete(profileId: String, key: String): Boolean { + return when ( + val result = settingsApi.deleteValue( + key = key, + scope = SettingScopeIdentity.profileDevice(), + profileId = profileId, + ) + ) { + is ApiResult.Success -> false + is ApiResult.Error -> + if (result.code == 404) { + // Nothing stored at this scope — the reset is already + // true, e.g. an earlier attempt landed before its + // response did. + false + } else { + failed( + "delete", key, "${result.code} ${result.error}: ${result.message}", + retry = isTransientHttp(result.code), + ) + } + is ApiResult.NetworkError -> + failed("delete", key, "network error: ${result.exception}", retry = true) } } + private fun failed(verb: String, key: String, detail: String, retry: Boolean): Boolean { + PrairieLog.w( + CATEGORY, TAG, + "$verb $key failed ($detail); ${if (retry) "kept queued for retry" else "dropped"}", + ) + return retry + } + private companion object { const val TAG = "ServerSettingsFlusher" + val CATEGORY = DiagnosticsLogCategory.NETWORK + + val REMOTE_KEYS: Set = SettingKeys.REMOTE.toSet() + + /** + * Retrying can help: the request never arrived, the server fell + * over, throttled us, timed out, or the session token was mid + * refresh. Everything else is the contract refusing the write — + * invalid value, unknown key, scope not allowed, or a mutation id + * reused for different content (409) — where a retry would fail + * identically forever. + */ + fun isTransientHttp(code: Int): Boolean = + code >= 500 || code == 408 || code == 429 || code == 401 + + const val MAX_AUTO_RETRIES = 5 + const val RETRY_BASE_DELAY_MS = 1_000L + const val RETRY_MAX_DELAY_MS = 60_000L + + fun retryDelayMs(attempt: Int): Long = + (RETRY_BASE_DELAY_MS shl (attempt - 1).coerceIn(0, 6)) + .coerceAtMost(RETRY_MAX_DELAY_MS) } } + +/** + * The nullable language-tag keys, where the store spells "no preference" as + * the empty string but the contract spells it as JSON null (the server's + * language_tag validator rejects `""`). + * + * The generated bindings classify boolean/int/double keys only, so these two + * groups are named here until the generator grows the remaining type sets. + */ +private val NULLABLE_LANGUAGE_KEYS: Set = setOf( + SettingKeys.CATALOG_METADATA_LANGUAGE, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, +) + +/** + * Nullable integer keys, where the contract's null means "no cap" and the + * local store has to spell that as some in-band value. Zero is chosen because + * the contract's range (100..200000) cannot hold it, so it can never collide + * with a real cap — but it is not a value the server would accept, so it is + * translated to JSON null rather than sent. + */ +private val NULLABLE_INT_KEYS: Set = setOf( + SettingKeys.PLAYBACK_MAX_BITRATE_KBPS, +) + +/** Keys whose store-side string is itself a JSON object document. */ +private val SETTING_OBJECT_KEYS: Set = setOf( + SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, +) + +/** + * Encodes the store's string spelling of a value as the JSON type the + * contract declares for [key], classified by the generated + * [SettingKeys.BOOLEAN_KEYS]/[SettingKeys.INT_KEYS]/[SettingKeys.DOUBLE_KEYS] + * sets. Returns null when the string cannot be that type — a client bug the + * server would reject with `invalid_value`, so the caller drops it loudly + * instead of retrying it forever. + */ +internal fun encodeSettingWireValue(key: String, raw: String): JsonElement? = when { + key in SettingKeys.BOOLEAN_KEYS -> raw.toBooleanStrictOrNull()?.let(::JsonPrimitive) + key in NULLABLE_INT_KEYS -> + raw.toLongOrNull()?.let { if (it <= 0L) JsonNull else JsonPrimitive(it) } + key in SettingKeys.INT_KEYS -> raw.toLongOrNull()?.let(::JsonPrimitive) + key in SettingKeys.DOUBLE_KEYS -> raw.toDoubleOrNull()?.let(::JsonPrimitive) + key in SETTING_OBJECT_KEYS -> + runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonObject + key in NULLABLE_LANGUAGE_KEYS && raw.isEmpty() -> JsonNull + else -> JsonPrimitive(raw) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt index 02159e9e7..8f0f0014d 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt @@ -3,16 +3,19 @@ package org.prairieserver.prairie.common.startup import android.content.Context import coil3.SingletonImageLoader import coil3.request.ImageRequest -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.components.resolveProfileAvatar import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort -import org.prairieserver.prairie.util.ArtworkUrl +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease +import org.prairieserver.prairie.viewmodel.hydrateHomeSections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -83,6 +86,7 @@ suspend fun warmAuthenticatedStartup( personalDataRepository: PersonalDataRepository, sectionRepository: SectionRepository, homeCache: HomeCachePort, + identityTransitions: IdentityTransitionBarrier, serverUrl: String?, artworkPlan: StartupArtworkPlan, ) { @@ -106,7 +110,15 @@ suspend fun warmAuthenticatedStartup( Unit }, async { - runCatching { warmHome(context, sectionRepository, homeCache, artworkPlan) } + runCatching { + warmHome( + context, + sectionRepository, + homeCache, + identityTransitions, + artworkPlan, + ) + } Unit }, ).awaitAll() @@ -135,27 +147,23 @@ private suspend fun CoroutineScope.warmHome( context: Context, sectionRepository: SectionRepository, homeCache: HomeCachePort, + identityTransitions: IdentityTransitionBarrier, artworkPlan: StartupArtworkPlan, ) { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = HomeCacheWriteLease(requestIdentityGeneration) when (val result = sectionRepository.getHomeSections()) { is ApiResult.Success -> { - val resolvedPairs: List> = - result.data.sections.map { section -> - async { - when (val itemsResult = sectionRepository.getHomeSectionItems(section.id)) { - is ApiResult.Success -> (itemsResult.data.section ?: section) to true - is ApiResult.Error, - is ApiResult.NetworkError -> section to false - } - } - }.awaitAll() - - if (resolvedPairs.all { it.second }) { - val resolved = resolvedPairs.map { it.first }.filter { it.items.isNotEmpty() } - if (resolved.isNotEmpty()) { - homeCache.cacheHome(resolved) - warmHomeArtwork(context, resolved, artworkPlan) - } + val hydration = hydrateHomeSections(result.data.sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) + } + if ( + hydration.fullyResolved && + hydration.sections.isNotEmpty() && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHome(hydration.sections, cacheWriteLease) + warmHomeArtwork(context, hydration.sections, artworkPlan) } } is ApiResult.Error, @@ -180,12 +188,8 @@ private suspend fun warmHomeArtwork( fun append(urlString: String?, widthPx: Int, heightPx: Int) { if (requests.size >= plan.maxUrls) return - val raw = urlString?.trim().orEmpty() - if (raw.isEmpty()) return - // Warm the preferred format for this device so ThumbhashImage's first - // request hits the Coil disk/memory cache. - val url = ArtworkUrl.preferred(raw) - if (!seen.add(url)) return + val url = urlString?.trim().orEmpty() + if (url.isEmpty() || !seen.add(url)) return requests.add( ImageRequest.Builder(context) .data(url) @@ -236,14 +240,22 @@ private suspend fun warmAvatarArtwork( serverUrl: String?, ) { val requests = profiles - .mapNotNull { profile -> - profile.avatar?.let { resolveAvatarUrl(serverUrl.orEmpty(), it) } - } + .mapNotNull { profile -> resolveProfileAvatar(serverUrl.orEmpty(), profile.avatarRef()) } .distinct() - .map { url -> + .map { resolved -> ImageRequest.Builder(context) - .data(url) + .data(resolved.url) .size(profileAvatarWarmSizePx, profileAvatarWarmSizePx) + // Warm the SAME cache entry the grid will later read. Without + // the shared key an uploaded avatar would be filed under the + // presigned URL warmup happened to get, and the screen's own + // (re-signed) URL would miss it and download all over again. + .apply { + resolved.cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .build() } warmImages(context, requests) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt index a342eabb8..d95aef83a 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/store/ScopedJsonFileStore.kt @@ -145,7 +145,7 @@ internal class ScopedJsonFileStore( private fun createExclusiveTemp(parent: File): ExclusiveTemp? { repeat(MAX_TEMP_ATTEMPTS) { - val candidate = File(parent, ".silo-${UUID.randomUUID()}.tmp") + val candidate = File(parent, ".prairie-${UUID.randomUUID()}.tmp") var directoryStream: java.nio.file.DirectoryStream? = null try { directoryStream = Files.newDirectoryStream(parent.toPath()) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt new file mode 100644 index 000000000..2dc5a08a3 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.ItemDetail + +fun movieDirectorCredit(detail: ItemDetail): String? { + if (!detail.type.equals("movie", ignoreCase = true)) return null + val names = detail.crew + .asSequence() + .filter { it.job?.trim().equals("Director", ignoreCase = true) } + .map { it.name.trim() } + .filter { it.isNotEmpty() } + .distinct() + .take(3) + .toList() + return names.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Directed by ", separator = ", ") +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupport.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupport.kt index c426ef11a..3974c44bd 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupport.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupport.kt @@ -2,15 +2,34 @@ package org.prairieserver.prairie.common.ui.components import android.net.Uri import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.delay +import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.network.ServerRegistry import org.koin.core.context.GlobalContext private const val diceBearPresetPrefix = "preset:dicebear:" private const val diceBearBaseUrl = "https://api.dicebear.com/9.x" +/** + * Scheme the server uses for an avatar the user uploaded, e.g. + * `upload:profile-avatars/1//original.webp`. + * + * This is an object-store *reference*, not a URL: the bytes live in a private + * bucket, so the only fetchable form is the presigned `avatar_url` the server + * returns alongside it. The client cannot sign R2 requests itself and must + * never try to build a URL out of this ref. + */ +private const val uploadAvatarPrefix = "upload:" + private val imageAvatarPrefixes = listOf( "http://", "https://", @@ -30,23 +49,119 @@ private val imageAvatarExtensions = listOf( ".avif", ) +/** + * The stored avatar reference plus the server's fetchable URL for it, kept in + * one value so the two cannot be threaded apart. + * + * They are useless separately: the ref alone cannot be fetched for uploads, and + * the URL alone cannot produce the initials/emoji fallback or a stable cache + * key. Every avatar-rendering composable takes this instead of a bare + * `avatar: String?` precisely so a screen cannot be half-migrated into showing + * initials where another screen shows the picture. + */ +@Immutable +data class ProfileAvatarRef( + /** Stored ref: `upload:…`, `preset:dicebear:…`, a path, an emoji, or null. */ + val avatar: String? = null, + /** Server-supplied fetchable URL (presigned and short-lived for uploads). */ + val avatarUrl: String? = null, +) { + companion object { + /** No avatar at all — renders as initials. */ + val None = ProfileAvatarRef() + } +} + +/** This profile's avatar ref and server-resolved URL as one value. */ +fun Profile.avatarRef(): ProfileAvatarRef = ProfileAvatarRef(avatar, avatarUrl) + +/** + * A fetchable avatar image: where to get it, and what to file it under. + * + * [cacheKey] is deliberately NOT the URL. A presigned upload URL carries + * `X-Amz-Date` / `X-Amz-Signature` query parameters that are regenerated on + * every `GET /profiles`, so keying Coil's memory and disk caches by the URL + * would miss on every single refresh and re-download the same bytes forever. + * Keying by the signature-free part makes the cache actually work — and, as a + * useful side effect, an already-loaded avatar keeps rendering from cache even + * after its signed URL has expired. + */ +@Immutable +data class ResolvedProfileAvatar( + val url: String, + /** Stable cache key, or null to let Coil key by [url] (fine for stable URLs). */ + val cacheKey: String? = null, +) + fun isImageAvatar(avatar: String?): Boolean { val value = avatar?.trim().orEmpty() if (value.isEmpty()) return false val lowercased = value.lowercase() return isDiceBearPresetAvatar(value) + || isUploadAvatarRef(value) || imageAvatarPrefixes.any(lowercased::startsWith) || "/" in lowercased || imageAvatarExtensions.any(lowercased::contains) } +/** True for the server's `upload:` object-store reference scheme. */ +fun isUploadAvatarRef(avatar: String?): Boolean = + avatar?.trim()?.lowercase()?.startsWith(uploadAvatarPrefix) == true + +/** + * Resolves what to actually draw for [avatar], or null when nothing is + * fetchable (caller falls back to emoji/initials). + * + * Order matters: + * 1. A server-supplied `avatar_url` wins whenever present. It is the only + * form that works for uploads and the server knows which variant to serve. + * 2. An `upload:` ref with no URL resolves to **null**. Appending the ref to + * the server origin used to yield `https://server/upload:profile-avatars/…`, + * a guaranteed 404 that rendered as an empty circle; initials are strictly + * better than a broken image request. + * 3. Everything else (DiceBear presets, absolute URLs, server-relative paths) + * keeps its existing behaviour. + */ +fun resolveProfileAvatar(serverUrl: String, avatar: ProfileAvatarRef): ResolvedProfileAvatar? { + val trimmedRef = avatar.avatar?.trim().orEmpty() + val trimmedUrl = avatar.avatarUrl?.trim().orEmpty() + + if (trimmedUrl.isNotEmpty()) { + return ResolvedProfileAvatar( + url = trimmedUrl, + // Only uploads get an override: their signature rotates. DiceBear + // and other query-bearing URLs must keep the query in their key — + // stripping it would collapse every preset onto one cache entry. + cacheKey = if (isUploadAvatarRef(trimmedRef)) { + stableUploadCacheKey(trimmedRef, trimmedUrl) + } else { + null + }, + ) + } + + if (isUploadAvatarRef(trimmedRef)) return null + + return resolveAvatarUrl(serverUrl, trimmedRef)?.let { ResolvedProfileAvatar(it) } +} + +/** + * Legacy single-string resolution, kept for refs that carry no server URL. + * + * Returns null for `upload:` refs — see [resolveProfileAvatar]. Prefer that + * function anywhere a [Profile] (and therefore an `avatar_url`) is in hand. + */ fun resolveAvatarUrl(serverUrl: String, avatar: String): String? { val trimmedAvatar = avatar.trim() if (trimmedAvatar.isEmpty()) return null resolveDiceBearPresetUrl(trimmedAvatar)?.let { return it } + // An upload ref is not a path. Never fabricate an origin-relative URL from + // it; the caller wants null so it can fall back to initials. + if (isUploadAvatarRef(trimmedAvatar)) return null + val normalizedServerUrl = serverUrl.trim().trimEnd('/') val lowercasedAvatar = trimmedAvatar.lowercase() val isAbsoluteAvatar = imageAvatarPrefixes @@ -68,6 +183,19 @@ fun resolveAvatarUrl(serverUrl: String, avatar: String): String? { } } +/** + * The signature-free identity of a presigned upload URL: everything up to the + * first `?` or `#`. That prefix (`https:////…//w256.webp`) + * is stable across re-signings but still distinguishes one profile's upload — + * and one rendition of it — from another. Falls back to the ref if the URL has + * no usable prefix. + */ +private fun stableUploadCacheKey(avatarRef: String, url: String): String { + val queryStart = url.indexOfFirst { it == '?' || it == '#' } + val withoutQuery = if (queryStart >= 0) url.substring(0, queryStart) else url + return withoutQuery.ifBlank { avatarRef } +} + fun profileAvatarDisplayText(avatar: String?, name: String): String { val trimmedAvatar = avatar?.trim().orEmpty() return if (trimmedAvatar.isNotEmpty() && !isImageAvatar(trimmedAvatar)) { @@ -77,6 +205,17 @@ fun profileAvatarDisplayText(avatar: String?, name: String): String { } } +/** [profileAvatarDisplayText] for call sites that already hold a [ProfileAvatarRef]. */ +fun profileAvatarDisplayText(avatar: ProfileAvatarRef, name: String): String = + profileAvatarDisplayText(avatar.avatar, name) + +/** + * True when the avatar is a literal glyph (emoji) rather than an image ref, so + * a caller can size that glyph differently from initials. + */ +fun isEmojiAvatar(avatar: ProfileAvatarRef): Boolean = + !avatar.avatar.isNullOrBlank() && !isImageAvatar(avatar.avatar) + @Composable fun rememberProfileServerUrl(): String { val serverRegistry = remember { GlobalContext.get().get() } @@ -85,6 +224,77 @@ fun rememberProfileServerUrl(): String { return remember(serverUrl) { serverUrl.trim().trimEnd('/') } } +/** A resolved avatar image plus the failure hook that retires it. */ +@Stable +class ProfileAvatarImage internal constructor( + val url: String, + val cacheKey: String?, + /** Report a load failure; the owning composable then falls back to text. */ + val onLoadFailed: () -> Unit, +) + +/** + * Resolves [avatar] against the active server, or null when there is nothing + * to draw and the caller should render emoji/initials instead. + * + * Handles presigned-URL expiry. The server signs upload URLs for 900 seconds, + * so a screen held open longer than that (TV profile selection left idling, the + * always-composed shell avatar) can be holding a URL that now 403s. Two things + * keep that from showing as an empty circle: + * + * - the stable cache key means an avatar that loaded once keeps rendering + * from Coil's memory/disk cache regardless of the URL's age, so expiry only + * bites for an image that was never fetched while the URL was valid; and + * - if the fetch does fail, [ProfileAvatarImage.onLoadFailed] retires this + * URL and the caller falls back to initials until a fresh one arrives. + * + * Deliberately no timer-based pre-emptive expiry: dropping the image the + * instant the signature ages out would discard a perfectly good cached bitmap. + * A fresh URL arrives with the next `GET /profiles` — on screen re-entry, a + * profile switch, or relaunch — and clears the failure flag automatically. + * + * A failure is also not assumed permanent. A DiceBear/CDN blip or a dropped + * connection retires a URL that is otherwise perfectly good, and a stable URL + * on an always-composed surface (the TV shell avatar) would otherwise never be + * requested again for the life of the process. Failures are therefore retried + * on the [avatarRetryDelaysMs] backoff before the avatar settles into initials. + */ +@Composable +fun rememberProfileAvatarImage(avatar: ProfileAvatarRef): ProfileAvatarImage? { + val serverUrl = rememberProfileServerUrl() + val resolved = remember(avatar, serverUrl) { resolveProfileAvatar(serverUrl, avatar) } + // Keyed on `resolved`, so any newly-signed URL starts trusted again. + var failureCount by remember(resolved) { mutableIntStateOf(0) } + var loadFailed by remember(resolved) { mutableStateOf(false) } + + val retryDelayMs = avatarRetryDelaysMs.getOrNull(failureCount - 1) + if (loadFailed && retryDelayMs != null) { + LaunchedEffect(resolved, failureCount) { + delay(retryDelayMs) + loadFailed = false + } + } + + return remember(resolved, loadFailed) { + resolved + ?.takeUnless { loadFailed } + ?.let { + ProfileAvatarImage(it.url, it.cacheKey) { + failureCount++ + loadFailed = true + } + } + } +} + +/** + * How long to wait before re-requesting an avatar that failed to load, per + * attempt. Bounded on purpose: a genuinely broken ref settles into initials + * after the last entry instead of re-requesting forever, while one that failed + * during an outage recovers on its own once the network is back. + */ +private val avatarRetryDelaysMs = listOf(5_000L, 20_000L, 60_000L) + fun String.profileInitials(): String { val trimmed = trim() if (trimmed.isEmpty()) return "?" diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashAsset.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashAsset.kt new file mode 100644 index 000000000..d0de39b80 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashAsset.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.common.ui.components + +import android.media.MediaCodecInfo.VideoCapabilities +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import androidx.annotation.RawRes +import org.prairieserver.prairie.common.R + +/** + * The startup splash ships at two tiers because weak TV boxes garble anything + * richer than they can actually decode. An onn 4K stick (Realtek, API 34) + * accepted a 4K60 splash and then presented half-reconstructed frames — the + * logo drew with its lower macroblock rows missing. Its decoder declares + * `performance-point-3840x2160 30-30` and a 1,879,200 blocks/sec ceiling + * against the 1,944,000 that 4K60 needs, so the capability data had the answer + * all along; nothing was asking it. + * + * [startupSplashRes] asks. Devices whose AVC decoder covers 1080p60 get the + * HD asset; everything else gets the 720p30 baseline, which is under every + * ceiling we've seen and still oversized for the box the splash draws into. + */ +@RawRes +fun startupSplashRes(): Int = + if (supportsAvc1080p60()) R.raw.startup_splash_hd else R.raw.startup_splash + +private const val HD_WIDTH = 1920 +private const val HD_HEIGHT = 1080 +private const val HD_FRAME_RATE = 60 + +/** + * True when some decoder claims 1080p60 AVC. Prefers hardware decoders where + * the platform can identify them: a software decoder's advertised performance + * points describe a CPU that may be busy doing everything else during a cold + * launch, which is exactly when the splash plays. + */ +private fun supportsAvc1080p60(): Boolean = runCatching { + val decoders = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos + .filterNot { it.isEncoder } + val preferred = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + decoders.filter { it.isHardwareAccelerated }.ifEmpty { decoders } + } else { + decoders + } + preferred.any { info -> + val videoCapabilities = runCatching { + info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC) + }.getOrNull()?.videoCapabilities + videoCapabilities?.coversHd() == true + } +}.getOrDefault(false) + +private fun VideoCapabilities.coversHd(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val points = supportedPerformancePoints + if (!points.isNullOrEmpty()) { + val target = VideoCapabilities.PerformancePoint(HD_WIDTH, HD_HEIGHT, HD_FRAME_RATE) + return points.any { it.covers(target) } + } + } + // Pre-Q, or a decoder that publishes no performance points: fall back to + // the size/rate limits, which encode the same blocks-per-second ceiling. + return areSizeAndRateSupported(HD_WIDTH, HD_HEIGHT, HD_FRAME_RATE.toDouble()) +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashVideo.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashVideo.kt index a6ac0a757..90468477f 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashVideo.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/StartupSplashVideo.kt @@ -29,7 +29,6 @@ import androidx.media3.datasource.RawResourceDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView -import org.prairieserver.prairie.common.R import kotlinx.coroutines.delay import java.util.concurrent.atomic.AtomicBoolean @@ -57,13 +56,17 @@ fun StartupSplashVideo( val playbackStarted = remember { AtomicBoolean(false) } var playbackFinished by remember { mutableStateOf(false) } var playbackVisibleStartedAt by remember { mutableStateOf(0L) } - val player = remember(context) { + // Tier the asset to what this device's decoder actually sustains — a 4K60 + // splash decoded into garbled half-frames on an onn 4K stick. See + // [startupSplashRes]. + val splashRes = remember { startupSplashRes() } + val player = remember(context, splashRes) { ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_OFF volume = 0f setMediaItem( MediaItem.fromUri( - RawResourceDataSource.buildRawResourceUri(R.raw.startup_splash), + RawResourceDataSource.buildRawResourceUri(splashRes), ), ) prepare() diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt index d6e9b85f6..2878f7db2 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt @@ -5,26 +5,42 @@ import android.util.LruCache import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil3.compose.AsyncImage +import coil3.decode.DataSource import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import org.prairieserver.prairie.util.ArtworkUrl private val DefaultPlaceholderColor = Color(0xFF1A1D27) +/** + * Optional feed-scoped gate for presenting newly decoded full-size artwork. + * The value is a [State] so a scrolling container can provide one stable object + * without recomposing its entire subtree whenever motion starts or stops. + */ +val LocalImagePresentationDeferral = staticCompositionLocalOf?> { null } + /** * Process-wide cache of decoded ThumbHash placeholders, keyed by base64 hash. * Decoded placeholders are tiny (≤32×32 ARGB) but the decode is a per-pixel @@ -46,9 +62,6 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * posters/backdrops blur up instead of popping in from a flat color. Falls back * to a neutral placeholder when no thumbhash is supplied or decoding fails. * - * Prefers AVIF siblings of canonical `.webp` artwork on API 31+ (platform AVIF - * decode), then WebP, then PNG when earlier formats are missing or fail. - * * @param url The remote image URL to load. * @param thumbhash Base64-encoded ThumbHash for the instant blurred preview. * @param contentDescription Accessibility description for the image. @@ -62,6 +75,12 @@ private fun decodeThumbhashPainter(hash: String): BitmapPainter? = * with a coordinated transition can supply their shared timing token. * @param onSuccess Optional signal that the full image has decoded. Useful for * keeping a semantic fallback visible until transparent artwork is ready. + * @param cacheKey Overrides the memory/disk cache key, which otherwise defaults + * to [url]. Needed when the URL is not stable for the same bytes — a + * presigned profile-avatar URL re-signs its query on every fetch, so keying + * by the URL would miss the cache every time. Must still be unique per image. + * @param onError Optional signal that the fetch or decode failed, so the caller + * can retire the URL and fall back rather than leave an empty box. */ @Composable fun ThumbhashImage( @@ -70,12 +89,17 @@ fun ThumbhashImage( contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, + /** Where a non-filling ([ContentScale.Fit]) image sits inside its bounds. */ + alignment: Alignment = Alignment.Center, transparent: Boolean = false, decodeSizePx: Int? = null, crossfadeMillis: Int = 300, onSuccess: (() -> Unit)? = null, + cacheKey: String? = null, + onError: (() -> Unit)? = null, ) { val context = LocalContext.current + val deferPresentationWhile = LocalImagePresentationDeferral.current // Cached placeholders resolve synchronously (instant on scroll-back); a cold // hash decodes off the composition thread and blurs up a frame later, so the @@ -108,36 +132,97 @@ fun ThumbhashImage( return } - val candidates = remember(url) { - ArtworkUrl.candidates(url) - } - var failedCount by remember(url) { mutableStateOf(0) } - val current = candidates[failedCount.coerceIn(0, (candidates.size - 1).coerceAtLeast(0))] - - val model = remember(current, decodeSizePx, crossfadeMillis) { + val model = remember(url, decodeSizePx, crossfadeMillis, cacheKey) { ImageRequest.Builder(context) - .data(current) + .data(url) .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) else crossfade(false) } .apply { decodeSizePx?.let { size(it) } } + .apply { + cacheKey?.let { + memoryCacheKey(it) + diskCacheKey(it) + } + } .build() } - AsyncImage( - model = model, - contentDescription = contentDescription, - contentScale = contentScale, - placeholder = placeholder, - onSuccess = { onSuccess?.invoke() }, - onError = { - if (failedCount < candidates.lastIndex) { - failedCount += 1 + if (deferPresentationWhile == null) { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + alignment = alignment, + placeholder = placeholder, + onSuccess = { onSuccess?.invoke() }, + onError = { onError?.invoke() }, + modifier = when { + transparent || placeholder != null -> modifier + else -> modifier.background(DefaultPlaceholderColor) + }, + ) + return + } + + // Keep request/decode/cache work moving during a fling, but do not ask the + // renderer to import a newly completed hardware bitmap until the feed is + // idle. drawWithContent is important here: merely covering the AsyncImage + // with its placeholder would still draw (and upload) the bitmap underneath. + // Once committed, the artwork stays committed through later gestures. + var fullImageReady by remember(url) { mutableStateOf(false) } + var fullImagePresented by remember(url) { mutableStateOf(false) } + val currentOnSuccess by rememberUpdatedState(onSuccess) + val presentFullImage = { + if (!fullImagePresented) { + fullImagePresented = true + currentOnSuccess?.invoke() + } + } + + LaunchedEffect(url, deferPresentationWhile, fullImageReady) { + if (!fullImageReady || fullImagePresented) return@LaunchedEffect + snapshotFlow { deferPresentationWhile.value }.first { isMoving -> !isMoving } + presentFullImage() + } + + Box(modifier = modifier) { + if (!fullImagePresented) { + when { + placeholder != null -> Image( + painter = placeholder, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + ) + !transparent -> Box( + modifier = Modifier + .fillMaxSize() + .background(DefaultPlaceholderColor), + ) } - }, - modifier = when { - transparent || placeholder != null -> modifier - else -> modifier.background(DefaultPlaceholderColor) - }, - ) + } + + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + alignment = alignment, + onSuccess = { state -> + fullImageReady = true + if ( + state.result.dataSource == DataSource.MEMORY_CACHE || + !deferPresentationWhile.value + ) { + presentFullImage() + } + }, + onError = { onError?.invoke() }, + modifier = Modifier + .fillMaxSize() + .drawWithContent { + if (fullImagePresented) drawContent() + }, + ) + } } diff --git a/android-shared/src/androidMain/res/raw/startup_splash.mp4 b/android-shared/src/androidMain/res/raw/startup_splash.mp4 index 6d04e56cf..9062a8ec0 100644 Binary files a/android-shared/src/androidMain/res/raw/startup_splash.mp4 and b/android-shared/src/androidMain/res/raw/startup_splash.mp4 differ diff --git a/android-shared/src/androidMain/res/raw/startup_splash_hd.mp4 b/android-shared/src/androidMain/res/raw/startup_splash_hd.mp4 new file mode 100644 index 000000000..ee5159ecf Binary files /dev/null and b/android-shared/src/androidMain/res/raw/startup_splash_hd.mp4 differ diff --git a/android-shared/src/androidMain/res/raw/startup_splash_lottie.json b/android-shared/src/androidMain/res/raw/startup_splash_lottie.json new file mode 100644 index 000000000..641d41618 --- /dev/null +++ b/android-shared/src/androidMain/res/raw/startup_splash_lottie.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":240,"w":3840,"h":2160,"nm":"Silo startup splash","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"tri","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":2,"s":[1846.7213104548275,641.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":3,"s":[1846.7213104548275,613.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":4,"s":[1846.7213104548275,601.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":5,"s":[1846.7213104548275,589.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":6,"s":[1846.7213104548275,585.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":7,"s":[1846.7213104548275,577.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":8,"s":[1846.7213104548275,573.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":9,"s":[1846.7213104548275,569.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":10,"s":[1846.7213104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":11,"s":[1846.7213104548275,561.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":12,"s":[1846.7213104548275,557.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":14,"s":[1846.7213104548275,553.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":16,"s":[1846.7213104548275,549.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":19,"s":[1846.7213104548275,545.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":26,"s":[1846.7213104548275,549.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":28,"s":[1846.7213104548275,553.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":29,"s":[1846.7213104548275,557.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":31,"s":[1846.7213104548275,561.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":35,"s":[1846.7213104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":91,"s":[1838.5654704548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":92,"s":[1826.3193904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":93,"s":[1814.0856304548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":94,"s":[1793.6837104548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":95,"s":[1769.2161904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":96,"s":[1740.6584304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":97,"s":[1699.8545904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":98,"s":[1650.9072304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":99,"s":[1585.6358704548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":100,"s":[1504.0405104548277,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":101,"s":[1406.1334704548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":102,"s":[1308.2264304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":103,"s":[1218.4875504548277,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":104,"s":[1145.0480304548273,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":105,"s":[1087.9448304548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":106,"s":[1034.9072304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":107,"s":[990.0377904548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":108,"s":[949.2339504548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":109,"s":[916.5982704548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":110,"s":[892.1307504548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":111,"s":[863.5729904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":112,"s":[839.0931504548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":113,"s":[818.6912304548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":114,"s":[802.3795504548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":115,"s":[777.8997104548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":116,"s":[765.6659504548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":117,"s":[753.4198704548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":118,"s":[737.1081904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":119,"s":[724.8621104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":120,"s":[712.6283504548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":121,"s":[700.3945904548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":122,"s":[692.2264304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":123,"s":[679.9926704548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":124,"s":[675.9147504548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":125,"s":[667.7589104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":126,"s":[663.6809904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":127,"s":[655.5128304548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":128,"s":[647.3569904548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":129,"s":[643.2790704548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":130,"s":[639.2011504548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":131,"s":[635.1232304548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":132,"s":[631.0453104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":133,"s":[626.9550704548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":134,"s":[622.8771504548274,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":136,"s":[618.7992304548276,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":138,"s":[614.7213104548275,565.0036627785331,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":239,"s":[614.7213104548275,565.0036627785331,0]}]},"a":{"a":0,"k":[614.7213104548275,565.0036627785331,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,3.658442437881604e-10],[-13.362135719593596,7.714777525731051],[-13.36219844980775,-7.714668874833137],[0.0,0.0],[0.0,-15.429071246074841],[13.361897138085169,-7.714657762187926],[0.0,0.0],[13.362135719528226,7.7147775258442834],[-0.00025092668067827617,15.429337743318683]],"o":[[-0.0002509265499384128,-15.42933774331874],[13.362135719593653,-7.714777525731108],[-3.168452167301439e-10,-1.829221218940802e-10],[13.361897138085169,7.714657762187926],[0.0,15.429071246074841],[0.0,0.0],[-13.36219844987312,7.714668874720019],[-13.362135719528283,-7.714777525844397],[0.0,1.1368683772161603e-13]],"v":[[388.3005092154413,353.4226819027115],[409.89368164682764,316.02115663012023],[453.08093889172613,316.02098104728816],[819.5492279374998,527.6028616507161],[841.1423626208945,565.0036627784983],[819.5492279374998,602.4044639062804],[453.0809388914093,813.9863445098913],[409.8936816465108,813.9861689266934],[388.3005092154413,776.5846436539191]],"c":true}},"nm":"tri-0"},{"ty":"fl","c":{"a":0,"k":[0.0,0.20392156862745098,0.984313725490196,1.0]},"o":{"a":0,"k":100},"r":2,"nm":"fill"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"tri"}],"ip":2,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"bar1","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":12,"s":[1859.6282077405451,997.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":13,"s":[1859.6282077405451,969.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":14,"s":[1859.6282077405451,957.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":15,"s":[1859.6282077405451,945.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":16,"s":[1859.6282077405451,941.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":17,"s":[1859.6282077405451,933.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":18,"s":[1859.6282077405451,929.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":19,"s":[1859.6282077405451,925.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":20,"s":[1859.6282077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":21,"s":[1859.6282077405451,917.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":22,"s":[1859.6282077405451,913.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":24,"s":[1859.6282077405451,909.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":26,"s":[1859.6282077405451,905.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":29,"s":[1859.6282077405451,901.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":36,"s":[1859.6282077405451,905.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":38,"s":[1859.6282077405451,909.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":39,"s":[1859.6282077405451,913.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[1859.6282077405451,917.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":45,"s":[1859.6282077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":91,"s":[1851.4723677405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":92,"s":[1839.226287740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":93,"s":[1826.992527740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":94,"s":[1806.590607740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":95,"s":[1782.123087740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":96,"s":[1753.5653277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":97,"s":[1712.7614877405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":98,"s":[1663.8141277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":99,"s":[1598.542767740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":100,"s":[1516.9474077405453,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":101,"s":[1419.040367740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":102,"s":[1321.1333277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":103,"s":[1231.3944477405453,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":104,"s":[1157.954927740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":105,"s":[1100.8517277405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":106,"s":[1047.8141277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":107,"s":[1002.9446877405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":108,"s":[962.140847740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":109,"s":[929.5051677405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":110,"s":[905.037647740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":111,"s":[876.4798877405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":112,"s":[852.000047740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":113,"s":[831.5981277405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":114,"s":[815.2864477405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":115,"s":[790.806607740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":116,"s":[778.5728477405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":117,"s":[766.3267677405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":118,"s":[750.0150877405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":119,"s":[737.7690077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":120,"s":[725.5352477405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":121,"s":[713.3014877405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":122,"s":[705.1333277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":123,"s":[692.8995677405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":124,"s":[688.8216477405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":125,"s":[680.6658077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":126,"s":[676.587887740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":127,"s":[668.4197277405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":128,"s":[660.2638877405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":129,"s":[656.185967740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":130,"s":[652.1080477405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":131,"s":[648.0301277405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":132,"s":[643.9522077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":133,"s":[639.8619677405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":134,"s":[635.784047740545,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":136,"s":[631.7061277405452,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":138,"s":[627.6282077405451,921.3185192010709,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":239,"s":[627.6282077405451,921.3185192010709,0]}]},"a":{"a":0,"k":[627.6282077405451,921.3185192010709,0]},"s":{"a":1,"k":[{"t":12,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":17,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":18,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":19,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":20,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":21,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":22,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":23,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":24,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":25,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":26,"s":[111.10000000000002,111.10000000000002,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":27,"s":[110.75000000000001,110.75000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":28,"s":[110.4,110.4,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":29,"s":[110.575,110.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":30,"s":[110.75,110.75,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":31,"s":[110.92500000000001,110.92500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":32,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":33,"s":[110.80000000000001,110.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":34,"s":[110.5,110.5,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":35,"s":[110.19999999999999,110.19999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":36,"s":[109.89999999999999,109.89999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":37,"s":[108.575,108.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":38,"s":[107.25,107.25,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":39,"s":[105.925,105.925,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":40,"s":[104.60000000000001,104.60000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[104.275,104.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":42,"s":[103.94999999999999,103.94999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":43,"s":[103.62499999999999,103.62499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":44,"s":[103.3,103.3,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":45,"s":[103.14999999999999,103.14999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":46,"s":[102.99999999999999,102.99999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":47,"s":[102.85,102.85,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":48,"s":[102.69999999999999,102.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":49,"s":[102.525,102.525,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":50,"s":[102.34999999999998,102.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":51,"s":[102.175,102.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":52,"s":[102.0,102.0,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":53,"s":[101.67500000000001,101.67500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":54,"s":[101.35000000000001,101.35000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":55,"s":[101.02499999999999,101.02499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":56,"s":[100.69999999999999,100.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":65,"s":[100.52499999999999,100.52499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":66,"s":[100.34999999999998,100.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":67,"s":[100.175,100.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":68,"s":[100.0,100.0,100.0]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-1.851958586485125e-10],[-15.032525897472397,8.679032872016023],[0.0,0.0],[-15.032387976968494,-8.679069828776619],[0.0002019302670532852,-17.35796478110592],[0.0,0.0],[15.032525897472283,-8.679032872016023],[0.0,0.0],[15.03238797696855,8.679069828776619],[-0.0002019302670532852,17.357964781105693]],"o":[[-0.00020193015154745808,-17.358065745092063],[1.6041212802520022e-10,-9.254108590539545e-11],[15.032438458772276,-8.678982392314765],[15.032387976968494,8.679069828776619],[1.1368683772161603e-13,0.0],[0.00020193015154745808,17.358065744906526],[-1.6052581486292183e-10,9.276845958083868e-11],[-15.03243845877239,8.678982392314765],[-15.032387976968494,-8.679069828776619],[0.0,0.0]],"v":[[388.3005092154413,951.236280973274],[412.59317034376886,909.1595924422602],[794.0779228801853,688.9097304566362],[842.6634898803014,688.909871756307],[866.9559062656491,730.9864189875573],[866.9559062656492,891.400757429053],[842.6632451373218,933.4774459598813],[461.1784926009053,1153.7273079455056],[412.592925600789,1153.7271666458348],[388.3005092154413,1111.6506194145845]],"c":true}},"nm":"bar1-0"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-7.298694981727749e-11],[-6.124135579227982,3.5357713222086886],[0.0,0.0],[-6.124258279773812,-3.5357384468559303],[-0.00017964490962185664,-7.0716324713380345],[0.0,0.0],[6.124135579227982,-3.5357713222086886],[0.0,0.0],[6.124258279773699,3.535738446856044],[0.00017964490962185664,7.0716324713380345]],"o":[[0.0001796452364715151,-7.071542646654052],[6.332356861094013e-11,-3.660716174636036e-11],[6.124213367064158,-3.535816239091787],[6.124258279773812,3.535738446856044],[0.0,0.0],[-0.00017964523635782825,7.071542646581179],[-6.320988177321851e-11,3.6493474908638746e-11],[-6.124213367064385,3.5358162390915595],[-6.124258279773812,-3.535738446856044],[0.0,1.1368683772161603e-13]],"v":[[534.0564759863693,938.1848238498341],[543.95348607569,921.0432023749846],[691.5089092265694,835.8515389819105],[711.3027116719136,835.8514132679284],[721.1999394947213,852.9931604567236],[721.1999394947213,904.4522145523806],[711.3029294054005,921.5938360271573],[563.7475062545213,1006.7854994202314],[543.953703809177,1006.7856251342133],[534.0564759863693,989.6438779454181]],"c":true}},"nm":"bar1-1"},{"ty":"fl","c":{"a":0,"k":[0.0,0.20392156862745098,0.984313725490196,1.0]},"o":{"a":0,"k":100},"r":2,"nm":"fill"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"bar1"}],"ip":12,"op":240,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"bar2","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":24,"s":[1859.6282077405451,1341.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":25,"s":[1859.6282077405451,1313.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":26,"s":[1859.6282077405451,1301.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":27,"s":[1859.6282077405451,1289.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":28,"s":[1859.6282077405451,1285.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":29,"s":[1859.6282077405451,1277.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":30,"s":[1859.6282077405451,1273.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":31,"s":[1859.6282077405451,1269.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":32,"s":[1859.6282077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":33,"s":[1859.6282077405451,1261.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":34,"s":[1859.6282077405451,1257.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":36,"s":[1859.6282077405451,1253.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":38,"s":[1859.6282077405451,1249.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[1859.6282077405451,1245.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":48,"s":[1859.6282077405451,1249.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":50,"s":[1859.6282077405451,1253.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":51,"s":[1859.6282077405451,1257.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":53,"s":[1859.6282077405451,1261.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":57,"s":[1859.6282077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":91,"s":[1851.4723677405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":92,"s":[1839.226287740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":93,"s":[1826.992527740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":94,"s":[1806.590607740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":95,"s":[1782.123087740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":96,"s":[1753.5653277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":97,"s":[1712.7614877405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":98,"s":[1663.8141277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":99,"s":[1598.542767740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":100,"s":[1516.9474077405453,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":101,"s":[1419.040367740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":102,"s":[1321.1333277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":103,"s":[1231.3944477405453,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":104,"s":[1157.954927740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":105,"s":[1100.8517277405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":106,"s":[1047.8141277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":107,"s":[1002.9446877405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":108,"s":[962.140847740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":109,"s":[929.5051677405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":110,"s":[905.037647740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":111,"s":[876.4798877405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":112,"s":[852.000047740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":113,"s":[831.5981277405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":114,"s":[815.2864477405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":115,"s":[790.806607740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":116,"s":[778.5728477405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":117,"s":[766.3267677405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":118,"s":[750.0150877405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":119,"s":[737.7690077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":120,"s":[725.5352477405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":121,"s":[713.3014877405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":122,"s":[705.1333277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":123,"s":[692.8995677405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":124,"s":[688.8216477405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":125,"s":[680.6658077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":126,"s":[676.587887740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":127,"s":[668.4197277405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":128,"s":[660.2638877405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":129,"s":[656.185967740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":130,"s":[652.1080477405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":131,"s":[648.0301277405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":132,"s":[643.9522077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":133,"s":[639.8619677405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":134,"s":[635.784047740545,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":136,"s":[631.7061277405452,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":138,"s":[627.6282077405451,1265.7344703116341,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":239,"s":[627.6282077405451,1265.7344703116341,0]}]},"a":{"a":0,"k":[627.6282077405451,1265.7344703116341,0]},"s":{"a":1,"k":[{"t":24,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":29,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":30,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":31,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":32,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":33,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":34,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":35,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":36,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":37,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":38,"s":[111.10000000000002,111.10000000000002,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":39,"s":[110.75000000000001,110.75000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":40,"s":[110.4,110.4,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[110.575,110.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":42,"s":[110.75,110.75,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":43,"s":[110.92500000000001,110.92500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":44,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":45,"s":[110.80000000000001,110.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":46,"s":[110.5,110.5,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":47,"s":[110.19999999999999,110.19999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":48,"s":[109.89999999999999,109.89999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":49,"s":[108.575,108.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":50,"s":[107.25,107.25,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":51,"s":[105.925,105.925,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":52,"s":[104.60000000000001,104.60000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":53,"s":[104.275,104.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":54,"s":[103.94999999999999,103.94999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":55,"s":[103.62499999999999,103.62499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":56,"s":[103.3,103.3,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":57,"s":[103.14999999999999,103.14999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":58,"s":[102.99999999999999,102.99999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":59,"s":[102.85,102.85,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":60,"s":[102.69999999999999,102.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":61,"s":[102.525,102.525,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":62,"s":[102.34999999999998,102.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":63,"s":[102.175,102.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":64,"s":[102.0,102.0,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":65,"s":[101.67500000000001,101.67500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":66,"s":[101.35000000000001,101.35000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":67,"s":[101.02499999999999,101.02499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":68,"s":[100.69999999999999,100.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":77,"s":[100.52499999999999,100.52499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":78,"s":[100.34999999999998,100.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":79,"s":[100.175,100.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":80,"s":[100.0,100.0,100.0]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-1.850821718107909e-10],[-15.032525897472397,8.679032872016023],[0.0,0.0],[-15.032387976968494,-8.679069828776619],[0.0002019302670532852,-17.35796478110592],[0.0,0.0],[15.032525897472283,-8.67903287201625],[0.0,0.0],[15.03238797696855,8.679069828776846],[-0.0002019302670532852,17.35796478110592]],"o":[[-0.00020193015154745808,-17.358065745091835],[1.6041212802520022e-10,-9.276845958083868e-11],[15.032438458772276,-8.678982392314765],[15.032387976968494,8.679069828776846],[1.1368683772161603e-13,2.2737367544323206e-13],[0.00020193015154745808,17.358065744906526],[-1.6052581486292183e-10,9.276845958083868e-11],[-15.03243845877239,8.678982392314765],[-15.032387976968494,-8.679069828776619],[0.0,0.0]],"v":[[388.3005092154413,1295.6522320838371],[412.59317034376886,1253.5755435528235],[794.0779228801853,1033.3256815671994],[842.6634898803014,1033.32582286687],[866.9559062656491,1075.4023700981204],[866.9559062656492,1235.8167085396162],[842.6632451373218,1277.8933970704443],[461.1784926009053,1498.1432590560687],[412.592925600789,1498.143117756398],[388.3005092154413,1456.0665705251474]],"c":true}},"nm":"bar2-0"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-7.321432349272072e-11],[-6.124135579227982,3.5357713222088023],[0.0,0.0],[-6.124258279773812,-3.5357384468559303],[-0.00017964490962185664,-7.0716324713380345],[0.0,0.0],[6.124135579228096,-3.5357713222088023],[0.0,0.0],[6.124258279773812,3.5357384468561577],[0.00017964490962185664,7.0716324713380345]],"o":[[0.0001796452364715151,-7.071542646654052],[6.332356861094013e-11,-3.637978807091713e-11],[6.124213367064158,-3.535816239091673],[6.124258279773812,3.5357384468561577],[0.0,0.0],[-0.00017964523635782825,7.0715426465812925],[-6.30961949354969e-11,3.637978807091713e-11],[-6.124213367064385,3.535816239091673],[-6.124258279773812,-3.5357384468559303],[0.0,0.0]],"v":[[534.0564759863693,1282.6007749603973],[543.95348607569,1265.4591534855476],[691.5089092265694,1180.2674900924735],[711.3027116719136,1180.2673643784915],[721.1999394947213,1197.4091115672868],[721.1999394947213,1248.8681656629437],[711.3029294054004,1266.0097871377207],[563.7475062545213,1351.2014505307945],[543.953703809177,1351.2015762447763],[534.0564759863693,1334.0598290559815]],"c":true}},"nm":"bar2-1"},{"ty":"fl","c":{"a":0,"k":[0.9607843137254902,0.043137254901960784,0.30980392156862746,1.0]},"o":{"a":0,"k":100},"r":2,"nm":"fill"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"bar2"}],"ip":24,"op":240,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"bar3","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":36,"s":[1859.6282077405451,1686.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":37,"s":[1859.6282077405451,1658.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":38,"s":[1859.6282077405451,1646.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":39,"s":[1859.6282077405451,1634.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":40,"s":[1859.6282077405451,1630.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[1859.6282077405451,1622.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":42,"s":[1859.6282077405451,1618.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":43,"s":[1859.6282077405451,1614.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":44,"s":[1859.6282077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":45,"s":[1859.6282077405451,1606.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":46,"s":[1859.6282077405451,1602.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":48,"s":[1859.6282077405451,1598.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":50,"s":[1859.6282077405451,1594.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":53,"s":[1859.6282077405451,1590.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":60,"s":[1859.6282077405451,1594.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":62,"s":[1859.6282077405451,1598.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":63,"s":[1859.6282077405451,1602.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":65,"s":[1859.6282077405451,1606.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":69,"s":[1859.6282077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":91,"s":[1851.4723677405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":92,"s":[1839.226287740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":93,"s":[1826.992527740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":94,"s":[1806.590607740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":95,"s":[1782.123087740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":96,"s":[1753.5653277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":97,"s":[1712.7614877405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":98,"s":[1663.8141277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":99,"s":[1598.542767740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":100,"s":[1516.9474077405453,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":101,"s":[1419.040367740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":102,"s":[1321.1333277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":103,"s":[1231.3944477405453,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":104,"s":[1157.954927740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":105,"s":[1100.8517277405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":106,"s":[1047.8141277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":107,"s":[1002.9446877405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":108,"s":[962.140847740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":109,"s":[929.5051677405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":110,"s":[905.037647740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":111,"s":[876.4798877405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":112,"s":[852.000047740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":113,"s":[831.5981277405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":114,"s":[815.2864477405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":115,"s":[790.806607740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":116,"s":[778.5728477405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":117,"s":[766.3267677405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":118,"s":[750.0150877405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":119,"s":[737.7690077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":120,"s":[725.5352477405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":121,"s":[713.3014877405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":122,"s":[705.1333277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":123,"s":[692.8995677405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":124,"s":[688.8216477405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":125,"s":[680.6658077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":126,"s":[676.587887740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":127,"s":[668.4197277405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":128,"s":[660.2638877405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":129,"s":[656.185967740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":130,"s":[652.1080477405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":131,"s":[648.0301277405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":132,"s":[643.9522077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":133,"s":[639.8619677405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":134,"s":[635.784047740545,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":136,"s":[631.7061277405452,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":138,"s":[627.6282077405451,1610.1504214221975,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":239,"s":[627.6282077405451,1610.1504214221975,0]}]},"a":{"a":0,"k":[627.6282077405451,1610.1504214221975,0]},"s":{"a":1,"k":[{"t":36,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":41,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":42,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":43,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":44,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":45,"s":[111.275,111.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":46,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":47,"s":[111.625,111.625,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":48,"s":[111.80000000000001,111.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":49,"s":[111.45,111.45,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":50,"s":[111.10000000000002,111.10000000000002,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":51,"s":[110.75000000000001,110.75000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":52,"s":[110.4,110.4,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":53,"s":[110.575,110.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":54,"s":[110.75,110.75,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":55,"s":[110.92500000000001,110.92500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":56,"s":[111.1,111.1,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":57,"s":[110.80000000000001,110.80000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":58,"s":[110.5,110.5,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":59,"s":[110.19999999999999,110.19999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":60,"s":[109.89999999999999,109.89999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":61,"s":[108.575,108.575,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":62,"s":[107.25,107.25,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":63,"s":[105.925,105.925,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":64,"s":[104.60000000000001,104.60000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":65,"s":[104.275,104.275,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":66,"s":[103.94999999999999,103.94999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":67,"s":[103.62499999999999,103.62499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":68,"s":[103.3,103.3,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":69,"s":[103.14999999999999,103.14999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":70,"s":[102.99999999999999,102.99999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":71,"s":[102.85,102.85,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":72,"s":[102.69999999999999,102.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":73,"s":[102.525,102.525,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":74,"s":[102.34999999999998,102.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":75,"s":[102.175,102.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":76,"s":[102.0,102.0,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":77,"s":[101.67500000000001,101.67500000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":78,"s":[101.35000000000001,101.35000000000001,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":79,"s":[101.02499999999999,101.02499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":80,"s":[100.69999999999999,100.69999999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":89,"s":[100.52499999999999,100.52499999999999,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":90,"s":[100.34999999999998,100.34999999999998,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":91,"s":[100.175,100.175,100.0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":92,"s":[100.0,100.0,100.0]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-1.8530954548623413e-10],[-15.032525897472283,8.67903287201625],[0.0,0.0],[-15.03238797696838,-8.679069828776619],[0.00020193026693959837,-17.35796478110592],[0.0,0.0],[15.032525897472283,-8.679032872016023],[0.0,0.0],[15.032387976968494,8.679069828776392],[-0.0002019302669964418,17.35796478110592]],"o":[[-0.00020193015154745808,-17.358065745092063],[1.6058265828178264e-10,-9.254108590539545e-11],[15.032438458772276,-8.678982392314538],[15.032387976968607,8.679069828776619],[1.1368683772161603e-13,-2.2737367544323206e-13],[0.00020193015154745808,17.358065744906753],[-1.6052581486292183e-10,9.276845958083868e-11],[-15.03243845877239,8.678982392314538],[-15.03238797696855,-8.679069828776619],[0.0,2.2737367544323206e-13]],"v":[[388.3005092154413,1640.0681831944005],[412.5931703437687,1597.9914946633867],[794.0779228801853,1377.7416326777627],[842.6634898803013,1377.7417739774335],[866.9559062656491,1419.8183212086838],[866.9559062656492,1580.2326596501794],[842.6632451373218,1622.3093481810076],[461.1784926009053,1842.5592101666318],[412.5929256007891,1842.559068866961],[388.3005092154413,1800.4825216357106]],"c":true}},"nm":"bar3-0"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,-7.321432349272072e-11],[-6.124135579228209,3.535771322208575],[0.0,0.0],[-6.124258279773812,-3.5357384468561577],[-0.00017964490962185664,-7.0716324713380345],[0.0,0.0],[6.124135579228096,-3.5357713222088023],[0.0,0.0],[6.124258279773812,3.5357384468561577],[0.00017964490962185664,7.0716324713380345]],"o":[[0.0001796452364715151,-7.0715426466542795],[6.30961949354969e-11,-3.660716174636036e-11],[6.124213367064158,-3.535816239092128],[6.124258279773812,3.5357384468561577],[0.0,2.2737367544323206e-13],[-0.00017964523635782825,7.0715426465812925],[-6.30961949354969e-11,3.660716174636036e-11],[-6.124213367064385,3.5358162390919006],[-6.124258279773812,-3.5357384468561577],[0.0,-2.2737367544323206e-13]],"v":[[534.0564759863693,1627.0167260709607],[543.9534860756902,1609.875104596111],[691.5089092265694,1524.6834412030369],[711.3027116719136,1524.6833154890546],[721.1999394947213,1541.8250626778497],[721.1999394947213,1593.2841167735069],[711.3029294054004,1610.4257382482836],[563.7475062545213,1695.6174016413577],[543.953703809177,1695.6175273553397],[534.0564759863693,1678.4757801665448]],"c":true}},"nm":"bar3-1"},{"ty":"fl","c":{"a":0,"k":[0.9803921568627451,0.4627450980392157,0.01568627450980392,1.0]},"o":{"a":0,"k":100},"r":2,"nm":"fill"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"bar3"}],"ip":36,"op":240,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"wordmark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"t":109,"s":[-396.0,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":110,"s":[-371.99843999999996,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":111,"s":[-335.99807999999996,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":112,"s":[-312.00048000000004,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":113,"s":[-276.00012,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":114,"s":[-264.00132,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":115,"s":[-251.99856,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":116,"s":[-236.00016000000002,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":117,"s":[-228.00096,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":118,"s":[-215.99819999999997,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":119,"s":[-203.99939999999998,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":120,"s":[-192.0006,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":121,"s":[-180.0018,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":122,"s":[-167.99903999999998,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":123,"s":[-159.99983999999998,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":124,"s":[-148.00103999999996,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":125,"s":[-140.00184,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":126,"s":[-131.99868,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":127,"s":[-119.99988,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":128,"s":[-116.00028,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":129,"s":[-108.00108,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":130,"s":[-100.00188,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":131,"s":[-91.99871999999999,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":132,"s":[-87.99911999999998,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":133,"s":[-79.99991999999999,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":134,"s":[-76.00031999999999,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":135,"s":[-72.00071999999999,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":136,"s":[-64.00152,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":137,"s":[-60.00192,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":138,"s":[-55.99836000000001,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":139,"s":[-51.99876000000001,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":140,"s":[-47.99916000000001,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":141,"s":[-43.99956000000002,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":142,"s":[-39.999960000000016,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":143,"s":[-36.000360000000015,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":144,"s":[-32.00076000000002,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":145,"s":[-28.001160000000016,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":147,"s":[-24.00156000000002,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":148,"s":[-20.00196000000002,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":150,"s":[-15.998399999999997,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":152,"s":[-11.998799999999996,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":154,"s":[-7.999199999999998,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":157,"s":[-3.999599999999999,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":160,"s":[-0.0,0.0,0],"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]}},{"t":239,"s":[-0.0,0.0,0]}]},"a":{"a":0,"k":[0.0,0.0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[71.38966236653846,0.0],[55.575496652431866,23.043498611983978],[40.21316424444262,42.47233077502915],[19.88066546916275,57.83466318301862],[0.0,0.0],[-44.7314973056159,-31.628331428213187],[-54.219996734079814,0.0],[-20.784332081397224,9.036666122346787],[-11.747665959050664,15.814165714106593],[0.0,21.687998693632153],[14.458665795754769,16.717832326341295],[30.724664815978485,9.940332734581489],[0.0,0.0],[40.664997550559974,48.34616375455471],[0.0,67.7749959176],[-29.36916489762666,45.18333061173337],[-52.86449681572799,25.302665142570618],[-68.67866252983481,0.0],[-51.50899689737571,-20.78433208139745],[-37.50216440773852,-37.95399771385598],[-18.97699885692805,-53.31633012184545],[0.0,0.0],[37.95399771385587,26.658165060922556],[48.79799706067206,0.0],[21.687998693631926,-9.036666122346674],[11.747665959050664,-16.71783232634141],[0.0,-21.687998693631926],[-15.362332407989243,-17.621498938575996],[-30.72466481597894,-9.940332734581261],[0.0,0.0],[-40.21316424444262,-46.99066383620266],[0.0,-67.77499591759988],[30.724664815978485,-45.635163917850605],[53.76816342796246,-25.754498448688082]],"o":[[-63.25666285642683,0.0],[-55.57549665243209,-23.043498611983978],[-40.21316424444262,-42.47233077502938],[0.0,0.0],[25.30266514257073,55.12366334631474],[44.73149730561613,31.628331428213414],[28.91733159150931,0.0],[20.784332081397224,-9.03666612234656],[11.747665959050664,-15.814165714106593],[0.0,-23.49533191810133],[-14.458665795754769,-16.717832326341522],[0.0,0.0],[-80.42632848888525,-26.206331754805205],[-40.664997550559974,-48.346163754554595],[0.0,-59.64199640748802],[29.36916489762666,-45.18333061173337],[52.86449681572799,-25.302665142570618],[60.545663019722724,0.0],[51.508996897376164,20.784332081397338],[37.50216440773852,37.95399771385598],[0.0,0.0],[-18.97699885692782,-46.99066383620266],[-37.95399771385587,-26.65816506092267],[-28.01366497927461,0.0],[-21.687998693632153,9.03666612234656],[-11.747665959050664,16.71783232634141],[0.0,23.495331918101442],[15.36233240798947,17.621498938575996],[0.0,0.0],[81.32999510112018,26.206331754805433],[40.21316424444262,46.99066383620266],[0.0,58.73832979525332],[-30.724664815978713,45.635163917850605],[-53.76816342796269,25.754498448687855]],"v":[[1557.007322772624,1630.2849590202238],[1378.759083509336,1595.7197111022479],[1235.076092164024,1497.445967021728],[1144.935347593616,1346.9854760846558],[1317.08383722432,1269.7219807385918],[1422.1350808966,1399.8499729003838],[1570.562321956144,1447.2924700427038],[1645.114817465504,1433.7374708591838],[1693.9128145261761,1396.461223104504],[1711.534313464752,1340.2079764928958],[1689.84631477112,1279.888230126232],[1622.07131885352,1239.9009825348478],[1424.168330774128,1174.8369864539518],[1242.53134171496,1063.0082431899118],[1181.53384538912,888.8265036816799],[1225.58759273556,731.5885131528479],[1348.938085305592,625.8595195213919],[1531.252824323936,587.9055218075359],[1699.334814199584,619.0820199296319],[1832.851556157256,707.1895146225119],[1917.570301054256,844.0950063760639],[1746.777311341904,921.3585017221279],[1661.380816485728,810.8852583764399],[1531.252824323936,770.8980107850558],[1456.700328814576,784.4530099685759],[1406.546831835552,823.0847576416079],[1388.925332896976,880.6935041715678],[1411.96883150896,942.3687504565838],[1481.0993273449121,983.7114979663198],[1674.9358156692479,1046.0644942105118],[1857.250554687592,1155.859987597024],[1917.570301054256,1328.0084772277278],[1871.4833038302882,1484.568717797384],[1744.744061464376,1591.653211347192]],"c":true}},"nm":"wordmark-0"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[1997.5447962370238,1614.0189599999999],[1997.5447962370238,873.9160045798079],[2200.869783989824,873.9160045798079],[2200.869783989824,1614.0189599999999]],"c":true}},"nm":"wordmark-1"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[1997.5447962370238,807.4965085805599],[1997.5447962370238,604.1715208277599],[2200.869783989824,604.1715208277599],[2200.869783989824,807.4965085805599]],"c":true}},"nm":"wordmark-2"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"o":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"v":[[2295.754778274464,1614.0189599999999],[2295.754778274464,587.9055218075359],[2499.0797660272638,587.9055218075359],[2499.0797660272638,1614.0189599999999]],"c":true}},"nm":"wordmark-3"},{"ty":"sh","ks":{"a":0,"k":{"i":[[73.19699559100764,0.0],[60.09382971360537,33.43566465268259],[35.69483118326934,58.28649648913597],[0.0,74.10066220324256],[-35.69483118326934,57.83466318301885],[-60.09382971360537,33.4356646526827],[-73.19699559100809,0.0],[-59.64199640748802,-33.43566465268259],[-35.69483118326934,-57.83466318301862],[0.0,-75.00432881547727],[35.69483118326934,-58.28649648913597],[59.64199640748802,-33.43566465268282]],"o":[[-73.19699559100809,0.0],[-60.09382971360537,-33.43566465268282],[-35.69483118326934,-58.28649648913597],[0.0,-75.00432881547727],[35.69483118326934,-57.83466318301862],[60.09382971360537,-33.43566465268259],[73.19699559100764,0.0],[59.64199640748802,33.4356646526827],[35.69483118326934,57.83466318301885],[0.0,74.10066220324256],[-35.69483118326934,58.28649648913597],[-59.64199640748802,33.43566465268259]],"v":[[2961.3052381852963,1630.2849590202238],[2761.369000228376,1580.1314620412],[2617.686008883064,1442.5482203284719],[2564.14376210816,1243.967482289904],[2617.686008883064,1044.7089942921598],[2761.369000228376,907.8035025386079],[2961.3052381852963,857.6500055595839],[3160.56372618304,907.8035025386079],[3303.568967569176,1044.7089942921598],[3357.11121434408,1243.967482289904],[3303.568967569176,1442.5482203284719],[3160.56372618304,1580.1314620412]],"c":true}},"nm":"wordmark-4"},{"ty":"sh","ks":{"a":0,"k":{"i":[[-37.050331101621396,0.0],[-27.561831673157485,17.169665632458646],[-15.81416571410682,30.724664815978713],[0.0,39.76133093832527],[15.814165714106366,30.272831509861135],[27.561831673157485,17.62149893857577],[37.05033110162094,0.0],[28.01366497927438,-17.621498938576224],[15.814165714106366,-30.272831509861362],[0.0,-39.76133093832527],[-15.81416571410682,-30.724664815978713],[-28.013664979274836,-17.169665632458646]],"o":[[37.05033110162094,0.0],[27.561831673157485,-17.169665632458646],[15.814165714106366,-30.724664815978713],[0.0,-39.76133093832527],[-15.81416571410682,-30.272831509861362],[-27.561831673157485,-17.621498938576224],[-37.050331101621396,0.0],[-28.013664979274836,17.62149893857577],[-15.81416571410682,30.272831509861135],[0.0,39.76133093832527],[15.814165714106366,30.724664815978713],[28.01366497927438,17.169665632458646]],"v":[[2961.3052381852963,1447.2924700427038],[3058.223482347464,1421.537971594016],[3123.2874784283604,1349.69647592136],[3147.00872699952,1243.967482289904],[3123.2874784283604,1138.916238617624],[3058.223482347464,1067.074742944968],[2961.3052381852963,1040.6424945371039],[2863.709244063952,1067.074742944968],[2797.96749802388,1138.916238617624],[2774.24624945272,1243.967482289904],[2797.96749802388,1349.69647592136],[2863.709244063952,1421.537971594016]],"c":true}},"nm":"wordmark-5"},{"ty":"fl","c":{"a":0,"k":[1.0,1.0,1.0,1.0]},"o":{"a":0,"k":100},"r":2,"nm":"fill"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"wordmark"}],"ip":109,"op":240,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":1,"nm":"bg","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[1920.0,1080.0,0]},"a":{"a":0,"k":[1920.0,1080.0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"sw":3840,"sh":2160,"sc":"#000000","ip":0,"op":240,"st":0,"bm":0}]} \ No newline at end of file diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/SiloDatabaseMigrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt similarity index 100% rename from android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/SiloDatabaseMigrationTest.kt rename to android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/db/PrairieDatabaseMigrationTest.kt diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt index 07fd65208..62776c304 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt @@ -7,6 +7,11 @@ import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -69,4 +74,56 @@ class RoomCatalogCacheRepositoryTest { scope = null assertNull(repo.getCachedLibraries()) } + + @Test + fun writeStartedBeforeProfileSwitchIsNotAttributedToNewProfile() = runTest { + val snapshotRequested = CompletableDeferred() + val releaseSnapshot = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val delayedRepo = RoomCatalogCacheRepository( + db = db, + snapshotProvider = { + snapshotRequested.complete(Unit) + releaseSnapshot.await() + scope + }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + + val oldProfileWrite = async { + delayedRepo.cacheLibraries(listOf(UserLibrary(id = 1, name = "Profile A", type = "movie"))) + } + snapshotRequested.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + releaseSnapshot.complete(Unit) + oldProfileWrite.await() + + assertNull(delayedRepo.getCachedLibraries()) + } + + @Test + fun writeRequestedByOldProfileButInvokedAfterSwitchIsNotAttributedToNewProfile() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val oldProfileGeneration = identityTransitions.generation.value + val guardedRepo = RoomCatalogCacheRepository( + db = db, + snapshotProvider = { scope }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + + guardedRepo.cacheLibraries( + listOf(UserLibrary(id = 1, name = "Profile A", type = "movie")), + CatalogCacheWriteLease(oldProfileGeneration), + ) + + assertEquals(0L, oldProfileGeneration) + assertNull(guardedRepo.getCachedLibraries()) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt index a9dfe5c46..3f261493b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt @@ -6,6 +6,11 @@ import org.prairieserver.prairie.common.data.db.PrairieDatabase import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -86,4 +91,56 @@ class RoomHomeCacheRepositoryTest { scope = null assertNull(repo.getCachedHome()) } + + @Test + fun writeRequestedByOldProfileButInvokedAfterSwitchIsNotAttributedToNewProfile() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val oldProfileGeneration = identityTransitions.generation.value + val guardedRepo = RoomHomeCacheRepository( + db = db, + snapshotProvider = { scope }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + + guardedRepo.cacheHome( + listOf(section("old", "c1")), + HomeCacheWriteLease(oldProfileGeneration), + ) + + assertEquals(0L, oldProfileGeneration) + assertNull(guardedRepo.getCachedHome()) + } + + @Test + fun profileSwitchDuringHomeScopeResolutionDoesNotAttributeOldWriteToNewProfile() = runTest { + val snapshotRequested = CompletableDeferred() + val releaseSnapshot = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val guardedRepo = RoomHomeCacheRepository( + db = db, + snapshotProvider = { + snapshotRequested.complete(Unit) + releaseSnapshot.await() + scope + }, + identityTransitions = identityTransitions, + now = { 1000L }, + ) + + val oldProfileWrite = async { + guardedRepo.cacheHome(listOf(section("old", "c1"))) + } + snapshotRequested.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + scope = AuthScopeSnapshot("s1", "p2", "https://s1.example", null) + } + releaseSnapshot.complete(Unit) + oldProfileWrite.await() + + assertNull(guardedRepo.getCachedHome()) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournalTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournalTest.kt index 280070ab9..dfaf3f099 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournalTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/BreadcrumbJournalTest.kt @@ -13,6 +13,7 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogLevel import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogLine import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -25,6 +26,7 @@ class BreadcrumbJournalTest { val journal = BreadcrumbJournal( noBackupFilesDir = temporaryFolder.root, writerDispatcher = UnconfinedTestDispatcher(testScheduler), + directorySync = {}, ) val runA = renderedLine("run-a", "foreground") val runB = renderedLine("run-b", "background") @@ -55,6 +57,7 @@ class BreadcrumbJournalTest { val previousJournal = BreadcrumbJournal( noBackupFilesDir = temporaryFolder.root, writerDispatcher = UnconfinedTestDispatcher(testScheduler), + directorySync = {}, ) previousJournal.setEnabled(IDENTITY_A) previousJournal.offer(previous) @@ -62,6 +65,7 @@ class BreadcrumbJournalTest { val journal = BreadcrumbJournal( noBackupFilesDir = temporaryFolder.root, writerDispatcher = UnconfinedTestDispatcher(testScheduler), + directorySync = {}, ) journal.closeGate() @@ -74,6 +78,23 @@ class BreadcrumbJournalTest { assertTrue(journal.linesForRun("previous-run", IDENTITY_A).isEmpty()) } + @Test + fun purgeFailsClosedWhenExistingBreadcrumbDirectoryCannotBeEnumerated() = runTest { + val root = temporaryFolder.newFolder("unreadable") + val directory = root.resolve("client-diagnostics/breadcrumbs") + assertTrue(directory.mkdirs()) + directory.resolve("segment-private-0.jsonl").writeText("private") + val journal = BreadcrumbJournal( + noBackupFilesDir = root, + writerDispatcher = UnconfinedTestDispatcher(testScheduler), + listFiles = { null }, + directorySync = {}, + ) + + assertFailsWith { journal.purge() } + assertTrue(directory.resolve("segment-private-0.jsonl").isFile) + } + private fun renderedLine(run: String, message: String): String = JSON.encodeToString( DiagnosticsLogLine( timestamp = "2026-07-22T00:00:00Z", diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCaptureTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCaptureTest.kt index 64218bd28..8a56ef2d0 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCaptureTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/CrashCaptureTest.kt @@ -1,11 +1,20 @@ package org.prairieserver.prairie.common.diagnostics -import kotlinx.serialization.json.Json +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlinx.coroutines.test.runTest import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.Rule import org.junit.rules.TemporaryFolder +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -76,6 +85,37 @@ class CrashCaptureTest { assertFalse(marker.logLines.any { it.contains("secret-token") }) } + @Test + fun renderedHostedMarkerRoundTripsItsDestinationKindWhileSelfHostedRemainsCompatible() { + val hosted = Json.decodeFromString( + CrashMarkerRenderer().render( + thread = Thread.currentThread(), + throwable = IllegalStateException("hosted crash"), + runtime = runtime().copy( + binding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + ), + occurredAtEpochMs = 1_700_000_000_000, + ).decodeToString(), + ) + val selfHosted = Json.decodeFromString( + CrashMarkerRenderer().render( + thread = Thread.currentThread(), + throwable = IllegalStateException("self-hosted crash"), + runtime = runtime(), + occurredAtEpochMs = 1_700_000_000_000, + ).decodeToString(), + ) + + assertEquals(DiagnosticsDestinationKind.HOSTED, hosted.binding?.destinationKind) + assertEquals(DiagnosticsDestinationKind.SELF_HOSTED, selfHosted.binding?.destinationKind) + } + @Test fun liveRingFromANewerIdentityGenerationIsNotAttachedToTheOldRuntime() { val ring = LogRing() @@ -142,7 +182,14 @@ class CrashCaptureTest { assertTrue(files.single().name.endsWith(".json")) assertFalse(files.single().name.endsWith(".tmp")) assertTrue(files.single().length() in 1..CrashMarkerRenderer.MAX_MARKER_BYTES.toLong()) - val decoded = FileJvmCrashMarkerSource(temporaryFolder.root).records().single() + val decoded = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { 1_700_000_000_000 }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = {}, + listFiles = File::listFiles, + ).records().single() assertEquals("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", decoded.runToken) assertEquals("capture-1", decoded.captureSessionId) } @@ -163,6 +210,253 @@ class CrashCaptureTest { assertTrue(directory.listFiles().orEmpty().isEmpty()) } + @Test + fun destructiveTransitionAbortsWhenAMatchingCrashMarkerCannotBeDeleted() = runTest { + FileCrashMarkerWriter( + noBackupFilesDir = temporaryFolder.root, + nowMs = { 1_700_000_000_000 }, + nanoTime = { 1 }, + ).write(Thread.currentThread(), IllegalStateException("private crash"), runtime()) + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = { false }, + syncDirectory = {}, + listFiles = File::listFiles, + ) + val transitions = DefaultIdentityTransitionBarrier() + transitions.installGate { source.purge(DiagnosticsBinding("server-1", "user-1")) } + var mutationRan = false + + assertFailsWith { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + } + } + + assertFalse(mutationRan) + assertEquals(0, transitions.generation.value) + assertEquals(1, temporaryFolder.root.resolve("client-diagnostics/crash-markers").listFiles().orEmpty().size) + } + + @Test + fun purgeStrictlyRemovesMatchingMalformedAndTemporaryMarkerEvidence() { + FileCrashMarkerWriter( + noBackupFilesDir = temporaryFolder.root, + nowMs = { 1_700_000_000_000 }, + nanoTime = { 1 }, + ).write(Thread.currentThread(), IllegalStateException("private crash"), runtime()) + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + directory.resolve(".jvm-2-2.tmp").writeText("raw temporary private crash") + directory.resolve("jvm-3-3.json").writeText("raw malformed private crash") + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = {}, + listFiles = File::listFiles, + ) + + source.purge(DiagnosticsBinding("server-1", "user-1")) + + assertTrue(directory.listFiles().orEmpty().isEmpty()) + } + + @Test + fun destructiveTransitionAbortsWhenCrashMarkerDirectoryCannotBeEnumerated() = runTest { + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + assertTrue(directory.mkdirs()) + directory.resolve("jvm-3-3.json").writeText("raw private crash") + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = {}, + listFiles = { null }, + ) + val transitions = DefaultIdentityTransitionBarrier() + transitions.installGate { source.purge(DiagnosticsBinding("server-1", "user-1")) } + var mutationRan = false + + assertFailsWith { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + } + } + + assertFalse(mutationRan) + assertTrue(directory.resolve("jvm-3-3.json").exists()) + } + + @Test + fun reconciliationStrictlyPrunesExpiredFutureMalformedTemporaryAndOverCapEvidence() { + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + assertTrue(directory.mkdirs()) + markerFile(directory, NOW - EIGHT_DAYS_MS) + markerFile(directory, NOW + ONE_HOUR_MS) + markerFile(directory, NOW - 10, fileTimestamp = NOW - 9) + directory.resolve("jvm-${NOW - 8}-8.json").writeText("raw malformed private crash") + directory.resolve("jvm-${NOW - 7}-7.json") + .writeBytes(ByteArray(CrashMarkerRenderer.MAX_MARKER_BYTES + 1)) + directory.resolve(".jvm-${NOW - 6}-6.tmp").writeText("raw temporary private crash") + directory.resolve("unexpected-private-evidence").writeText("raw unexpected private crash") + assertTrue(directory.resolve("unexpected-directory").mkdir()) + val retainedTimes = listOf(NOW - 4, NOW - 3, NOW - 2, NOW - 1) + retainedTimes.forEach { markerFile(directory, it) } + var syncs = 0 + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { NOW }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = { syncs += 1 }, + listFiles = File::listFiles, + ) + + val records = source.records() + + assertEquals(retainedTimes.takeLast(3), records.map(JvmCrashMarkerRecord::occurredAtEpochMs)) + assertEquals( + retainedTimes.takeLast(3).map { "jvm-$it-1.json" }, + directory.listFiles().orEmpty().map(File::getName).sorted(), + ) + assertEquals(1, syncs) + } + + @Test + fun expiredMarkerDeletionAndDirectorySyncFailuresFailClosed() { + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + assertTrue(directory.mkdirs()) + val expired = markerFile(directory, NOW - EIGHT_DAYS_MS) + val deletionFailure = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { NOW }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = { false }, + syncDirectory = {}, + listFiles = File::listFiles, + ) + + assertFailsWith { deletionFailure.reconcile() } + assertTrue(expired.exists()) + + val syncFailure = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { NOW }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = { error("fsync failed") }, + listFiles = File::listFiles, + ) + assertFailsWith { syncFailure.reconcile() } + assertFalse(expired.exists()) + } + + @Test + fun markerReconciliationFailsClosedWhenTheOwnedDirectoryCannotBeEnumerated() { + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + assertTrue(directory.mkdirs()) + markerFile(directory, NOW) + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { NOW }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = {}, + listFiles = { null }, + ) + + assertFailsWith { source.reconcile() } + assertTrue(directory.listFiles().orEmpty().isNotEmpty()) + } + + @Test + fun reconciliationRemovesLiveAndDanglingSymbolicLinksWithoutFollowingThem() { + val directory = temporaryFolder.root.resolve("client-diagnostics/crash-markers") + assertTrue(directory.mkdirs()) + val outside = temporaryFolder.root.resolve("outside-private-evidence").apply { + writeText("private evidence outside marker root") + } + val liveLink = directory.resolve("jvm-${NOW}-1.json") + val danglingLink = directory.resolve("dangling-private-evidence") + java.nio.file.Files.createSymbolicLink(liveLink.toPath(), outside.toPath()) + java.nio.file.Files.createSymbolicLink( + danglingLink.toPath(), + temporaryFolder.root.resolve("missing-private-evidence").toPath(), + ) + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + nowMs = { NOW }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = {}, + listFiles = File::listFiles, + ) + + source.reconcile() + + assertTrue(directory.listFiles().orEmpty().isEmpty()) + assertTrue(outside.isFile) + assertEquals("private evidence outside marker root", outside.readText()) + } + + @Test + fun closeAndPurgeWaitForAnInFlightMarkerPublication() { + val gate = JvmCrashMarkerFileGate() + val writer = FileCrashMarkerWriter( + noBackupFilesDir = temporaryFolder.root, + nowMs = { 1_700_000_000_000 }, + nanoTime = { 1 }, + ) + val runtime = AtomicReference(runtime().copy(identityKey = DiagnosticsIdentityKey( + binding = DiagnosticsBinding("server-1", "user-1"), + profileId = "profile-1", + ownershipGeneration = 7, + ))) + val writeEntered = CountDownLatch(1) + val releaseWrite = CountDownLatch(1) + val transitionStarted = CountDownLatch(1) + val transitionFinished = CountDownLatch(1) + val handler = CrashExceptionHandler( + markerSink = CrashMarkerSink { thread, throwable, snapshot -> + writeEntered.countDown() + check(releaseWrite.await(5, TimeUnit.SECONDS)) + writer.write(thread, throwable, snapshot) + }, + runtimeSnapshot = runtime::get, + previous = null, + writeGate = gate, + ) + val source = FileJvmCrashMarkerSource( + noBackupFilesDir = temporaryFolder.root, + fileGate = gate, + deleteFile = File::delete, + syncDirectory = {}, + listFiles = File::listFiles, + ) + val crashThread = thread(name = "diagnostics-crash-test") { + handler.uncaughtException(Thread.currentThread(), IllegalStateException("private crash")) + } + assertTrue(writeEntered.await(5, TimeUnit.SECONDS)) + val transitionThread = thread(name = "diagnostics-transition-test") { + transitionStarted.countDown() + gate.withLock { runtime.set(CrashRuntimeSnapshot.empty()) } + source.purge(DiagnosticsBinding("server-1", "user-1")) + transitionFinished.countDown() + } + assertTrue(transitionStarted.await(5, TimeUnit.SECONDS)) + assertFalse(transitionFinished.await(100, TimeUnit.MILLISECONDS)) + + releaseWrite.countDown() + crashThread.join(5_000) + transitionThread.join(5_000) + + assertFalse(crashThread.isAlive) + assertFalse(transitionThread.isAlive) + assertEquals(CrashRuntimeSnapshot.empty(), runtime.get()) + assertTrue(source.records().isEmpty()) + } + private fun runtime( logs: List = listOf("{\"msg\":\"safe\"}"), deviceSnapshotJson: String? = "{\"captured_at\":\"2026-07-22T00:00:00Z\"}", @@ -179,4 +473,25 @@ class CrashCaptureTest { logGeneration = 7, redactionTokens = listOf("secret-token"), ) + + private fun markerFile( + directory: File, + occurredAtEpochMs: Long, + fileTimestamp: Long = occurredAtEpochMs, + ): File = directory.resolve("jvm-$fileTimestamp-1.json").also { file -> + file.writeBytes( + CrashMarkerRenderer().render( + thread = Thread.currentThread(), + throwable = IllegalStateException("private crash"), + runtime = runtime(), + occurredAtEpochMs = occurredAtEpochMs, + ), + ) + } + + private companion object { + const val NOW = 1_700_000_000_000L + const val ONE_HOUR_MS = 60L * 60 * 1_000 + const val EIGHT_DAYS_MS = 8L * 24 * 60 * 60 * 1_000 + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilderTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilderTest.kt index 972361f92..cacfb7941 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilderTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsBundleBuilderTest.kt @@ -5,12 +5,17 @@ import java.io.File import java.security.MessageDigest import java.util.zip.GZIPInputStream import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Rule import org.junit.rules.TemporaryFolder import org.prairieserver.prairie.model.diagnostics.DiagnosticsArchive import org.prairieserver.prairie.model.diagnostics.DiagnosticsConsent import org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.model.diagnostics.DiagnosticsCrashInfo +import org.prairieserver.prairie.model.diagnostics.DiagnosticsCrashProvenance +import org.prairieserver.prairie.model.diagnostics.DiagnosticsCrashSource import org.prairieserver.prairie.model.diagnostics.DiagnosticsDestination import org.prairieserver.prairie.model.diagnostics.DiagnosticsDeviceSummary import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory @@ -55,10 +60,56 @@ class DiagnosticsBundleBuilderTest { assertEquals(bundle.bytes.size.toLong(), bundle.manifest.archive.bytes) assertEquals(tarBytes.size.toLong(), bundle.manifest.archive.uncompressedBytes) assertEquals(sha256Hex(bundle.bytes), bundle.manifest.archive.sha256) + assertEquals(0xff.toByte(), bundle.bytes[9], "self-hosted gzip origin stays runtime-native") assertFalse(Json.parseToJsonElement(entries.first().bytes.decodeToString()).jsonObject.containsKey("archive")) assertTrue(Json.parseToJsonElement(bundle.manifestBytes.decodeToString()).jsonObject.containsKey("archive")) } + @Test + fun hostedBundleUsesCollectorCanonicalGzipOriginBeforeHashing() { + val bundle = builder.build( + report( + artifacts = mapOf("device.json" to "{}".encodeToByteArray()), + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + redactionTokens = emptyList(), + ) + + assertContentEquals(byteArrayOf(0x1f, 0x8b.toByte()), bundle.bytes.copyOfRange(0, 2)) + assertEquals(0, bundle.bytes[9].toInt()) + assertEquals(sha256Hex(bundle.bytes), bundle.manifest.archive.sha256) + } + + @Test + fun hostedBundlePreservesCanonicalApplicationVersionMetadata() { + val source = report( + artifacts = mapOf("device.json" to "{}".encodeToByteArray()), + destinationKind = DiagnosticsDestinationKind.HOSTED, + ).let { report -> + report.copy( + manifest = report.manifest.copy( + report = report.manifest.report.copy( + appVersion = "0.3.11", + appBuild = "14", + osVersion = "16", + ), + ), + ) + } + + val bundle = builder.build(source, redactionTokens = emptyList()) + val embedded = Json.parseToJsonElement( + bundle.sanitizedEntries.getValue("manifest.json").decodeToString(), + ).jsonObject.getValue("report").jsonObject + + assertEquals("0.3.11", bundle.manifest.report.appVersion) + assertEquals("14", bundle.manifest.report.appBuild) + assertEquals("16", bundle.manifest.report.osVersion) + assertEquals("0.3.11", embedded.getValue("app_version").jsonPrimitive.content) + assertEquals("14", embedded.getValue("app_build").jsonPrimitive.content) + assertEquals("16", embedded.getValue("os_version").jsonPrimitive.content) + } + @Test fun bundleIsDeterministicAndRedactsTextWithoutTouchingBinary() { val secret = "secret-token" @@ -116,6 +167,908 @@ class DiagnosticsBundleBuilderTest { ) } + @Test + fun hostedBundleFiltersLogsAndBreadcrumbsToCollectorV1WithoutChangingSelfHosted() { + val playbackLine = """{"ts":"2026-08-11T00:00:00Z","run":"run-1","lvl":"I","cat":"playback","tag":"Player","msg":"stats playback_session_id=private-playback-correlation","attrs":{"decoder":"c2.android.avc","buffered_ms":1200,"failure_code":"source-private"}}""" + val lifecycleLine = """{"ts":"2026-08-11T00:00:01Z","run":"run-1","lvl":"I","cat":"lifecycle","tag":"Lifecycle","msg":"performance","attrs":{"state":"foreground","p95_frame_ms":22,"startup_first_frame_ms":400}}""" + val focusLine = """{"ts":"2026-08-11T00:00:02Z","run":"run-1","lvl":"I","cat":"focus","tag":"Focus","msg":"moved","attrs":{"target":"send","action":"enter","route":"private-route"}}""" + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "logs.jsonl" to "$playbackLine\n$lifecycleLine\n".encodeToByteArray(), + "breadcrumbs.jsonl" to "$focusLine\n".encodeToByteArray(), + "crash/tombstone.pb" to "opaque-private-native-trace".encodeToByteArray(), + ) + + val hosted = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ) + val hostedEntries = untar(gunzip(hosted.bytes)).associateBy(TarEntry::name) + val hostedLogs = hostedEntries.getValue("logs.jsonl").bytes.decodeToString() + .lineSequence().filter(String::isNotBlank).map { Json.parseToJsonElement(it).jsonObject }.toList() + val hostedBreadcrumb = Json.parseToJsonElement( + hostedEntries.getValue("breadcrumbs.jsonl").bytes.decodeToString().trim(), + ).jsonObject + + assertEquals( + "android-c2-platform-decoder", + hostedLogs[0].getValue("attrs").jsonObject.getValue("decoder").jsonPrimitive.content, + ) + assertFalse(hostedLogs[0].getValue("attrs").jsonObject.containsKey("buffered_ms")) + assertFalse(hostedLogs[0].getValue("attrs").jsonObject.containsKey("failure_code")) + assertFalse(hostedLogs[0].getValue("msg").jsonPrimitive.content.contains("private-playback-correlation")) + assertTrue(hostedLogs[0].getValue("msg").jsonPrimitive.content.contains("[redacted_private_id]")) + assertEquals(setOf("state"), hostedLogs[1].getValue("attrs").jsonObject.keys) + assertEquals(setOf("target", "action"), hostedBreadcrumb.getValue("attrs").jsonObject.keys) + assertFalse(hostedEntries.containsKey("crash/tombstone.pb")) + assertFalse(hosted.manifest.archive.entries.contains("crash/tombstone.pb")) + + val selfHosted = builder.build( + report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ) + val selfHostedEntries = untar(gunzip(selfHosted.bytes)).associateBy(TarEntry::name) + val selfHostedLogs = selfHostedEntries.getValue("logs.jsonl").bytes.decodeToString() + val selfHostedBreadcrumbs = selfHostedEntries.getValue("breadcrumbs.jsonl").bytes.decodeToString() + assertTrue(selfHostedLogs.contains("buffered_ms")) + assertTrue(selfHostedLogs.contains("failure_code")) + assertTrue(selfHostedLogs.contains("private-playback-correlation")) + assertTrue(selfHostedLogs.contains("p95_frame_ms")) + assertTrue(selfHostedBreadcrumbs.contains("private-route")) + assertContentEquals( + "opaque-private-native-trace".encodeToByteArray(), + selfHostedEntries.getValue("crash/tombstone.pb").bytes, + ) + } + + @Test + fun hostedBundleWithholdsPrivatePlaybackAndAttemptAttributesFromCollector() { + // Pins the hosted allowlist in both directions so it cannot silently drift + // from the Apple client's hostedAttributeRegistry. The playback keys that + // describe one user's viewing session (session_id, play_method, reason, + // position_ms) and the network retry counter (attempt) are withheld; the + // rest of the newly registered keys are safe and must survive. lifecycle + // "reason" is a client-side classification, not the playback operator free + // text, so it stays. + val playbackLine = """{"ts":"2026-08-14T00:00:00Z","run":"run-1","lvl":"I","cat":"playback","tag":"Player","msg":"stats","attrs":{"sink":"hdmi","fmt":"hevc","width":3840,"height":2160,"hdr_mode":"hdr10","bitrate_kbps":18000,"dropped_frames":3,"audio_underruns":1,"session_id":"private-server-playback-session","play_method":"transcode","reason":"operator-free-text-stop-reason","position_ms":42500}}""" + val networkLine = """{"ts":"2026-08-14T00:00:01Z","run":"run-1","lvl":"I","cat":"network","tag":"Http","msg":"request","attrs":{"method":"GET","path":"/health","status":503,"duration_ms":120,"outcome":"retried","error_code":"timeout","attempt":4}}""" + val lifecycleLine = """{"ts":"2026-08-14T00:00:02Z","run":"run-1","lvl":"I","cat":"lifecycle","tag":"Lifecycle","msg":"startup","attrs":{"state":"foreground","phase":"first_frame","duration_ms":400,"outcome":"succeeded","reason":"cold_start_classification","launch_type":"cold"}}""" + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "logs.jsonl" to "$playbackLine\n$networkLine\n$lifecycleLine\n".encodeToByteArray(), + ) + + val hostedLogs = untar(gunzip(builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ).bytes)).associateBy(TarEntry::name) + .getValue("logs.jsonl").bytes.decodeToString() + val hostedLines = hostedLogs.lineSequence().filter(String::isNotBlank) + .map { Json.parseToJsonElement(it).jsonObject }.toList() + + assertEquals( + setOf( + "sink", + "fmt", + "width", + "height", + "hdr_mode", + "bitrate_kbps", + "dropped_frames", + "audio_underruns", + ), + hostedLines[0].getValue("attrs").jsonObject.keys, + ) + assertEquals( + setOf("method", "path", "status", "duration_ms", "outcome", "error_code"), + hostedLines[1].getValue("attrs").jsonObject.keys, + ) + assertEquals( + setOf("state", "phase", "duration_ms", "outcome", "reason", "launch_type"), + hostedLines[2].getValue("attrs").jsonObject.keys, + ) + assertEquals( + "cold_start_classification", + hostedLines[2].getValue("attrs").jsonObject.getValue("reason").jsonPrimitive.content, + ) + for (withheld in listOf( + "session_id", + "play_method", + "position_ms", + "attempt", + "private-server-playback-session", + "operator-free-text-stop-reason", + )) { + assertFalse(hostedLogs.contains(withheld), "hosted logs must not carry $withheld: $hostedLogs") + } + + val selfHostedLogs = untar(gunzip(builder.build( + report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ).bytes)).associateBy(TarEntry::name) + .getValue("logs.jsonl").bytes.decodeToString() + for (retained in listOf( + "session_id", + "play_method", + "position_ms", + "attempt", + "private-server-playback-session", + "operator-free-text-stop-reason", + "cold_start_classification", + )) { + assertTrue( + selfHostedLogs.contains(retained), + "self-hosted logs must still carry $retained: $selfHostedLogs", + ) + } + } + + @Test + fun hostedBundleNormalizesDecoderNamesOnLogsBreadcrumbsAndDeviceOnly() { + val decoderFamilies = listOf( + "c2.android.avc.decoder" to "android-c2-platform-decoder", + "c2.vendor.avc.decoder" to "android-c2-vendor-decoder", + "c2.qti.hevc.decoder" to "android-c2-vendor-decoder", + "OMX.google.h264.decoder" to "android-omx-platform-decoder", + "OMX.android.hevc.decoder" to "android-omx-platform-decoder", + "OMX.Nvidia.h264.decode" to "android-omx-vendor-decoder", + "OMX.qcom.video.decoder.avc" to "android-omx-vendor-decoder", + "OMX.vendor.video.decoder.hevc" to "android-omx-vendor-decoder", + "com.example.super.decoder" to "android-decoder", + "android-c2-platform-decoder" to "android-c2-platform-decoder", + "android-c2-vendor-decoder" to "android-c2-vendor-decoder", + "android-omx-platform-decoder" to "android-omx-platform-decoder", + "android-omx-vendor-decoder" to "android-omx-vendor-decoder", + "android-decoder" to "android-decoder", + ) + val logs = decoderFamilies.mapIndexed { index, (raw, _) -> + """{"ts":"2026-08-11T00:00:${index.toString().padStart(2, '0')}Z","run":"run-1","lvl":"I","cat":"playback","tag":"Player","msg":"decoder","attrs":{"decoder":"$raw"}}""" + }.joinToString(separator = "\n", postfix = "\n").encodeToByteArray() + val breadcrumbs = decoderFamilies.mapIndexed { index, (raw, _) -> + """{"ts":"2026-08-11T00:01:${index.toString().padStart(2, '0')}Z","run":"run-1","lvl":"I","cat":"playback","tag":"Breadcrumb","msg":"decoder","attrs":{"decoder":"$raw"}}""" + }.joinToString(separator = "\n", postfix = "\n").encodeToByteArray() + val device = decoderFamilies.mapIndexed { index, (raw, _) -> + """{"codec":"codec-$index","decoder_name":"$raw","hardware":true}""" + }.joinToString(prefix = "{\"video_codecs\":[", separator = ",", postfix = "]}") + .encodeToByteArray() + val artifacts = mapOf( + "device.json" to device, + "logs.jsonl" to logs, + "breadcrumbs.jsonl" to breadcrumbs, + ) + + val hostedEntries = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries + val expected = decoderFamilies.map { (_, family) -> family } + assertEquals( + setOf( + "android-c2-platform-decoder", + "android-c2-vendor-decoder", + "android-omx-platform-decoder", + "android-omx-vendor-decoder", + "android-decoder", + ), + expected.toSet(), + ) + + listOf("logs.jsonl", "breadcrumbs.jsonl").forEach { path -> + val actual = hostedEntries.getValue(path).decodeToString() + .lineSequence() + .filter(String::isNotBlank) + .map { line -> + Json.parseToJsonElement(line).jsonObject + .getValue("attrs").jsonObject + .getValue("decoder").jsonPrimitive.content + } + .toList() + assertEquals(expected, actual, path) + assertTrue(actual.none { '.' in it }, path) + } + val hostedDeviceDecoders = Json.parseToJsonElement( + hostedEntries.getValue("device.json").decodeToString(), + ).jsonObject.getValue("video_codecs").jsonArray.map { codec -> + codec.jsonObject.getValue("decoder_name").jsonPrimitive.content + } + assertEquals(expected, hostedDeviceDecoders) + assertTrue(hostedDeviceDecoders.none { '.' in it }) + + val selfHostedEntries = builder.build( + report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries + artifacts.forEach { (path, bytes) -> + assertContentEquals(bytes, selfHostedEntries.getValue(path), path) + } + } + + @Test + fun hostedBundleCanonicalizesPrivateHostsPathsAndIdentifierAssignmentsInEveryTextField() { + val privateHost = "saved-private-silo.example" + val networkLine = """{"ts":"2026-08-11T00:00:00Z","run":"run-1","lvl":"I","cat":"network","tag":"wss://$privateHost/items/42 planAttemptKey=attempt-private","msg":"host_0123456789abcdef selectedFileId=991 playbackSessionId=session-private","attrs":{"method":"GET","path":"/users/42/items/0123456789abcdef","status":200,"duration_ms":5}}""" + val device = """{"server":"$privateHost","socket":"ws://$privateHost/items/42?token=private","note":"sessionId=session-private trackId=track-private","host_token":"host_fedcba9876543210"}""" + val bundle = builder.build( + report( + artifacts = mapOf( + "device.json" to device.encodeToByteArray(), + "logs.jsonl" to "$networkLine\n".encodeToByteArray(), + ), + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + redactionTokens = listOf(privateHost), + ) + val entries = untar(gunzip(bundle.bytes)).associateBy(TarEntry::name) + val shippedDevice = entries.getValue("device.json").bytes.decodeToString() + val shippedLog = entries.getValue("logs.jsonl").bytes.decodeToString() + val shipped = entries.values.joinToString("\n") { it.bytes.decodeToString() } + + listOf( + privateHost, + "host_0123456789abcdef", + "host_fedcba9876543210", + "attempt-private", + "session-private", + "track-private", + "/items/42", + "/users/42", + ).forEach { leaked -> assertFalse(shipped.contains(leaked), "leaked $leaked in $shipped") } + assertTrue(shipped.contains("wss://redacted.invalid/items/{id}"), shipped) + assertTrue(shipped.contains("ws://redacted.invalid/items/{id}"), shipped) + assertTrue(shipped.contains("[redacted_private_id]"), shipped) + Json.parseToJsonElement(shippedDevice) + shippedLog.lineSequence().filter(String::isNotBlank).forEach { line -> Json.parseToJsonElement(line) } + } + + @Test + fun hostedBundleCanonicalizesLoopbackIdentityAcrossEveryTextSurfaceOnly() { + val device = """{"host":"127.0.0.1","host.name":"LOCALHOST","server_url":"http://127.0.0.2:49152/device/42","server.url":"ws://[::1]:9000/device/42","origin":{"note":"removed"},"safe":{"hostname":"localhost","originUrl":"https://127.0.0.3/origin","base.url":"http://localhost/base","endpoint":"[::1]","address":"127.0.0.4","url":"http://[::1]:9001/device","server_instance_id":"keep","note":"device LOCALHOST 127.0.0.0 127.255.255.255 [::1] ::1 connect http://localhost:8080/items/42 url=http://127.0.0.5/private \"host\":\"127.0.0.12\" 'hostname'='localhost' \"playbackSessionId\":\"device-private-session\""}}""".encodeToByteArray() + val logs = """{"ts":"2026-08-11T00:00:00Z","run":"::1","lvl":"E","cat":"network","tag":"http://127.0.0.2:49152/items/42","msg":"host=127.0.0.1 throwable LOCALHOST peer [::1] ws://[::1]:9000/users/99 server_instance_id=keep \"host\":\"127.0.0.13\" 'hostname'='localhost' \"playbackSessionId\":\"log-private-session\"","attrs":{"method":"GET","path":"/items/42","status":500,"duration_ms":2}}""" + .plus('\n').encodeToByteArray() + val breadcrumbs = """{"ts":"2026-08-11T00:00:01Z","run":"run-1","lvl":"I","cat":"focus","tag":"ws://127.0.0.3:9002/library/42","msg":"server_url='ws://[::1]:9001/items/42' origin=https://example.test/private bare 127.255.254.253 and ::1 \"host\":\"127.0.0.14\" 'hostname'='localhost' 'playbackSessionId'='breadcrumb-private-session'","attrs":{"target":"127.0.0.9","action":"baseUrl=http://localhost:1234/x"}}""" + .plus('\n').encodeToByteArray() + val crashSummary = """{"summary":"endpoint=http://127.0.0.5:8080/x bare localhost \"host\":\"127.0.0.15\" \"playbackSessionId\":\"summary-private-session\"","stack_excerpt":"peer ::1 and [::1] http://127.0.0.6:8080/items/42 'hostname'='localhost' 'playbackSessionId'='excerpt-private-session'","thread":"url='http://localhost:9000/private'"}""" + .encodeToByteArray() + val crashStack = ( + "IllegalStateException: hostname=\"LOCALHOST\" address=[::1] peer 127.0.0.7 ::1 [::1]\n" + + "at ws://[::1]:9000/items/42 endpoint : http://127.0.0.8:8080/private\n" + + "\"host\":\"127.0.0.16\" 'hostname'='localhost' \"playbackSessionId\":\"stack-private-session\"\n" + + "server=redacted.invalid server_instance_id=keep" + ).encodeToByteArray() + val artifacts = mapOf( + "device.json" to device, + "logs.jsonl" to logs, + "crash/summary.json" to crashSummary, + "crash/stack.txt" to crashStack, + "breadcrumbs.jsonl" to breadcrumbs, + ) + fun withLoopbackManifest(report: PendingReport): PendingReport = report.copy( + manifest = report.manifest.copy( + report = report.manifest.report.copy( + captureSessionId = + "\"host\":\"127.0.0.1\" \"playbackSessionId\":\"manifest-private-session\"", + appVersion = "http://localhost:49152/build/42", + appBuild = "127.0.0.10", + osVersion = "peer ::1", + ), + deviceSummary = report.manifest.deviceSummary.copy( + manufacturer = "LOCALHOST", + model = "[::1]", + os = "http://127.0.0.11:8080/os/42", + formFactor = "server=already-safe", + ), + ), + ) + + val hosted = builder.build( + withLoopbackManifest(report(artifacts, DiagnosticsDestinationKind.HOSTED)), + redactionTokens = emptyList(), + ) + val hostedSurfaces = linkedMapOf( + "outer manifest" to hosted.manifestBytes.decodeToString(), + "embedded manifest" to hosted.sanitizedEntries.getValue("manifest.json").decodeToString(), + "device" to hosted.sanitizedEntries.getValue("device.json").decodeToString(), + "logs" to hosted.sanitizedEntries.getValue("logs.jsonl").decodeToString(), + "breadcrumbs" to hosted.sanitizedEntries.getValue("breadcrumbs.jsonl").decodeToString(), + "crash summary" to hosted.sanitizedEntries.getValue("crash/summary.json").decodeToString(), + "crash stack" to hosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString(), + ) + hostedSurfaces.forEach { (name, text) -> + assertFalse(text.contains("localhost", ignoreCase = true), "$name: $text") + assertFalse(text.contains("127."), "$name: $text") + assertFalse(text.contains("::1"), "$name: $text") + assertFalse(text.contains("example.test"), "$name: $text") + assertFalse(text.contains("already-safe"), "$name: $text") + assertFalse(text.contains("private-session"), "$name: $text") + } + val hostedText = hostedSurfaces.values.joinToString("\n") + assertTrue(hostedText.contains("http://redacted.invalid:49152/build/{id}"), hostedText) + assertTrue(hostedText.contains("http://redacted.invalid:49152/items/{id}"), hostedText) + assertTrue(hostedText.contains("ws://redacted.invalid:9000/redacted"), hostedText) + assertTrue(hostedText.contains("ws://redacted.invalid:9002/library/{id}"), hostedText) + assertTrue(hostedText.contains("http://redacted.invalid:8080/items/{id}"), hostedText) + assertFalse(hostedText.contains("server_instance_id=keep"), hostedText) + assertFalse( + Regex( + """(?i)(? + val manifest = Json.parseToJsonElement(hostedSurfaces.getValue(name)).jsonObject + assertEquals( + HOSTED_DIAGNOSTICS_COLLECTOR_ID, + manifest.getValue("destination").jsonObject.getValue("server_instance_id").jsonPrimitive.content, + ) + } + val hostedDevice = Json.parseToJsonElement(hostedSurfaces.getValue("device")).jsonObject + assertEquals(setOf("safe"), hostedDevice.keys) + assertEquals( + setOf("note"), + hostedDevice.getValue("safe").jsonObject.keys, + ) + + val selfHosted = builder.build( + withLoopbackManifest(report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED)), + redactionTokens = emptyList(), + ) + artifacts.forEach { (path, bytes) -> + assertContentEquals(bytes, selfHosted.sanitizedEntries.getValue(path), path) + } + listOf( + selfHosted.manifestBytes.decodeToString(), + selfHosted.sanitizedEntries.getValue("manifest.json").decodeToString(), + ).forEach { manifest -> + val captureSessionId = Json.parseToJsonElement(manifest).jsonObject + .getValue("report").jsonObject + .getValue("capture_session_id").jsonPrimitive.content + assertTrue(captureSessionId.contains("\"host\":\"127.0.0.1\""), manifest) + assertTrue(manifest.contains("http://localhost:49152/build/42"), manifest) + assertTrue(manifest.contains("manifest-private-session"), manifest) + assertFalse(manifest.contains("[redacted_network_identity]"), manifest) + } + } + + @Test + fun hostedBundleNormalizesR8ObfuscatedCrashSymbolsWithoutChangingSelfHostedEvidence() { + val rawStack = ( + "a.b: failure\n" + + " at a.b.c(SourceFile:42)\n" + + "caused by c.d: nested failure\n" + + " at a.b.invokeSuspend(SourceFile:7)\n" + + "java.lang.IllegalStateException: named failure\n" + + " at org.prairieserver.prairie.Player.play(Player.kt:9)\n" + ) + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "logs.jsonl" to ( + """{"ts":"2026-08-11T00:00:00Z","run":"run-1","lvl":"E","cat":"crash","tag":"Crash","msg":"playback failed\na.b: failure\ncaused by c.d: nested failure","attrs":{"fingerprint":"safe","source":"ueh"}}""" + + "\n" + ).encodeToByteArray(), + "crash/summary.json" to + """{"throwable_type":"a.b","stack_excerpt":"a.b: failure\n at a.b.c(SourceFile:42)"}""" + .encodeToByteArray(), + "crash/stack.txt" to rawStack.encodeToByteArray(), + ) + fun withCrashManifest(report: PendingReport): PendingReport = report.copy( + manifest = report.manifest.copy( + report = report.manifest.report.copy(type = DiagnosticsReportType.CRASH), + crash = DiagnosticsCrashInfo( + summary = "a.b", + stackExcerpt = rawStack, + thread = "main", + foreground = true, + source = DiagnosticsCrashSource.UEH, + provenance = DiagnosticsCrashProvenance.PRE_FAILURE, + occurredAt = "2026-08-11T00:00:00Z", + ), + ), + ) + + val hosted = builder.build( + withCrashManifest(report(artifacts, DiagnosticsDestinationKind.HOSTED)), + redactionTokens = emptyList(), + ) + val hostedSurfaces = linkedMapOf( + "outer manifest" to hosted.manifestBytes.decodeToString(), + "embedded manifest" to hosted.sanitizedEntries.getValue("manifest.json").decodeToString(), + "logs" to hosted.sanitizedEntries.getValue("logs.jsonl").decodeToString(), + "crash summary" to hosted.sanitizedEntries.getValue("crash/summary.json").decodeToString(), + "crash stack" to hosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString(), + ) + hostedSurfaces.forEach { (name, text) -> + assertTrue(text.contains("android-obfuscated-error"), "$name: $text") + listOf("a.b:", "c.d:", "at a.b.").forEach { raw -> + assertFalse(text.contains(raw), "$name leaked $raw: $text") + } + } + listOf("outer manifest", "embedded manifest", "crash summary", "crash stack").forEach { name -> + assertTrue( + hostedSurfaces.getValue(name).contains("android-obfuscated-frame"), + "$name: ${hostedSurfaces.getValue(name)}", + ) + } + val hostedStack = hostedSurfaces.getValue("crash stack") + assertTrue(hostedStack.contains("at android-obfuscated-frame(SourceFile:42)"), hostedStack) + assertTrue(hostedStack.contains("at android-obfuscated-frame(SourceFile:7)"), hostedStack) + assertTrue(hostedStack.contains("java.lang.IllegalStateException: named failure"), hostedStack) + assertTrue(hostedStack.contains("at org.prairieserver.prairie.Player.play(Player.kt:9)"), hostedStack) + + val selfHosted = builder.build( + withCrashManifest(report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED)), + redactionTokens = emptyList(), + ) + artifacts.forEach { (path, bytes) -> + assertContentEquals(bytes, selfHosted.sanitizedEntries.getValue(path), path) + } + val selfHostedManifests = listOf( + selfHosted.manifestBytes.decodeToString(), + selfHosted.sanitizedEntries.getValue("manifest.json").decodeToString(), + ) + selfHostedManifests.forEach { manifest -> + assertTrue(manifest.contains("a.b"), manifest) + assertFalse(manifest.contains("android-obfuscated-error"), manifest) + assertFalse(manifest.contains("android-obfuscated-frame"), manifest) + } + } + + @Test + fun hostedBundleRedactsUnsafeCrashStackLinesWithoutDiscardingSafeFrames() { + val rawStack = ( + "java.lang.IllegalStateException: content://private.authority/item/42\n" + + " at a.b.c(SourceFile:42)\n" + + " at java.base/java.lang.Thread.run(Thread.java:840)\n" + + " at app//com.example.Foo.bar(Foo.java:12)\n" + + " at app/my.module@1.0/com.example.Foo.baz(Foo.java:13)\n" + + " at org.prairieserver.prairie.Player.(Player.kt:3)\n" + + " at org.prairieserver.prairie.Player.play(Player.kt:9)\n" + + "caused by java.lang.IllegalArgumentException: nested failure\n" + + "diagnostic source content://private.authority/item/42\n" + ) + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/summary.json" to """{"kind":"jvm_crash"}""".encodeToByteArray(), + "crash/stack.txt" to rawStack.encodeToByteArray(), + ) + fun crashReport(destinationKind: DiagnosticsDestinationKind) = report(artifacts, destinationKind).let { value -> + value.copy( + manifest = value.manifest.copy( + report = value.manifest.report.copy(type = DiagnosticsReportType.CRASH), + crash = DiagnosticsCrashInfo( + summary = "java.lang.IllegalStateException", + stackExcerpt = rawStack, + thread = "main", + foreground = true, + source = DiagnosticsCrashSource.UEH, + provenance = DiagnosticsCrashProvenance.PRE_FAILURE, + occurredAt = "2026-08-11T00:00:00Z", + ), + ), + ) + } + + val hosted = builder.build( + crashReport(DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ) + val hostedStack = hosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString() + val hostedExcerpt = Json.parseToJsonElement(hosted.manifestBytes.decodeToString()).jsonObject + .getValue("crash").jsonObject + .getValue("stack_excerpt").jsonPrimitive.content + + listOf(hostedStack, hostedExcerpt).forEach { text -> + assertTrue(text.lineSequence().any { it == "java.lang.IllegalStateException" }, text) + assertTrue(text.contains("at android-obfuscated-frame(SourceFile:42)"), text) + assertTrue(text.contains("at java.lang.Thread.run(Thread.java:840)"), text) + assertTrue(text.contains("at com.example.Foo.bar(Foo.java:12)"), text) + assertTrue(text.contains("at com.example.Foo.baz(Foo.java:13)"), text) + assertTrue(text.contains("at org.prairieserver.prairie.Player.(Player.kt:3)"), text) + assertTrue(text.contains("at org.prairieserver.prairie.Player.play(Player.kt:9)"), text) + assertTrue(text.lineSequence().any { it == "caused by java.lang.IllegalArgumentException" }, text) + assertTrue(text.contains("[redacted_private_id]"), text) + assertFalse(text.contains("named failure"), text) + assertFalse(text.contains("nested failure"), text) + assertFalse(text.contains("content://"), text) + assertFalse(text.contains("private.authority"), text) + } + + val selfHosted = builder.build( + crashReport(DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ) + assertEquals(rawStack, selfHosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString()) + } + + @Test + fun hostedBundleStillFailsClosedWhenEveryCrashStackLineIsUnsafe() { + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/stack.txt" to ( + "content://private.authority/item/42\n" + + "custom://another.private/source\n" + ).encodeToByteArray(), + ) + + val hosted = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ) + + assertEquals( + "[redacted_private_id]", + hosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString(), + ) + } + + @Test + fun hostedBundleDoesNotLeakPrivateContextSplitAcrossCrashStackLines() { + val privateHost = "private-deployment-host" + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/stack.txt" to ( + "server content://private.authority/item/42\n" + + "java.lang.IllegalStateException: $privateHost\n" + + " at org.prairieserver.prairie.Player.play(Player.kt:9)\n" + ).encodeToByteArray(), + ) + + val hosted = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries.getValue("crash/stack.txt").decodeToString() + + assertTrue(hosted.contains("[redacted_private_id]"), hosted) + assertTrue(hosted.lineSequence().any { it == "java.lang.IllegalStateException" }, hosted) + assertTrue(hosted.contains("at org.prairieserver.prairie.Player.play(Player.kt:9)"), hosted) + assertFalse(hosted.contains("content://"), hosted) + assertFalse(hosted.contains(privateHost), hosted) + } + + @Test + fun hostedBundleBoundsCrashExcerptAfterUnsafeLineReplacementExpandsIt() { + val rawStack = buildString { + repeat(850) { + append("x://\n") + append(" at org.prairieserver.prairie.Player.play(Player.kt:9)\n") + } + }.take(8 * 1_024) + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/stack.txt" to rawStack.encodeToByteArray(), + ) + val report = report(artifacts, DiagnosticsDestinationKind.HOSTED).let { value -> + value.copy( + manifest = value.manifest.copy( + report = value.manifest.report.copy(type = DiagnosticsReportType.CRASH), + crash = DiagnosticsCrashInfo( + summary = "java.lang.IllegalStateException", + stackExcerpt = rawStack, + thread = "main", + foreground = true, + source = DiagnosticsCrashSource.UEH, + provenance = DiagnosticsCrashProvenance.PRE_FAILURE, + occurredAt = "2026-08-11T00:00:00Z", + ), + ), + ) + } + + val hosted = builder.build(report, redactionTokens = emptyList()) + val hostedExcerpt = Json.parseToJsonElement(hosted.manifestBytes.decodeToString()).jsonObject + .getValue("crash").jsonObject + .getValue("stack_excerpt").jsonPrimitive.content + + assertTrue(hostedExcerpt.encodeToByteArray().size <= 8 * 1_024, hostedExcerpt.length.toString()) + assertTrue(hostedExcerpt.contains("at org.prairieserver.prairie.Player.play(Player.kt:9)"), hostedExcerpt) + assertFalse(hostedExcerpt.contains("x://"), hostedExcerpt) + assertTrue( + hostedExcerpt.removeSuffix("\n").lineSequence().all { line -> + line == "[redacted_private_id]" || + line == " at org.prairieserver.prairie.Player.play(Player.kt:9)" + }, + hostedExcerpt.takeLast(80), + ) + } + + @Test + fun hostedBundleRedactsBareAndPrefixedPrivateIdsButPreservesCanonicalCaptureAndRunFields() { + val captureId = "run_0123456789abcdef0123456789abcdef" + val structuredRunId = "run_99999999999999999999999999999999" + val structuredBreadcrumbRunId = "0198a8f8-5678-4abc-8def-0123456789ab" + val freeUuid = "0198a8f8-9999-4abc-8def-0123456789ab" + val privateTokens = listOf( + "ps-1", + "playback_2", + "session-3", + "file_4", + "item-5", + "media_6", + "plan-7", + "attempt_8", + "profile-9", + "account_10", + "user-11", + "device_12", + "content-13", + "library_14", + "request-15", + "req_16", + "correlation-abcdefgh", + "server-17", + "subtitle-18", + "track_19", + "run-20", + ) + val semanticTokens = listOf( + "request_cancelled", + "request_completed", + "session_unavailable", + "playback_unavailable", + "file_not_found", + "plan_invalidated", + "item_count", + ) + val freeText = (listOf("Request", freeUuid) + privateTokens + semanticTokens).joinToString(" ") + val logs = + """{"ts":"2026-08-11T00:00:00Z","run":"$structuredRunId","lvl":"E","cat":"crash","tag":"request-15","msg":"$freeText","attrs":{"fingerprint":"correlation-abcdefgh","source":"file_4"}}""" + + "\n" + val breadcrumbs = + """{"ts":"2026-08-11T00:00:01Z","run":"$structuredBreadcrumbRunId","lvl":"I","cat":"focus","tag":"req_16","msg":"$freeText","attrs":{"target":"user-11","action":"request_cancelled"}}""" + + "\n" + val device = + """{"note":"$freeText","nested":{"request":"request_abcdefgh","url_note":"http://redacted.invalid/items/request-15"}}""" + val crashSummary = """{"summary":"$freeText","stack_excerpt":"Request $freeUuid failed"}""" + val crashStack = "IllegalStateException: $freeText\n at Safe.Frame.method(Source.kt:1)\n" + val artifacts = mapOf( + "device.json" to device.encodeToByteArray(), + "logs.jsonl" to logs.encodeToByteArray(), + "breadcrumbs.jsonl" to breadcrumbs.encodeToByteArray(), + "crash/summary.json" to crashSummary.encodeToByteArray(), + "crash/stack.txt" to crashStack.encodeToByteArray(), + ) + fun withPrivateManifest(report: PendingReport): PendingReport = report.copy( + manifest = report.manifest.copy( + report = report.manifest.report.copy( + captureSessionId = captureId, + appVersion = "request_abcdefgh", + appBuild = "request_cancelled", + osVersion = "Request $freeUuid failed", + ), + deviceSummary = report.manifest.deviceSummary.copy( + manufacturer = "device_12", + model = "request_completed", + ), + ), + ) + + val hosted = builder.build( + withPrivateManifest(report(artifacts, DiagnosticsDestinationKind.HOSTED)), + redactionTokens = emptyList(), + ) + val hostedSurfaces = linkedMapOf( + "outer manifest" to hosted.manifestBytes.decodeToString(), + "embedded manifest" to hosted.sanitizedEntries.getValue("manifest.json").decodeToString(), + "device" to hosted.sanitizedEntries.getValue("device.json").decodeToString(), + "logs" to hosted.sanitizedEntries.getValue("logs.jsonl").decodeToString(), + "breadcrumbs" to hosted.sanitizedEntries.getValue("breadcrumbs.jsonl").decodeToString(), + "crash summary" to hosted.sanitizedEntries.getValue("crash/summary.json").decodeToString(), + "crash stack" to hosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString(), + ) + hostedSurfaces.forEach { (name, text) -> + assertFalse(text.contains(freeUuid), "$name leaked $freeUuid: $text") + privateTokens.forEach { token -> + assertFalse(text.contains(token), "$name leaked $token: $text") + } + assertTrue(text.contains("[redacted_private_id]"), "$name: $text") + } + val outerManifest = Json.parseToJsonElement(hostedSurfaces.getValue("outer manifest")).jsonObject + val embeddedManifest = Json.parseToJsonElement(hostedSurfaces.getValue("embedded manifest")).jsonObject + listOf(outerManifest, embeddedManifest).forEach { manifest -> + val hostedCaptureId = + manifest.getValue("report").jsonObject.getValue("capture_session_id").jsonPrimitive.content + assertTrue(CANONICAL_UUID.matches(hostedCaptureId), hostedCaptureId) + assertFalse(hostedCaptureId.contains(captureId), hostedCaptureId) + } + val hostedLog = Json.parseToJsonElement(hostedSurfaces.getValue("logs").trim()).jsonObject + val hostedBreadcrumb = Json.parseToJsonElement(hostedSurfaces.getValue("breadcrumbs").trim()).jsonObject + assertTrue(CANONICAL_UUID.matches(hostedLog.getValue("run").jsonPrimitive.content)) + assertEquals(structuredBreadcrumbRunId, hostedBreadcrumb.getValue("run").jsonPrimitive.content) + assertTrue(hostedSurfaces.getValue("device").contains("[redacted_private_id]")) + semanticTokens.forEach { token -> + assertTrue(hostedSurfaces.values.any { it.contains(token) }, "missing semantic token $token") + } + + val selfHosted = builder.build( + withPrivateManifest(report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED)), + redactionTokens = emptyList(), + ) + artifacts.forEach { (path, bytes) -> + assertContentEquals(bytes, selfHosted.sanitizedEntries.getValue(path), path) + } + assertTrue(selfHosted.manifestBytes.decodeToString().contains(freeUuid)) + assertTrue(selfHosted.manifestBytes.decodeToString().contains("request_abcdefgh")) + } + + @Test + fun hostedLoopbackNormalizationRequiresLiteralTokenBoundariesAndValidIpv4Octets() { + val nearMisses = listOf( + "mylocalhost", + "127.0.0.256", + "1127.0.0.1", + ) + nearMisses.forEach { value -> + val bundle = builder.build( + report( + artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/stack.txt" to value.encodeToByteArray(), + ), + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + redactionTokens = emptyList(), + ) + assertEquals( + value, + bundle.sanitizedEntries.getValue("crash/stack.txt").decodeToString(), + value, + ) + + val selfHosted = builder.build( + report( + artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/stack.txt" to value.encodeToByteArray(), + ), + destinationKind = DiagnosticsDestinationKind.SELF_HOSTED, + ), + redactionTokens = emptyList(), + ) + assertEquals(value, selfHosted.sanitizedEntries.getValue("crash/stack.txt").decodeToString()) + } + } + + @Test + fun hostedBundleNormalizesBareNetworkAndAndroidPathProseAcrossEveryTextSurface() { + val privateIp = "192.168.1.44" + val privateDns = "silo.home.ArpaServer" + val privatePath = "/data/user/0/org.prairieserver.prairie/files/diagnostics.log" + val compactId = "0123456789abcdef0123456789abcdef" + val transportError = + "java.net.ConnectException: Failed to connect to /$privateIp:8096; " + + "java.net.UnknownHostException: Unable to resolve host \"$privateDns\"; file=$privatePath; " + + "user_id=alice password=hunter2 request_id=req_abcdefgh peer=0x7f000001; " + + "targets 2130706433 017700000001 127.1 127.0x000001; " + + "unicode https://silo。home/users/42; routes /api/v1/items/42 and " + + "/api?token=secretvalue; request $compactId" + val jsonTransportError = transportError.replace("\\", "\\\\").replace("\"", "\\\"") + val logs = + """{"ts":"2026-08-11T00:00:00.123Z","run":"run_0123456789abcdef0123456789abcdef","lvl":"E","cat":"network","tag":"$privateDns","msg":"$jsonTransportError","attrs":{"method":"GET","path":"/items/42","status":503,"duration_ms":5}}""" + .plus('\n').encodeToByteArray() + val crashSummary = + """{"kind":"jvm_crash","summary":"$jsonTransportError","stack_excerpt":"$jsonTransportError"}""" + .encodeToByteArray() + val crashStack = (transportError + "\n at java.net.Socket.connect(Socket.java:42)\n").encodeToByteArray() + val device = + """{"captured_at":"2026-08-11T00:00:00.987654321Z","note":"$jsonTransportError","user_id":"alice","nested":{"password":"hunter2"}}""" + .encodeToByteArray() + val artifacts = mapOf( + "device.json" to device, + "logs.jsonl" to logs, + "crash/summary.json" to crashSummary, + "crash/stack.txt" to crashStack, + ) + fun withNetworkManifest(report: PendingReport) = report.copy( + manifest = report.manifest.copy( + report = report.manifest.report.copy( + capturedAt = "2026-08-11T00:00:00.456Z", + captureSessionId = "run_0123456789abcdef0123456789abcdef", + appVersion = privateDns, + ), + deviceSummary = report.manifest.deviceSummary.copy(model = "peer=$privateIp"), + ), + ) + + val hosted = builder.build( + withNetworkManifest(report(artifacts, DiagnosticsDestinationKind.HOSTED)), + redactionTokens = emptyList(), + ) + val hostedSurfaces = hosted.sanitizedEntries.values.map(ByteArray::decodeToString) + + hosted.manifestBytes.decodeToString() + hostedSurfaces.forEach { text -> + assertFalse(text.contains(privateIp), text) + assertFalse(text.contains(privateDns), text) + assertFalse(text.contains(privatePath), text) + assertFalse(text.contains("user_id"), text) + assertFalse(text.contains("password"), text) + assertFalse(text.contains("request_id"), text) + assertFalse(text.contains("peer="), text) + listOf("2130706433", "017700000001", "127.1", "127.0x000001", "silo。home") + .forEach { value -> assertFalse(text.contains(value), text) } + assertFalse(text.contains("/api"), text) + assertFalse(text.contains(compactId), text) + } + val hostedText = hostedSurfaces.joinToString("\n") + assertTrue(hostedText.contains("[redacted_private_id]"), hostedText) + listOf( + "2026-08-11T00:00:00.123Z", + "2026-08-11T00:00:00.456Z", + "2026-08-11T00:00:00.987654321Z", + ).forEach { timestamp -> assertTrue(hostedText.contains(timestamp), hostedText) } + + val selfHosted = builder.build( + withNetworkManifest(report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED)), + redactionTokens = emptyList(), + ) + artifacts.forEach { (path, bytes) -> + assertContentEquals(bytes, selfHosted.sanitizedEntries.getValue(path), path) + } + assertTrue(selfHosted.manifestBytes.decodeToString().contains(privateDns)) + assertTrue(selfHosted.manifestBytes.decodeToString().contains("peer=$privateIp")) + } + + @Test + fun hostedDeviceSnapshotOmitsDeterministicRouteDeviceAndBuildIdentifiersOnlyForHosted() { + val device = """{"identity":{"manufacturer":"NVIDIA","build_fingerprint_hash":"${"a".repeat(32)}"},"audio":{"route_hashes":["${"b".repeat(32)}"],"outputs":[{"type":"hdmi","id":"${"c".repeat(32)}","address":"${"d".repeat(32)}"}]}}""" + val artifacts = mapOf("device.json" to device.encodeToByteArray()) + + val hostedDevice = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries.getValue("device.json").decodeToString() + val selfHostedDevice = builder.build( + report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries.getValue("device.json").decodeToString() + + listOf( + "build_fingerprint_hash", + "route_hashes", + "\"id\"", + "\"address\"", + "a".repeat(32), + "b".repeat(32), + "c".repeat(32), + "d".repeat(32), + ) + .forEach { value -> assertFalse(hostedDevice.contains(value), hostedDevice) } + assertTrue(selfHostedDevice.contains("build_fingerprint_hash"), selfHostedDevice) + assertTrue(selfHostedDevice.contains("route_hashes"), selfHostedDevice) + assertTrue(selfHostedDevice.contains("\"id\""), selfHostedDevice) + assertTrue(selfHostedDevice.contains("\"address\""), selfHostedDevice) + } + + @Test + fun hostedCrashSummaryOmitsProcessIdentityOnlyForHosted() { + val processHash = "e".repeat(32) + val summary = + """{"kind":"native_crash","process_hash":"$processHash","pid":42,"status":6}""" + val artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "crash/summary.json" to summary.encodeToByteArray(), + ) + + val hostedSummary = builder.build( + report(artifacts, DiagnosticsDestinationKind.HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries.getValue("crash/summary.json").decodeToString() + val selfHostedSummary = builder.build( + report(artifacts, DiagnosticsDestinationKind.SELF_HOSTED), + redactionTokens = emptyList(), + ).sanitizedEntries.getValue("crash/summary.json").decodeToString() + + assertFalse(hostedSummary.contains("process_hash"), hostedSummary) + assertFalse(hostedSummary.contains(processHash), hostedSummary) + assertTrue(hostedSummary.contains("\"kind\":\"native_crash\""), hostedSummary) + assertEquals(summary, selfHostedSummary) + } + @Test fun invalidUtf8TextIsReplacedByRedactionFailureSentinel() { val report = report( @@ -133,7 +1086,10 @@ class DiagnosticsBundleBuilderTest { ) } - private fun report(artifacts: Map): PendingReport { + private fun report( + artifacts: Map, + destinationKind: DiagnosticsDestinationKind = DiagnosticsDestinationKind.SELF_HOSTED, + ): PendingReport { val directory = temporaryFolder.newFolder() artifacts.forEach { (path, bytes) -> directory.resolve(path).also { file -> @@ -141,11 +1097,28 @@ class DiagnosticsBundleBuilderTest { file.writeBytes(bytes) } } + val reportManifest = manifest().let { value -> + if (destinationKind == DiagnosticsDestinationKind.HOSTED) { + value.copy( + report = value.report.copy(profileId = null), + destination = DiagnosticsDestination(HOSTED_DIAGNOSTICS_COLLECTOR_ID), + playbackSessionIds = emptyList(), + ) + } else { + value + } + } return PendingReport( id = "a".repeat(32), directory = directory, - binding = PendingReportBinding("server-1", "user-1", "profile-1", 7), - manifest = manifest(), + binding = PendingReportBinding( + "server-1", + "user-1", + "profile-1", + 7, + destinationKind, + ), + manifest = reportManifest, state = PendingReportState( capturedAtEpochMs = 1, fingerprint = "fingerprint", @@ -210,5 +1183,8 @@ class DiagnosticsBundleBuilderTest { private companion object { const val TAR_BLOCK_SIZE = 512 + val CANONICAL_UUID = Regex( + """(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$""", + ) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinatorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinatorTest.kt index c28045c73..cd088a1a1 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinatorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsCoordinatorTest.kt @@ -1,11 +1,19 @@ package org.prairieserver.prairie.common.diagnostics +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory import java.io.File +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -23,10 +31,13 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsReport import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.IdentityTransitionTarget import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -58,12 +69,931 @@ class DiagnosticsCoordinatorTest { assertEquals(TimedCaptureStatus.INVALIDATED, fixture.coordinator.state.value.timedCapture.status) assertTrue(capture.cancelled.isNotEmpty()) + assertTrue(capture.persistentBreadcrumbsEnabled, "the new identity may enable fresh evidence") + } + + @Test + fun neverClosesCaptureAndPurgesAllBindingEvidence() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val fixture = fixture( + identity, + transitions, + capture, + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.coordinator.startTimedCapture() + fixture.evidence.add(ADULT_A.binding) + + fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) + + assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + assertFalse(capture.hasPersistentEvidence) + assertFalse(ADULT_A.binding in fixture.evidence) + assertEquals(listOf(ADULT_A.binding), fixture.purgedBindings) + + fixture.coordinator.captureNow() + assertEquals(1, capture.captureNowCalls) assertFalse(capture.hasPersistentEvidence) } @Test - fun neverClosesCaptureAndPurgesAllBindingEvidence() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun manualCaptureFailsClosedWhenLiveDestinationAttestationFails() = runTest { + val identity = MutableIdentityResolver(ADULT_A).apply { captureAttestationAllowed = false } + val capture = RecordingCaptureController() + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + capture, + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + + assertNull(fixture.coordinator.captureNow()) + fixture.coordinator.startTimedCapture() + + assertEquals(2, identity.captureAttestationCalls) + assertEquals(0, capture.captureNowCalls) + assertEquals(TimedCaptureStatus.IDLE, fixture.coordinator.state.value.timedCapture.status) + } + + @Test + fun promptAggregatesAccountReportsAcrossProfilesAndTheyRemainVisibleOffline() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + Dispatchers.Unconfined, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.reports.save(reportCapture(ADULT_A, "first")) + fixture.reports.save(reportCapture(ADULT_B, "second")) + + fixture.coordinator.refresh() + + assertEquals(2, fixture.coordinator.state.value.pending.size) + assertEquals(2, fixture.coordinator.state.value.prompt?.reportCount) + + identity.current = null + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) + assertEquals(2, fixture.coordinator.state.value.pending.size) + assertEquals(null, fixture.coordinator.state.value.prompt) + } + + @Test + fun unresolvedProfileSwitchCannotRestoreAnAdultOfflineCache() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val fixture = fixture( + identity, + transitions, + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.reports.save(reportCapture(ADULT_A, "adult")) + + transitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + identity.current = null + } + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) + assertFalse(fixture.coordinator.state.value.profileEligible) + assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + } + + @Test + fun confirmedIneligibleProfileClearsThePreviousAdultOfflineCache() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.reports.save(reportCapture(ADULT_A, "adult")) + + identity.current = ADULT_A.copy(profileEligible = false) + fixture.coordinator.refresh() + identity.current = null + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) + assertFalse(fixture.coordinator.state.value.profileEligible) + assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + } + + @Test + fun offlineCacheIsHiddenWhenLocalIdentityCannotBeAttested() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.reports.save(reportCapture(ADULT_A, "adult")) + + identity.current = null + identity.trustCachedIdentity = false + fixture.coordinator.refresh() + + assertFalse(fixture.coordinator.state.value.profileEligible) + assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + } + + @Test + fun promptUploadRejectsANoticeVersionDifferentFromTheApprovedBatch() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(ADULT_A, "adult")) + + val decision = fixture.coordinator.upload(report.id, expectedNoticeVersion = ADULT_A.noticeVersion + 1) + + assertEquals(DiagnosticsUploadDecision.KeptConsentReviewRequired, decision) + assertNotNull(fixture.reports.load(report.id)) + } + + @Test + fun stalePromptCannotGrantAlwaysForADifferentNoticeVersion() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + + fixture.coordinator.setConsent( + DiagnosticsConsentMode.ALWAYS, + expectedNoticeVersion = ADULT_A.noticeVersion + 1, + ) + + assertEquals(DiagnosticsConsentMode.ASK, fixture.coordinator.state.value.consent) + } + + @Test + fun hostedDestinationNeverEnablesAutomaticCrashUploads() = runTest { + val hosted = ADULT_A.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device"), + profileId = null, + sourceProfileId = ADULT_A.profileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + ) + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + + fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) + fixture.reports.save(reportCapture(hosted, "hosted")) + fixture.coordinator.refresh() + + assertEquals(DiagnosticsConsentMode.ASK, fixture.coordinator.state.value.consent) + assertFalse(fixture.coordinator.state.value.allowsAutomaticUpload) + assertEquals(HOSTED_DIAGNOSTICS_RETENTION_DAYS, fixture.coordinator.state.value.retentionDays) + assertEquals( + CAPTURED_AT + PENDING_DIAGNOSTICS_RETENTION_DAYS * 24L * 60 * 60 * 1_000, + fixture.coordinator.state.value.pending.single().expiresAtEpochMs, + "local pending evidence expires after seven days even though uploaded reports disclose 30-day retention", + ) + } + + @Test + fun cachedHostedCapabilitiesCannotOpenPersistentCaptureGates() = runTest { + val identity = MutableIdentityResolver(hostedContext()).apply { + captureAttestationAllowed = false + } + val capture = RecordingCaptureController() + val runtime = RecordingRuntimePublisher() + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + capture, + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + runtimePublisher = runtime, + ) + + fixture.coordinator.start() + fixture.coordinator.refresh() + + assertTrue(identity.captureAttestationCalls > 0) + assertFalse(runtime.live) + assertFalse(capture.debugLoggingEnabled) + assertFalse(capture.persistentBreadcrumbsEnabled) + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + } + + @Test + fun hostedProcessingSchedulesStatusPollingWithoutReportingFailure() = runTest { + val hosted = hostedContext() + val scheduled = mutableListOf() + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { DiagnosticsUploader { DiagnosticsUploadDecision.HostedProcessing("ABC123") } }, + uploadScheduler = DiagnosticsUploadScheduler(scheduled::add), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-processing")) + + val decision = fixture.coordinator.upload(report.id) + + assertEquals(DiagnosticsUploadDecision.HostedProcessing("ABC123"), decision) + assertEquals(listOf(report.id), scheduled) + assertNotNull(fixture.reports.load(report.id)) + } + + @Test + fun hostedDeletePersistsErasureIntentAndRetriesAfterAnAmbiguousFailure() = runTest { + val hosted = ADULT_A.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device"), + profileId = null, + sourceProfileId = ADULT_A.profileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + ) + val deleter = RecordingHostedReportDeleter(result = false) + var scheduledDeletionRetries = 0 + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + hostedReportDeleter = deleter, + hostedDeletionScheduler = HostedDiagnosticsDeletionScheduler { scheduledDeletionRetries += 1 }, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-delete")) + val bundle = FileDiagnosticsBundleBuilder().build(report, redactionTokens = emptyList()) + fixture.reports.saveHostedEnvelope(report.id, bundle) + + assertTrue(fixture.coordinator.delete(report.id)) + + assertEquals(null, fixture.reports.load(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + assertEquals(listOf(report.id), deleter.reportIds) + assertTrue(scheduledDeletionRetries > 0) + assertTrue(fixture.coordinator.state.value.pending.none { it.id == report.id }) + + deleter.result = true + fixture.coordinator.refresh() + + assertTrue(fixture.reports.hostedDeletionIntents().isEmpty()) + assertEquals(listOf(report.id, report.id), deleter.reportIds) + } + + @Test + fun selfHostedDeleteRemainsLocalOnly() = runTest { + val deleter = RecordingHostedReportDeleter(result = false) + val selfHosted = ADULT_A.copy(destinationKind = DiagnosticsDestinationKind.SELF_HOSTED) + val fixture = fixture( + MutableIdentityResolver(selfHosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + hostedReportDeleter = deleter, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(ADULT_A, "self-hosted-delete")) + + assertTrue(fixture.coordinator.delete(report.id)) + + assertEquals(null, fixture.reports.load(report.id)) + assertTrue(deleter.reportIds.isEmpty()) + } + + @Test + fun turnOffStagesHostedErasureBeforePurgingLocalEvidence() = runTest { + val hosted = ADULT_A.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device"), + profileId = null, + sourceProfileId = ADULT_A.profileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + ) + val deleter = RecordingHostedReportDeleter(result = false) + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + hostedReportDeleter = deleter, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-turn-off")) + val bundle = FileDiagnosticsBundleBuilder().build(report, redactionTokens = emptyList()) + fixture.reports.saveHostedEnvelope(report.id, bundle) + + fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) + + assertEquals(null, fixture.reports.load(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + assertEquals(listOf(report.id), deleter.reportIds) + assertEquals(listOf(hosted.binding), fixture.purgedBindings) + assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + } + + @Test + fun failedTurnOffStaysClosedAndRefreshRetriesDurableLocalAndRemoteErasure() = runTest { + val hosted = hostedContext() + var failPurge = true + val capture = RecordingCaptureController() + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + capture, + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + purgeFailure = { + if (failPurge) IllegalStateException("injected Turn Off purge failure") else null + }, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-never-retry")) + fixture.reports.markHostedProcessing(report.id, "ABC123") + + assertFailsWith { + fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) + } + + assertEquals(DiagnosticsConsentMode.NEVER, fixture.settings.consent(hosted.binding, hosted.noticeVersion).mode) + assertEquals(listOf(hosted.binding), fixture.settings.pendingErasureBindings()) + assertNotNull(fixture.reports.load(report.id)) + assertTrue(capture.gateClosed) + + failPurge = false + fixture.coordinator.refresh() + + assertTrue(fixture.settings.pendingErasureBindings().isEmpty()) + assertNull(fixture.reports.load(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + } + + @Test + fun pendingTurnOffErasureRetriesBeforeAResolvedChildProfileReturnsIneligible() = runTest { + val hosted = hostedContext() + val identity = MutableIdentityResolver(hosted) + val transitions = DefaultIdentityTransitionBarrier() + var failPurge = true + val fixture = fixture( + identity, + transitions, + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + purgeFailure = { + if (failPurge) IllegalStateException("injected Turn Off purge failure") else null + }, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-never-child-retry")) + fixture.reports.markHostedProcessing(report.id, "ABC123") + + assertFailsWith { + fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) + } + assertEquals(listOf(hosted.binding), fixture.settings.pendingErasureBindings()) + + failPurge = false + transitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + identity.current = hosted.copy(profileEligible = false, ownershipGeneration = 1) + } + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.INELIGIBLE, fixture.coordinator.state.value.availability) + assertTrue(fixture.settings.pendingErasureBindings().isEmpty()) + assertNull(fixture.reports.load(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + } + + @Test + fun explicitDeleteReturnsFailureUntilHostedEvidenceIsPhysicallyAbsent() = runTest { + val hosted = hostedContext() + var blockedId: String? = null + var failDeletion = false + val deletionCalls = mutableListOf() + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + reportsFactory = { files -> + FilePendingReportStore( + noBackupFilesDir = files, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + deleteRecursively = { file -> + deletionCalls += file.name + if (failDeletion && file.name == blockedId) { + false + } else { + file.deleteRecursively() + } + }, + ) + }, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-delete-partial")) + fixture.coordinator.refresh() + blockedId = report.id + failDeletion = true + + assertFalse(fixture.coordinator.delete(report.id)) + + assertTrue(report.directory.resolve("device.json").isFile, deletionCalls.toString()) + assertTrue( + report.directory.parentFile.parentFile + .resolve("hosted-deletion-intents.json") + .readText() + .contains(report.id), + ) + assertTrue(fixture.coordinator.state.value.pending.any { it.id == report.id }) + } + + @Test + fun startupRefreshRetriesPersistedHostedErasureIntents() = runTest { + val hosted = ADULT_A.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device"), + profileId = null, + sourceProfileId = ADULT_A.profileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + ) + val deleter = RecordingHostedReportDeleter(result = true) + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + hostedReportDeleter = deleter, + ) + val report = fixture.reports.save(reportCapture(hosted, "hosted-startup-delete")) + fixture.reports.markHostedProcessing(report.id, "ABC123") + val interruptedCopy = temporaryFolder.newFolder("hosted-startup-delete-copy") + report.directory.copyRecursively(interruptedCopy, overwrite = true) + fixture.reports.stageHostedDeletionAndDelete(report.id) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + interruptedCopy.copyRecursively(report.directory, overwrite = true) + assertTrue(report.directory.isDirectory) + + fixture.coordinator.start() + runCurrent() + + assertFalse(report.directory.exists()) + assertTrue(fixture.reports.hostedDeletionIntents().isEmpty()) + assertEquals(listOf(report.id), deleter.reportIds) + } + + @Test + fun hostedErasureNetworkWaitDoesNotBlockCoordinatorRefresh() = runTest { + val hosted = hostedContext() + val deletionStarted = CompletableDeferred() + val releaseDeletion = CompletableDeferred() + val deleter = HostedDiagnosticsReportDeleter { + deletionStarted.complete(Unit) + releaseDeletion.await() + true + } + val dispatcher = StandardTestDispatcher(testScheduler) + val identity = MutableIdentityResolver(hosted) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + dispatcher, + hostedReportDeleter = deleter, + ) + val report = fixture.reports.save(reportCapture(hosted, "hosted-non-blocking-delete")) + fixture.reports.markHostedProcessing(report.id, "ABC123") + fixture.reports.stageHostedDeletionAndDelete(report.id) + + fixture.coordinator.start() + runCurrent() + assertTrue(deletionStarted.isCompleted) + + identity.current = null + val refresh = async(UnconfinedTestDispatcher(testScheduler)) { fixture.coordinator.refresh() } + runCurrent() + assertEquals( + DiagnosticsAvailabilityUi.OFFLINE, + fixture.coordinator.state.value.availability, + "remote DELETE polling must run outside the coordinator actor", + ) + + refresh.cancel() + releaseDeletion.complete(Unit) + runCurrent() + assertTrue(fixture.reports.hostedDeletionIntents().isEmpty()) + } + + @Test + fun queuedDeleteAfterHostedReadyStagesReceiptAndEventuallyErasesRemoteReport() = runTest { + val hosted = hostedContext() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + val deleter = RecordingHostedReportDeleter(result = false) + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + val report = checkNotNull(reports.load(reportId)) + uploadStarted.complete(Unit) + releaseUpload.await() + reports.recordHostedReadyAndDelete(reportId, report.binding) + DiagnosticsUploadDecision.Uploaded("ABC123") + } + }, + hostedReportDeleter = deleter, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-ready-delete-race")) + + val upload = async { fixture.coordinator.upload(report.id) } + uploadStarted.await() + val deletion = async { fixture.coordinator.delete(report.id) } + assertTrue(deleter.reportIds.isEmpty(), "Delete must remain queued while the manual upload owns the actor") + releaseUpload.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.Uploaded("ABC123"), upload.await()) + assertTrue(deletion.await()) + assertNull(fixture.reports.load(report.id)) + assertEquals(hosted.binding, fixture.reports.hostedReadyBinding(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + assertEquals(listOf(report.id), deleter.reportIds) + + deleter.result = true + fixture.coordinator.refresh() + + assertTrue(fixture.reports.hostedDeletionIntents().isEmpty()) + assertNull(fixture.reports.hostedReadyBinding(report.id)) + assertEquals(listOf(report.id, report.id), deleter.reportIds) + assertEquals(DiagnosticsUploadDecision.KeptInvalid, fixture.coordinator.upload(report.id)) + } + + @Test + fun queuedTurnOffAfterHostedReadyStagesReceiptAndEventuallyErasesRemoteReport() = runTest { + val hosted = hostedContext() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + val deleter = RecordingHostedReportDeleter(result = false) + val fixture = fixture( + MutableIdentityResolver(hosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + val report = checkNotNull(reports.load(reportId)) + uploadStarted.complete(Unit) + releaseUpload.await() + reports.recordHostedReadyAndDelete(reportId, report.binding) + DiagnosticsUploadDecision.Uploaded("ABC123") + } + }, + hostedReportDeleter = deleter, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + val report = fixture.reports.save(reportCapture(hosted, "hosted-ready-never-race")) + + val upload = async { fixture.coordinator.upload(report.id) } + uploadStarted.await() + val turnOff = async { fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) } + assertTrue(deleter.reportIds.isEmpty(), "Turn Off must remain queued while the manual upload owns the actor") + releaseUpload.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.Uploaded("ABC123"), upload.await()) + turnOff.await() + assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + assertNull(fixture.reports.load(report.id)) + assertEquals(hosted.binding, fixture.reports.hostedReadyBinding(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + assertEquals(listOf(report.id), deleter.reportIds) + assertEquals(listOf(hosted.binding), fixture.purgedBindings) + + deleter.result = true + fixture.coordinator.refresh() + + assertTrue(fixture.reports.hostedDeletionIntents().isEmpty()) + assertNull(fixture.reports.hostedReadyBinding(report.id)) + assertEquals(listOf(report.id, report.id), deleter.reportIds) + } + + @Test + fun automaticUploadAndTurnOffShareTheCoordinatorPrivacyBoundary() = runTest { + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var uploadCalls = 0 + val selfHosted = ADULT_A.copy(destinationKind = DiagnosticsDestinationKind.SELF_HOSTED) + val fixture = fixture( + MutableIdentityResolver(selfHosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + uploadStarted.complete(Unit) + releaseUpload.await() + uploadCalls += 1 + reports.delete(reportId) + DiagnosticsUploadDecision.Uploaded("ABC123") + } + }, + ) + fixture.settings.setDestinationKind(DiagnosticsDestinationKind.SELF_HOSTED) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) + val report = fixture.reports.save(reportCapture(selfHosted, "worker-never-race")) + + val upload = async { fixture.coordinator.uploadAutomatically(report.id) } + uploadStarted.await() + val turnOff = async { fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) } + runCurrent() + assertFalse(turnOff.isCompleted, "Turn Off must wait for transport that already won the actor boundary") + releaseUpload.complete(Unit) + assertEquals(DiagnosticsUploadDecision.Uploaded("ABC123"), upload.await()) + turnOff.await() + assertEquals(1, uploadCalls) + assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + assertNull(fixture.reports.load(report.id)) + + val later = fixture.coordinator.uploadAutomatically(report.id) + assertEquals(DiagnosticsUploadDecision.KeptInvalid, later) + assertEquals(1, uploadCalls, "no transport may start after Turn Off returned") + } + + @Test + fun destinationChangeThatWinsBeforeAutomaticUploadPreventsTransport() = runTest { + var uploadCalls = 0 + val selfHosted = ADULT_A.copy(destinationKind = DiagnosticsDestinationKind.SELF_HOSTED) + val fixture = fixture( + MutableIdentityResolver(selfHosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { + DiagnosticsUploader { + uploadCalls += 1 + DiagnosticsUploadDecision.Uploaded("ABC123") + } + }, + ) + fixture.settings.setDestinationKind(DiagnosticsDestinationKind.SELF_HOSTED) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) + val report = fixture.reports.save( + reportCapture(selfHosted, "destination-race"), + ) + + fixture.coordinator.setDestination(DiagnosticsDestinationKind.HOSTED) + val decision = fixture.coordinator.uploadAutomatically(report.id) + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, decision) + assertEquals(0, uploadCalls) + } + + @Test + fun concurrentManualAndWorkerUploadCannotPostTheSameReportTwice() = runTest { + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var uploadCalls = 0 + val selfHosted = ADULT_A.copy(destinationKind = DiagnosticsDestinationKind.SELF_HOSTED) + val fixture = fixture( + MutableIdentityResolver(selfHosted), + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + uploadStarted.complete(Unit) + releaseUpload.await() + uploadCalls += 1 + reports.delete(reportId) + DiagnosticsUploadDecision.Uploaded("ABC123") + } + }, + ) + fixture.settings.setDestinationKind(DiagnosticsDestinationKind.SELF_HOSTED) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) + val report = fixture.reports.save( + reportCapture(selfHosted, "manual-worker-dedup"), + ) + + val manual = async { fixture.coordinator.upload(report.id) } + uploadStarted.await() + val worker = async { fixture.coordinator.uploadAutomatically(report.id) } + runCurrent() + releaseUpload.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.Uploaded("ABC123"), manual.await()) + assertEquals(DiagnosticsUploadDecision.KeptInvalid, worker.await()) + assertEquals(1, uploadCalls) + } + + @Test + fun offlineStatePreservesTheCachedConsentChoice() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val fixture = fixture( + identity, + DefaultIdentityTransitionBarrier(), + RecordingCaptureController(), + backgroundScope, + UnconfinedTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + + fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) + identity.current = null + fixture.coordinator.refresh() + assertEquals(DiagnosticsConsentMode.ALWAYS, fixture.coordinator.state.value.consent) + + identity.current = ADULT_A + fixture.coordinator.refresh() + fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) + identity.current = null + fixture.coordinator.refresh() + assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + } + + @Test + fun signOutPurgesTheOldBindingAfterTheSynchronousGateCloses() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val fixture = fixture( + identity, + transitions, + capture, + backgroundScope, + StandardTestDispatcher(testScheduler), + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.evidence.add(ADULT_A.binding) + + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + assertTrue(capture.gateClosed) + assertFalse(ADULT_A.binding in fixture.evidence) + assertEquals(listOf(ADULT_A.binding), fixture.purgedBindings) + identity.current = null + } + fixture.coordinator.refresh() + + assertFalse(ADULT_A.binding in fixture.evidence) + assertEquals(listOf(ADULT_A.binding), fixture.purgedBindings) + } + + @Test + fun failedSynchronousSignOutPurgeAbortsMutationAndRetriesAfterCoordinatorReconstruction() = runTest { + val hosted = hostedContext().copy(localServerId = "server-a", ownershipGeneration = 0) + val identity = MutableIdentityResolver(hosted) + val transitions = DefaultIdentityTransitionBarrier() + var failNextPurge = true + val fixture = fixture( + identity, + transitions, + RecordingCaptureController(), + backgroundScope, + StandardTestDispatcher(testScheduler), + purgeFailure = { + if (failNextPurge) { + failNextPurge = false + IllegalStateException("injected purge failure") + } else { + null + } + }, + ) + fixture.coordinator.start() + fixture.coordinator.refresh() + fixture.evidence += hosted.binding + val report = fixture.reports.save(reportCapture(hosted, "failed-sign-out")) + fixture.reports.markHostedProcessing(report.id, "REMOTE1") + var mutationRan = false + + assertFailsWith { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + identity.current = null + } + } + + assertFalse(mutationRan) + assertEquals(0, transitions.generation.value) + assertNotNull(fixture.reports.load(report.id)) + assertTrue(hosted.binding in fixture.settings.bindingsForLocalServer("server-a")) + assertNull(fixture.settings.cachedContext(), "settings deletion may precede the failed evidence purge") + + val reconstructedTransitions = DefaultIdentityTransitionBarrier() + val reconstructed = DefaultDiagnosticsCoordinator( + scope = backgroundScope, + actorDispatcher = StandardTestDispatcher(testScheduler), + identity = identity, + identityTransitions = reconstructedTransitions, + settings = fixture.settings, + reports = fixture.reports, + capture = RecordingCaptureController(), + uploader = DiagnosticsUploader { DiagnosticsUploadDecision.KeptUnavailable }, + uploadScheduler = DiagnosticsUploadScheduler { }, + ) + reconstructed.start() + reconstructed.refresh() + reconstructedTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + assertNull(fixture.reports.load(report.id)) + assertEquals(listOf(report.id), fixture.reports.hostedDeletionIntents()) + identity.current = null + } + + assertEquals(1, reconstructedTransitions.generation.value) + assertFalse(hosted.binding in fixture.evidence) + assertTrue(fixture.settings.bindingsForLocalServer("server-a").isEmpty()) + } + + @Test + fun removingInactiveServerUsesTheDurableBindingIndexWithoutPurgingActiveEvidence() = runTest { + val activeA = hostedContext().copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-a"), + localServerId = "server-a", + ownershipGeneration = 0, + ) + val inactiveB = activeA.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-b"), + localServerId = "server-b", + ) + val identity = MutableIdentityResolver(activeA) val transitions = DefaultIdentityTransitionBarrier() val capture = RecordingCaptureController() val fixture = fixture( @@ -71,215 +1001,589 @@ class DiagnosticsCoordinatorTest { transitions, capture, backgroundScope, - UnconfinedTestDispatcher(testScheduler), + StandardTestDispatcher(testScheduler), ) + // Persist both scopes before constructing the coordinator mirror, as after + // a process restart only this index can identify an inactive server's + // one-way hosted binding. + fixture.settings.cacheContext(inactiveB) + fixture.settings.cacheContext(activeA) + fixture.evidence += setOf(activeA.binding, inactiveB.binding) + val activeReport = fixture.reports.save(reportCapture(activeA, "active-a")) + fixture.reports.markHostedProcessing(activeReport.id, "ACTIVE1") + val pendingB = fixture.reports.save(reportCapture(inactiveB, "pending-b")) + fixture.reports.markHostedProcessing(pendingB.id, "REMOTE2") + val readyB = fixture.reports.save(reportCapture(inactiveB, "ready-b")) + fixture.reports.recordHostedReadyAndDelete(readyB.id, readyB.binding) fixture.coordinator.start() fixture.coordinator.refresh() - fixture.coordinator.startTimedCapture() - fixture.evidence.add(ADULT_A.binding) - - fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) - assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) - assertFalse(capture.hasPersistentEvidence) - assertFalse(ADULT_A.binding in fixture.evidence) - assertEquals(listOf(ADULT_A.binding), fixture.purgedBindings) + transitions.changing( + kind = IdentityTransitionKind.SERVER_REMOVE, + target = { + IdentityTransitionTarget( + serverId = "server-b", + affectsCurrentIdentity = false, + ) + }, + ) { + assertNotNull(fixture.reports.load(activeReport.id)) + assertNull(fixture.reports.load(pendingB.id)) + assertEquals(setOf(pendingB.id, readyB.id), fixture.reports.hostedDeletionIntents().toSet()) + assertTrue(activeA.binding in fixture.evidence) + assertFalse(inactiveB.binding in fixture.evidence) + assertEquals(0, capture.currentEvidencePurgeCount, "inactive removal must not clear active live evidence") + } - fixture.coordinator.captureNow() - assertEquals(1, capture.captureNowCalls) - assertFalse(capture.hasPersistentEvidence) + assertEquals(listOf(inactiveB.binding), fixture.purgedBindings) + assertTrue(fixture.settings.bindingsForLocalServer("server-b").isEmpty()) + assertEquals(listOf(activeA.binding), fixture.settings.bindingsForLocalServer("server-a")) } @Test - fun promptAggregatesAccountReportsAcrossProfilesAndTheyRemainVisibleOffline() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun accountReplacementPurgesPendingProcessingAndReadyAuthorityBeforeMutation() = runTest { + val hosted = hostedContext().copy(localServerId = "server-a") + val transitions = DefaultIdentityTransitionBarrier() val fixture = fixture( - identity, - DefaultIdentityTransitionBarrier(), + MutableIdentityResolver(hosted), + transitions, RecordingCaptureController(), backgroundScope, UnconfinedTestDispatcher(testScheduler), ) fixture.coordinator.start() fixture.coordinator.refresh() - fixture.reports.save(reportCapture(ADULT_A, "first")) - fixture.reports.save(reportCapture(ADULT_B, "second")) - - fixture.coordinator.refresh() - - assertEquals(2, fixture.coordinator.state.value.pending.size) - assertEquals(2, fixture.coordinator.state.value.prompt?.reportCount) + val pending = fixture.reports.save(reportCapture(hosted, "replace-pending")) + val processing = fixture.reports.save(reportCapture(hosted, "replace-processing")) + fixture.reports.markHostedProcessing(processing.id, "ABC123") + val ready = fixture.reports.save(reportCapture(hosted, "replace-ready")) + fixture.reports.recordHostedReadyAndDelete(ready.id, ready.binding) + var mutationRan = false - identity.current = null - fixture.coordinator.refresh() + transitions.changing( + kind = IdentityTransitionKind.ACCOUNT_REPLACE, + target = { IdentityTransitionTarget(serverId = "server-a") }, + ) { + assertNull(fixture.reports.load(pending.id)) + assertNull(fixture.reports.load(processing.id)) + assertNull(fixture.reports.load(ready.id)) + assertEquals( + listOf(pending.id, processing.id, ready.id).sorted(), + fixture.reports.hostedDeletionIntents(), + ) + mutationRan = true + } - assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) - assertEquals(2, fixture.coordinator.state.value.pending.size) - assertEquals(null, fixture.coordinator.state.value.prompt) + assertTrue(mutationRan) + assertEquals(listOf(hosted.binding), fixture.purgedBindings) + assertTrue(fixture.settings.bindingsForLocalServer("server-a").isEmpty()) } @Test - fun unresolvedProfileSwitchCannotRestoreAnAdultOfflineCache() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun immediateSwitchThenSignOutPurgesTheNewServerWhileTheActorStillOwnsOldWork() = runTest { + val serverA = hostedContext().copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-a"), + localServerId = "server-a", + ownershipGeneration = 0, + ) + val serverB = serverA.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-b"), + localServerId = "server-b", + ) + val identity = MutableIdentityResolver(serverA) val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() val fixture = fixture( identity, transitions, - RecordingCaptureController(), + capture, backgroundScope, UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + checkNotNull(reports.load(reportId)) + uploadStarted.complete(Unit) + releaseUpload.await() + DiagnosticsUploadDecision.KeptUnavailable + } + }, ) + fixture.settings.cacheContext(serverB) + fixture.settings.cacheContext(serverA) + fixture.evidence += setOf(serverA.binding, serverB.binding) + val oldWork = fixture.reports.save(reportCapture(serverA, "actor-held-a")) + val pendingB = fixture.reports.save(reportCapture(serverB, "pending-b")) + fixture.reports.markHostedProcessing(pendingB.id, "REMOTE2") + val readyB = fixture.reports.save(reportCapture(serverB, "ready-b")) + fixture.reports.recordHostedReadyAndDelete(readyB.id, readyB.binding) fixture.coordinator.start() fixture.coordinator.refresh() - fixture.reports.save(reportCapture(ADULT_A, "adult")) - transitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { + val upload = async { fixture.coordinator.upload(oldWork.id) } + uploadStarted.await() + transitions.changing(IdentityTransitionKind.SERVER_SWITCH) { + identity.current = serverB + } + capture.hasPersistentEvidence = true + capture.gateClosed = false + + transitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { IdentityTransitionTarget(serverId = "server-b") }, + ) { + assertNotNull(fixture.reports.load(oldWork.id)) + assertNull(fixture.reports.load(pendingB.id)) + assertEquals(setOf(pendingB.id, readyB.id), fixture.reports.hostedDeletionIntents().toSet()) + assertTrue(serverA.binding in fixture.evidence) + assertFalse(serverB.binding in fixture.evidence) + assertFalse(capture.hasPersistentEvidence) identity.current = null } - fixture.coordinator.refresh() - assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) - assertFalse(fixture.coordinator.state.value.profileEligible) - assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + assertEquals(2, capture.currentEvidencePurgeCount) + assertEquals(listOf(serverB.binding), fixture.purgedBindings) + assertEquals(listOf(serverA.binding), fixture.settings.bindingsForLocalServer("server-a")) + assertTrue(fixture.settings.bindingsForLocalServer("server-b").isEmpty()) + releaseUpload.complete(Unit) + assertEquals(DiagnosticsUploadDecision.KeptUnavailable, upload.await()) } @Test - fun confirmedIneligibleProfileClearsThePreviousAdultOfflineCache() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun immediateSwitchThenRemovingTheOldServerLeavesNewServerLiveEvidenceUntouched() = runTest { + val serverA = hostedContext().copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-a"), + localServerId = "server-a", + ownershipGeneration = 0, + ) + val serverB = serverA.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "hosted-account-b"), + localServerId = "server-b", + ) + val identity = MutableIdentityResolver(serverA) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() val fixture = fixture( identity, - DefaultIdentityTransitionBarrier(), - RecordingCaptureController(), + transitions, + capture, backgroundScope, UnconfinedTestDispatcher(testScheduler), + uploaderFactory = { reports -> + DiagnosticsUploader { reportId -> + checkNotNull(reports.load(reportId)) + uploadStarted.complete(Unit) + releaseUpload.await() + DiagnosticsUploadDecision.KeptUnavailable + } + }, ) + fixture.settings.cacheContext(serverB) + fixture.settings.cacheContext(serverA) + fixture.evidence += setOf(serverA.binding, serverB.binding) + val heldA = fixture.reports.save(reportCapture(serverA, "held-a")) + fixture.reports.markHostedProcessing(heldA.id, "REMOTE1") + val pendingB = fixture.reports.save(reportCapture(serverB, "pending-b")) fixture.coordinator.start() fixture.coordinator.refresh() - fixture.reports.save(reportCapture(ADULT_A, "adult")) - identity.current = ADULT_A.copy(profileEligible = false, ownershipGeneration = 2) - fixture.coordinator.refresh() - identity.current = null - fixture.coordinator.refresh() + val upload = async { fixture.coordinator.upload(heldA.id) } + uploadStarted.await() + transitions.changing(IdentityTransitionKind.SERVER_SWITCH) { + identity.current = serverB + } + capture.hasPersistentEvidence = true + capture.gateClosed = false + val livePurgeCountAfterSwitch = capture.currentEvidencePurgeCount - assertEquals(DiagnosticsAvailabilityUi.OFFLINE, fixture.coordinator.state.value.availability) - assertFalse(fixture.coordinator.state.value.profileEligible) - assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + transitions.changing( + kind = IdentityTransitionKind.SERVER_REMOVE, + target = { + IdentityTransitionTarget( + serverId = "server-a", + affectsCurrentIdentity = false, + ) + }, + ) { + assertNull(fixture.reports.load(heldA.id)) + assertNotNull(fixture.reports.load(pendingB.id)) + assertEquals(listOf(heldA.id), fixture.reports.hostedDeletionIntents()) + assertTrue(capture.hasPersistentEvidence) + assertEquals(livePurgeCountAfterSwitch, capture.currentEvidencePurgeCount) + assertFalse(serverA.binding in fixture.evidence) + assertTrue(serverB.binding in fixture.evidence) + } + + assertEquals(listOf(serverA.binding), fixture.purgedBindings) + assertTrue(fixture.settings.bindingsForLocalServer("server-a").isEmpty()) + assertEquals(listOf(serverB.binding), fixture.settings.bindingsForLocalServer("server-b")) + releaseUpload.complete(Unit) + assertEquals(DiagnosticsUploadDecision.KeptUnavailable, upload.await()) } @Test - fun offlineCacheIsHiddenWhenLocalIdentityCannotBeAttested() = runTest { + fun failedDurableBindingRegistrationKeepsEvidenceClosedAndRecoversWithoutKillingTheActor() = runTest { val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController().apply { + hasPersistentEvidence = true + purgeFailuresRemaining = 3 + } + val runtime = RecordingRuntimePublisher() + lateinit var failingStore: FailingUpdateDataStore + var incidentCalls = 0 val fixture = fixture( - identity, - DefaultIdentityTransitionBarrier(), - RecordingCaptureController(), - backgroundScope, - UnconfinedTestDispatcher(testScheduler), + identity = identity, + transitions = transitions, + capture = capture, + scope = backgroundScope, + actorDispatcher = UnconfinedTestDispatcher(testScheduler), + dataStoreDecorator = { delegate -> + FailingUpdateDataStore(delegate).also { failingStore = it } + }, + runtimePublisher = runtime, + incidentCollectorFactory = { + DiagnosticsIncidentCollector { _, _ -> + incidentCalls += 1 + emptyList() + } + }, ) + fixture.coordinator.start() fixture.coordinator.refresh() - fixture.reports.save(reportCapture(ADULT_A, "adult")) - identity.current = null - identity.trustCachedIdentity = false + assertEquals(DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, fixture.coordinator.state.value.availability) + assertEquals(0, runtime.publishCalls) + assertEquals(0, incidentCalls) + assertFalse(capture.debugLoggingEnabled) + assertFalse(capture.persistentBreadcrumbsEnabled) + assertTrue(capture.gateClosed) + assertTrue(capture.hasPersistentEvidence, "the injected first purge failed") + assertTrue(fixture.settings.bindingsForLocalServer(ADULT_A.localServerId!!).isEmpty()) + assertNull(fixture.coordinator.captureNow()) + assertEquals(0, capture.captureNowCalls) + + capture.purgeFailuresRemaining = 0 + failingStore.failUpdates = false fixture.coordinator.refresh() - assertFalse(fixture.coordinator.state.value.profileEligible) - assertTrue(fixture.coordinator.state.value.pending.isEmpty()) + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + assertEquals(1, runtime.publishCalls) + assertEquals(1, incidentCalls) + assertTrue(capture.hasPersistentEvidence) + assertEquals(4, capture.currentEvidencePurgeCount) + assertEquals(listOf(ADULT_A.binding), fixture.settings.bindingsForLocalServer(ADULT_A.localServerId!!)) } @Test - fun promptUploadRejectsANoticeVersionDifferentFromTheApprovedBatch() = runTest { + fun rawMarkerReconciliationRunsBeforeIdentityResolutionAndRecoversWithoutKillingTheActor() = runTest { val identity = MutableIdentityResolver(ADULT_A) + val capture = RecordingCaptureController() + var reconciliationFailuresRemaining = 2 + var incidentCalls = 0 val fixture = fixture( - identity, - DefaultIdentityTransitionBarrier(), - RecordingCaptureController(), - backgroundScope, - UnconfinedTestDispatcher(testScheduler), + identity = identity, + transitions = DefaultIdentityTransitionBarrier(), + capture = capture, + scope = backgroundScope, + actorDispatcher = UnconfinedTestDispatcher(testScheduler), + incidentCollectorFactory = { + DiagnosticsIncidentCollector { _, _ -> + incidentCalls += 1 + emptyList() + } + }, + storedEvidenceReconciler = DiagnosticsStoredEvidenceReconciler { + if (reconciliationFailuresRemaining > 0) { + reconciliationFailuresRemaining -= 1 + error("marker directory unavailable") + } + }, ) + fixture.coordinator.start() fixture.coordinator.refresh() - val report = fixture.reports.save(reportCapture(ADULT_A, "adult")) - val decision = fixture.coordinator.upload(report.id, expectedNoticeVersion = ADULT_A.noticeVersion + 1) + assertEquals(DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, fixture.coordinator.state.value.availability) + assertEquals(0, identity.resolveCalls) + assertEquals(0, incidentCalls) + assertTrue(capture.gateClosed) + assertFalse(capture.persistentBreadcrumbsEnabled) - assertEquals(DiagnosticsUploadDecision.KeptConsentReviewRequired, decision) - assertNotNull(fixture.reports.load(report.id)) + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + assertEquals(1, identity.resolveCalls) + assertEquals(1, incidentCalls) } @Test - fun stalePromptCannotGrantAlwaysForADifferentNoticeVersion() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun incidentMarkerCleanupFailureKeepsEvidenceClosedAndActorCanRetry() = runTest { + val capture = RecordingCaptureController() + var failuresRemaining = 2 val fixture = fixture( - identity, - DefaultIdentityTransitionBarrier(), - RecordingCaptureController(), - backgroundScope, - UnconfinedTestDispatcher(testScheduler), + identity = MutableIdentityResolver(ADULT_A), + transitions = DefaultIdentityTransitionBarrier(), + capture = capture, + scope = backgroundScope, + actorDispatcher = UnconfinedTestDispatcher(testScheduler), + incidentCollectorFactory = { + DiagnosticsIncidentCollector { _, _ -> + if (failuresRemaining > 0) { + failuresRemaining -= 1 + error("marker delete failed") + } + emptyList() + } + }, ) + fixture.coordinator.start() fixture.coordinator.refresh() - fixture.coordinator.setConsent( - DiagnosticsConsentMode.ALWAYS, - expectedNoticeVersion = ADULT_A.noticeVersion + 1, - ) + assertEquals(DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, fixture.coordinator.state.value.availability) + assertTrue(capture.gateClosed) + assertFalse(capture.persistentBreadcrumbsEnabled) - assertEquals(DiagnosticsConsentMode.ASK, fixture.coordinator.state.value.consent) + fixture.coordinator.refresh() + + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + assertTrue(capture.persistentBreadcrumbsEnabled) } @Test - fun offlineStatePreservesTheCachedConsentChoice() = runTest { - val identity = MutableIdentityResolver(ADULT_A) + fun detachedRawGenerationCleanupFailureKeepsActorClosedUntilRetrySucceeds() = runTest { + val capture = RecordingCaptureController().apply { + hasPersistentEvidence = true + // start() enqueues one refresh before the explicit request that + // drives the background actor in this deterministic fixture. + reconciliationFailuresRemaining = 2 + purgeFailuresRemaining = 2 + } val fixture = fixture( - identity, - DefaultIdentityTransitionBarrier(), - RecordingCaptureController(), - backgroundScope, - UnconfinedTestDispatcher(testScheduler), + identity = MutableIdentityResolver(ADULT_A), + transitions = DefaultIdentityTransitionBarrier(), + capture = capture, + scope = backgroundScope, + actorDispatcher = UnconfinedTestDispatcher(testScheduler), ) + fixture.coordinator.start() fixture.coordinator.refresh() - fixture.coordinator.setConsent(DiagnosticsConsentMode.ALWAYS) - identity.current = null + assertEquals(DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, fixture.coordinator.state.value.availability) + assertTrue(capture.gateClosed) + assertTrue(capture.hasPersistentEvidence) + assertEquals(2, capture.currentEvidencePurgeCount) + fixture.coordinator.refresh() - assertEquals(DiagnosticsConsentMode.ALWAYS, fixture.coordinator.state.value.consent) - identity.current = ADULT_A + assertEquals(DiagnosticsAvailabilityUi.AVAILABLE, fixture.coordinator.state.value.availability) + assertTrue(capture.persistentBreadcrumbsEnabled) + assertEquals(3, capture.currentEvidencePurgeCount) + } + + @Test + fun startupCleanupFailureStillInstallsGateAndBlocksAccountReplacementUntilRecovery() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + var failDeletion = true + lateinit var staging: File + val fixture = fixture( + identity = identity, + transitions = transitions, + capture = capture, + scope = backgroundScope, + actorDispatcher = UnconfinedTestDispatcher(testScheduler), + reportsFactory = { files -> + val root = files.resolve("client-diagnostics/pending") + check(root.mkdirs()) + staging = root.resolve(".staging-${"f".repeat(32)}") + check(staging.mkdirs()) + staging.resolve("logs.jsonl").writeText("raw startup evidence") + FilePendingReportStore( + noBackupFilesDir = files, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + deleteRecursively = { file -> + if (failDeletion && file == staging) false else file.deleteRecursively() + }, + ) + }, + ) + + fixture.coordinator.start() fixture.coordinator.refresh() - fixture.coordinator.setConsent(DiagnosticsConsentMode.NEVER) - identity.current = null + assertEquals(DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE, fixture.coordinator.state.value.availability) + assertTrue(staging.exists()) + var mutationRan = false + + assertFailsWith { + transitions.changing( + kind = IdentityTransitionKind.ACCOUNT_REPLACE, + target = { IdentityTransitionTarget(serverId = ADULT_A.localServerId) }, + ) { + mutationRan = true + } + } + assertFalse(mutationRan) + + failDeletion = false + transitions.changing( + kind = IdentityTransitionKind.ACCOUNT_REPLACE, + target = { IdentityTransitionTarget(serverId = ADULT_A.localServerId) }, + ) { + mutationRan = true + } + + assertTrue(mutationRan) + assertFalse(staging.exists()) + } + + @Test + fun identityMutationWaitsForRefreshEvidenceCommitThenClosesAndPurgesIt() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val runtime = RecordingRuntimePublisher() + val fixture = fixture( + identity = identity, + transitions = transitions, + capture = capture, + scope = backgroundScope, + actorDispatcher = StandardTestDispatcher(testScheduler), + runtimePublisher = runtime, + ) + fixture.coordinator.start() fixture.coordinator.refresh() - assertEquals(DiagnosticsConsentMode.NEVER, fixture.coordinator.state.value.consent) + runtime.pauseNextPublish() + + val refresh = async { fixture.coordinator.refresh() } + checkNotNull(runtime.publishStarted).await() + var mutationRan = false + val transition = async { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + identity.current = null + } + } + runCurrent() + + assertFalse(mutationRan, "the generation guard must hold until publish finishes") + checkNotNull(runtime.releasePublish).complete(Unit) + refresh.await() + transition.await() + runCurrent() + + assertTrue(mutationRan) + assertFalse(runtime.live) + assertFalse(capture.hasPersistentEvidence) + assertFalse(capture.debugLoggingEnabled) + assertFalse(capture.persistentBreadcrumbsEnabled) + assertTrue(fixture.settings.bindingsForLocalServer(ADULT_A.localServerId!!).isEmpty()) } @Test - fun signOutPurgesTheOldBindingAfterTheSynchronousGateCloses() = runTest { + fun identityMutationWaitsForIncidentPersistenceThenPurgesTheNewReport() = runTest { val identity = MutableIdentityResolver(ADULT_A) val transitions = DefaultIdentityTransitionBarrier() val capture = RecordingCaptureController() + val incidentStarted = CompletableDeferred() + val releaseIncident = CompletableDeferred() + var pauseIncident = false + var savedIncident: PendingReport? = null val fixture = fixture( - identity, - transitions, - capture, - backgroundScope, - UnconfinedTestDispatcher(testScheduler), + identity = identity, + transitions = transitions, + capture = capture, + scope = backgroundScope, + actorDispatcher = StandardTestDispatcher(testScheduler), + incidentCollectorFactory = { reports -> + DiagnosticsIncidentCollector { context, _ -> + if (!pauseIncident) return@DiagnosticsIncidentCollector emptyList() + incidentStarted.complete(Unit) + releaseIncident.await() + listOf(reports.save(reportCapture(context, "incident-race")).also { savedIncident = it }) + } + }, ) fixture.coordinator.start() fixture.coordinator.refresh() - fixture.evidence.add(ADULT_A.binding) + pauseIncident = true - transitions.changing(IdentityTransitionKind.SIGN_OUT) { - assertTrue(capture.gateClosed) - identity.current = null + val refresh = async { fixture.coordinator.refresh() } + incidentStarted.await() + var mutationRan = false + val transition = async { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + identity.current = null + } } + runCurrent() + assertFalse(mutationRan) + + releaseIncident.complete(Unit) + refresh.await() + transition.await() + runCurrent() + + assertTrue(mutationRan) + assertNotNull(savedIncident) + assertNull(fixture.reports.load(checkNotNull(savedIncident).id)) + assertTrue(fixture.reports.list(ADULT_A.binding).isEmpty()) + } + + @Test + fun identityMutationWaitsForOneShotCaptureThenPurgesTheNewReport() = runTest { + val identity = MutableIdentityResolver(ADULT_A) + val transitions = DefaultIdentityTransitionBarrier() + val capture = RecordingCaptureController() + val fixture = fixture( + identity = identity, + transitions = transitions, + capture = capture, + scope = backgroundScope, + actorDispatcher = StandardTestDispatcher(testScheduler), + ) + fixture.coordinator.start() fixture.coordinator.refresh() + val captureStarted = CompletableDeferred() + val releaseCapture = CompletableDeferred() + var savedCapture: PendingReport? = null + capture.captureNowAction = { context -> + captureStarted.complete(Unit) + releaseCapture.await() + fixture.reports.save(reportCapture(context, "capture-race")).also { savedCapture = it } + } - assertFalse(ADULT_A.binding in fixture.evidence) - assertEquals(listOf(ADULT_A.binding), fixture.purgedBindings) + val captureResult = async { fixture.coordinator.captureNow() } + captureStarted.await() + var mutationRan = false + val transition = async { + transitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationRan = true + identity.current = null + } + } + runCurrent() + assertFalse(mutationRan) + + releaseCapture.complete(Unit) + transition.await() + captureResult.await() + runCurrent() + + assertTrue(mutationRan) + assertNotNull(savedCapture) + assertNull(fixture.reports.load(checkNotNull(savedCapture).id)) + assertTrue(fixture.reports.list(ADULT_A.binding).isEmpty()) } @Test @@ -316,10 +1620,15 @@ class DiagnosticsCoordinatorTest { } ring.offer("{\"cat\":\"playback\",\"msg\":\"safe\"}") ring.offer("{\"cat\":\"network\",\"msg\":\"safe\"}") - val store = FilePendingReportStore(files, nowMs = { 20L }) + val store = FilePendingReportStore( + files, + nowMs = { 20L }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) val controller = FileDiagnosticsCaptureController( logBuffer = ring, - fileLogger = DiagnosticsFileLogger(files, UnconfinedTestDispatcher(testScheduler)), + fileLogger = DiagnosticsFileLogger(files, UnconfinedTestDispatcher(testScheduler), directorySync = {}), reports = store, deviceSnapshots = DeviceSnapshotCollector(StableDeviceProbe(), nowRfc3339 = { "2026-07-22T00:00:00Z" }), deviceSnapshotCache = DeviceSnapshotCache(), @@ -361,21 +1670,47 @@ class DiagnosticsCoordinatorTest { capture: RecordingCaptureController, scope: CoroutineScope, actorDispatcher: CoroutineDispatcher, + uploaderFactory: (PendingReportStore) -> DiagnosticsUploader = { + DiagnosticsUploader { DiagnosticsUploadDecision.KeptUnavailable } + }, + uploadScheduler: DiagnosticsUploadScheduler = DiagnosticsUploadScheduler { }, + hostedDeletionScheduler: HostedDiagnosticsDeletionScheduler = HostedDiagnosticsDeletionScheduler.None, + hostedReportDeleter: HostedDiagnosticsReportDeleter = HostedDiagnosticsReportDeleter.None, + purgeFailure: (() -> Throwable?)? = null, + dataStoreDecorator: (DataStore) -> DataStore = { it }, + runtimePublisher: DiagnosticsRuntimePublisher = DiagnosticsRuntimePublisher.None, + incidentCollectorFactory: (PendingReportStore) -> DiagnosticsIncidentCollector = { + DiagnosticsIncidentCollector { _, _ -> emptyList() } + }, + storedEvidenceReconciler: DiagnosticsStoredEvidenceReconciler = DiagnosticsStoredEvidenceReconciler.None, + reportsFactory: (File) -> FilePendingReportStore = { files -> + FilePendingReportStore( + files, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + }, ): Fixture { val files = temporaryFolder.newFolder() val purgedBindings = mutableListOf() val evidence = mutableSetOf() - val settings = DiagnosticsSettingsStore( - dataStore = PreferenceDataStoreFactory.create { + val reports = reportsFactory(files) + val dataStore = dataStoreDecorator( + PreferenceDataStoreFactory.create { File(files, "diagnostics-${System.nanoTime()}.preferences_pb") }, - bindingPurger = DiagnosticsBindingPurger { binding -> + ) + val settings = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = DiagnosticsBindingPurger { binding, includeLiveCapture -> + purgeFailure?.invoke()?.let { throw it } purgedBindings += binding evidence -= binding - capture.purge(binding) + if (includeLiveCapture) capture.purgeCurrentEvidence() + reports.purge(binding) }, ) - val reports = FilePendingReportStore(files, nowMs = { CAPTURED_AT }) val coordinator = DefaultDiagnosticsCoordinator( scope = scope, actorDispatcher = actorDispatcher, @@ -384,18 +1719,32 @@ class DiagnosticsCoordinatorTest { settings = settings, reports = reports, capture = capture, - uploader = DiagnosticsUploader { DiagnosticsUploadDecision.KeptUnavailable }, - uploadScheduler = DiagnosticsUploadScheduler { }, + uploader = uploaderFactory(reports), + uploadScheduler = uploadScheduler, + hostedDeletionScheduler = hostedDeletionScheduler, + hostedReportDeleter = hostedReportDeleter, + runtimePublisher = runtimePublisher, + incidentCollector = incidentCollectorFactory(reports), + storedEvidenceReconciler = storedEvidenceReconciler, ) - return Fixture(coordinator, reports, evidence, purgedBindings) + return Fixture(coordinator, settings, reports, evidence, purgedBindings, dataStore) } + private fun hostedContext() = ADULT_A.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device"), + profileId = null, + sourceProfileId = ADULT_A.profileId, + destinationKind = DiagnosticsDestinationKind.HOSTED, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + ) + private fun reportCapture(context: DiagnosticsCaptureContext, fingerprint: String) = PendingReportCapture( binding = PendingReportBinding( serverInstanceId = context.binding.serverInstanceId, accountUserId = context.binding.accountUserId, profileId = context.profileId, ownershipGeneration = context.ownershipGeneration, + destinationKind = context.destinationKind, ), manifest = DiagnosticsManifest( schemaVersion = 1, @@ -425,20 +1774,35 @@ class DiagnosticsCoordinatorTest { var current: DiagnosticsCaptureContext?, ) : DiagnosticsIdentityResolver { var trustCachedIdentity = true - override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = current + var resolveCalls = 0 + var captureAttestationAllowed = true + var captureAttestationCalls = 0 + override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + resolveCalls += 1 + return current + } + override suspend fun resolveForCapture(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + captureAttestationCalls += 1 + return if (captureAttestationAllowed) current else null + } override suspend fun matchesCachedIdentity(cached: CachedDiagnosticsContext): Boolean = trustCachedIdentity } private class RecordingCaptureController : DiagnosticsCaptureController { var gateClosed = false var hasPersistentEvidence = false + var debugLoggingEnabled = false + var persistentBreadcrumbsEnabled = false val cancelled = mutableListOf() + var currentEvidencePurgeCount = 0 + var purgeFailuresRemaining = 0 + var reconciliationFailuresRemaining = 0 var captureNowCalls = 0 + var captureNowAction: suspend (DiagnosticsCaptureContext) -> PendingReport? = { null } private var nextGeneration = 0L override fun closeGate() { gateClosed = true - hasPersistentEvidence = false } override suspend fun start(context: DiagnosticsCaptureContext): ActiveDiagnosticsCapture { @@ -462,11 +1826,84 @@ class DiagnosticsCoordinatorTest { override suspend fun captureNow(context: DiagnosticsCaptureContext): PendingReport? { captureNowCalls += 1 - return null + return captureNowAction(context) } - override suspend fun purge(binding: DiagnosticsBinding) { + override suspend fun setDebugLogging(context: DiagnosticsCaptureContext?, enabled: Boolean) { + debugLoggingEnabled = context != null && enabled + if (debugLoggingEnabled) hasPersistentEvidence = true + } + + override suspend fun setPersistentBreadcrumbs(context: DiagnosticsCaptureContext?, enabled: Boolean) { + persistentBreadcrumbsEnabled = context != null && enabled + if (persistentBreadcrumbsEnabled) hasPersistentEvidence = true + } + + override suspend fun reconcileStoredEvidence() { + if (reconciliationFailuresRemaining > 0) { + reconciliationFailuresRemaining -= 1 + throw IllegalStateException("injected detached evidence cleanup failure") + } + } + + override suspend fun purgeCurrentEvidence() { + currentEvidencePurgeCount += 1 + if (purgeFailuresRemaining > 0) { + purgeFailuresRemaining -= 1 + throw IllegalStateException("injected live evidence purge failure") + } hasPersistentEvidence = false + debugLoggingEnabled = false + persistentBreadcrumbsEnabled = false + } + } + + private class RecordingRuntimePublisher : DiagnosticsRuntimePublisher { + var live = false + var publishCalls = 0 + var closeCalls = 0 + var publishStarted: CompletableDeferred? = null + var releasePublish: CompletableDeferred? = null + + override fun closeGate() { + closeCalls += 1 + live = false + } + + override suspend fun publish(context: DiagnosticsCaptureContext) { + publishCalls += 1 + publishStarted?.complete(Unit) + releasePublish?.await() + live = true + } + + fun pauseNextPublish() { + publishStarted = CompletableDeferred() + releasePublish = CompletableDeferred() + } + } + + private class FailingUpdateDataStore( + private val delegate: DataStore, + ) : DataStore { + var failUpdates = true + + override val data: Flow = delegate.data + + override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences { + if (failUpdates) throw IllegalStateException("injected DataStore update failure") + return delegate.updateData(transform) + } + } + + private class RecordingHostedReportDeleter( + var result: Boolean, + ) : HostedDiagnosticsReportDeleter { + val reportIds = mutableListOf() + + override suspend fun delete(reportId: String): Boolean { + reportIds += reportId + return result } } @@ -489,9 +1926,11 @@ class DiagnosticsCoordinatorTest { private data class Fixture( val coordinator: DiagnosticsCoordinator, + val settings: DiagnosticsSettingsStore, val reports: PendingReportStore, val evidence: MutableSet, val purgedBindings: MutableList, + val dataStore: DataStore, ) private companion object { @@ -501,10 +1940,10 @@ class DiagnosticsCoordinatorTest { profileEligible = true, noticeVersion = 2, status = DiagnosticsAvailabilityStatus.AVAILABLE, - ownershipGeneration = 1, + ownershipGeneration = 0, localServerId = "local-server-1", ) - val ADULT_B = ADULT_A.copy(profileId = "adult-b", ownershipGeneration = 2) + val ADULT_B = ADULT_A.copy(profileId = "adult-b", ownershipGeneration = 1) const val CAPTURED_AT = 1_700_000_000_000L } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLoggerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLoggerTest.kt index 7fbc41900..728d22349 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLoggerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsFileLoggerTest.kt @@ -8,6 +8,7 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -20,7 +21,7 @@ class DiagnosticsFileLoggerTest { fun writesJsonLinesUnderNoBackupAndFreezeRetainsAStableSnapshot() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val noBackup = temporaryFolder.newFolder("no-backup") - val logger = DiagnosticsFileLogger(noBackup, writerDispatcher = dispatcher) + val logger = DiagnosticsFileLogger(noBackup, writerDispatcher = dispatcher, directorySync = {}) logger.start(generation = 7) logger.offer("one") @@ -46,6 +47,7 @@ class DiagnosticsFileLoggerTest { channelCapacity = 16, maxSegments = 2, maxSegmentBytes = 14, + directorySync = {}, ) logger.start(generation = 9) @@ -68,6 +70,7 @@ class DiagnosticsFileLoggerTest { channelCapacity = 2, maxSegments = 5, maxSegmentBytes = 1_024, + directorySync = {}, ) logger.start(generation = 11) @@ -87,6 +90,7 @@ class DiagnosticsFileLoggerTest { noBackupFilesDir = noBackup, writerDispatcher = dispatcher, maxSegmentBytes = 8, + directorySync = {}, ) logger.start(generation = 13) @@ -98,4 +102,97 @@ class DiagnosticsFileLoggerTest { assertFalse(noBackup.resolve("client-diagnostics/logs/generation-13").exists()) assertFalse(logger.isActive) } + + @Test + fun purgeFailureAtDirectorySyncPropagatesAfterVerifiedRawDeletion() = runTest { + val noBackup = temporaryFolder.newFolder("purge-fsync") + val root = noBackup.resolve("client-diagnostics/logs") + assertTrue(root.mkdirs()) + root.resolve("raw.jsonl").writeText("private") + val logger = DiagnosticsFileLogger( + noBackupFilesDir = noBackup, + writerDispatcher = StandardTestDispatcher(testScheduler), + directorySync = { error("injected fsync failure") }, + ) + + assertFailsWith { logger.purgeStoredEvidence() } + assertFalse(root.exists()) + } + + @Test + fun startupReconcilesCrashInterruptedFrozenGeneration() = runTest { + val noBackup = temporaryFolder.newFolder("restart-frozen") + val dispatcher = StandardTestDispatcher(testScheduler) + val first = DiagnosticsFileLogger(noBackup, writerDispatcher = dispatcher, directorySync = {}) + first.start(generation = 17) + first.offer("raw captured line") + advanceUntilIdle() + val frozen = first.freeze(expectedGeneration = 17) + val generationDirectory = noBackup.resolve("client-diagnostics/logs/generation-17") + assertTrue(generationDirectory.isDirectory) + assertTrue(frozen.files.isNotEmpty()) + + // Simulates process death after the pending report publish and before raw cleanup. + DiagnosticsFileLogger(noBackup, writerDispatcher = dispatcher, directorySync = {}) + + assertFalse(generationDirectory.exists()) + } + + @Test + fun partialFrozenCleanupFailsClosedAndRestartRetriesIt() = runTest { + val noBackup = temporaryFolder.newFolder("partial-frozen") + val dispatcher = StandardTestDispatcher(testScheduler) + var failGenerationDelete = false + val logger = DiagnosticsFileLogger( + noBackupFilesDir = noBackup, + writerDispatcher = dispatcher, + deleteRecursively = { target -> + if (failGenerationDelete && target.name == "generation-19") { + target.resolve("segment-00000.jsonl").delete() + false + } else { + target.deleteRecursively() + } + }, + directorySync = {}, + ) + logger.start(generation = 19) + logger.offer("raw captured line") + advanceUntilIdle() + val frozen = logger.freeze(expectedGeneration = 19) + failGenerationDelete = true + + assertFailsWith { logger.deleteFrozen(frozen) } + val generationDirectory = noBackup.resolve("client-diagnostics/logs/generation-19") + assertTrue(generationDirectory.isDirectory) + + DiagnosticsFileLogger(noBackup, writerDispatcher = dispatcher, directorySync = {}) + + assertFalse(generationDirectory.exists()) + } + + @Test + fun failedStartupReconciliationBlocksNewCaptureUntilCleanupRecovers() = runTest { + val noBackup = temporaryFolder.newFolder("startup-cleanup-failure") + val stale = noBackup.resolve("client-diagnostics/logs/generation-21") + assertTrue(stale.mkdirs()) + stale.resolve("segment-00000.jsonl").writeText("private crash-leftover bytes") + var allowDelete = false + val logger = DiagnosticsFileLogger( + noBackupFilesDir = noBackup, + writerDispatcher = StandardTestDispatcher(testScheduler), + deleteRecursively = { target -> allowDelete && target.deleteRecursively() }, + directorySync = {}, + ) + + assertFailsWith { logger.start(generation = 22) } + assertFalse(logger.isActive) + assertTrue(stale.isDirectory) + + allowDelete = true + logger.start(generation = 22) + assertTrue(logger.isActive) + assertFalse(stale.exists()) + logger.cancel(expectedGeneration = 22) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentationTest.kt index 65a6fab60..0cda139fd 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsInstrumentationTest.kt @@ -43,11 +43,55 @@ class DiagnosticsInstrumentationTest { safeDiagnosticsNetworkPath("/api/v1/playback/sessions/private-session/control/ws#fragment"), ) assertEquals("/api/v1/items/{id}", safeDiagnosticsNetworkPath("/api/v1/items/status")) - assertEquals("/api/v1/other", safeDiagnosticsNetworkPath("/api/v1/playback/start/status")) + // Allowlisted resource, unmatched tail: names the resource so the + // endpoint is actionable. Unknown resources stay anonymous (below). + assertEquals("/api/v1/playback/other", safeDiagnosticsNetworkPath("/api/v1/playback/start/status")) + // The per-item favorite/watchlist probes answer "not a favourite" with a + // 404, so they are the loudest 4xx the client emits. A template resolves + // them exactly; without one they were only ever "/api/v1/favorites/other". + assertEquals("/api/v1/favorites/{id}", safeDiagnosticsNetworkPath("/api/v1/favorites/episode-tvdb-1-1-1")) + assertEquals("/api/v1/watchlist/{id}", safeDiagnosticsNetworkPath("/api/v1/watchlist/series-tvdb-1")) assertEquals("/api/v1/other", safeDiagnosticsNetworkPath("/api/v1/private/private-id")) assertEquals("/other", safeDiagnosticsNetworkPath("/not-api/private-id")) } + /** + * Every root this change allowlisted, not just the two the original test + * happened to cover. 116 of 121 recorded 4xx were unnameable because these + * collapsed to "/api/v1/other"; a template that silently stops matching + * puts them straight back there. + */ + @Test + fun everyNewlyAllowlistedRouteResolvesToItsTemplate() { + mapOf( + "/api/v1/favorites" to "/api/v1/favorites", + "/api/v1/favorites/episode-tvdb-1-1-1" to "/api/v1/favorites/{id}", + "/api/v1/history" to "/api/v1/history", + "/api/v1/library-playback-prefs" to "/api/v1/library-playback-prefs", + "/api/v1/library-playback-prefs/library-7" to "/api/v1/library-playback-prefs/{id}", + "/api/v1/metadata/ai/status" to "/api/v1/metadata/ai/status", + "/api/v1/onboarding/flow" to "/api/v1/onboarding/flow", + "/api/v1/onboarding/state" to "/api/v1/onboarding/state", + "/api/v1/onboarding/progress" to "/api/v1/onboarding/progress", + "/api/v1/watch/series-tvdb-1" to "/api/v1/watch/{id}", + "/api/v1/watchlist" to "/api/v1/watchlist", + "/api/v1/watchlist/series-tvdb-1" to "/api/v1/watchlist/{id}", + ).forEach { (rawPath, expected) -> + assertEquals(expected, safeDiagnosticsNetworkPath(rawPath), rawPath) + } + } + + /** + * The other half of the same bargain: an allowlisted resource names itself + * even on an unmatched tail, but an unknown resource stays anonymous. + */ + @Test + fun anAllowlistedResourceNamesItselfWhileAnUnknownOneStaysAnonymous() { + assertEquals("/api/v1/history/other", safeDiagnosticsNetworkPath("/api/v1/history/2026/08")) + assertEquals("/api/v1/onboarding/other", safeDiagnosticsNetworkPath("/api/v1/onboarding/private-step/detail")) + assertEquals("/api/v1/other", safeDiagnosticsNetworkPath("/api/v1/private-resource/private-id")) + } + @Test fun statsSnapshotsRequireDetailedCaptureAndFiveSecondCadence() { val cadence = DiagnosticsStatsCadence(intervalMs = 5_000) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPlaybackSessionTrackerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPlaybackSessionTrackerTest.kt index 033ae78de..cee35f3a6 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPlaybackSessionTrackerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPlaybackSessionTrackerTest.kt @@ -58,11 +58,15 @@ class DiagnosticsPlaybackSessionTrackerTest { fun runtimePublisherIncludesSessionsAndPrivacyGateClearsThem() = runTest { val tracker = DiagnosticsPlaybackSessionTracker() val publisher = DefaultDiagnosticsRuntimePublisher( - ledger = DiagnosticsRunLedger(temporaryFolder.newFolder()), + ledger = DiagnosticsRunLedger( + temporaryFolder.newFolder(), + directorySync = {}, + atomicRename = ::testAtomicRename, + ), logBuffer = LogRing(), deviceSnapshots = DeviceSnapshotCollector(EmptyProbe), deviceSnapshotCache = DeviceSnapshotCache(), - redactionTokens = DiagnosticsRedactionTokenProvider { emptyList() }, + redactionTokens = DiagnosticsRedactionTokenProvider { _ -> emptyList() }, playbackSessions = tracker, captureSessionIdFactory = { "capture-1" }, ) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyIntegrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyIntegrationTest.kt index 15009bbc6..4b07783f4 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyIntegrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsPrivacyIntegrationTest.kt @@ -18,6 +18,7 @@ import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -35,7 +36,12 @@ class DiagnosticsPrivacyIntegrationTest { fun childThenAdultManualBundleHasNoChildGeneration() = runTest { val root = temporaryFolder.newFolder() val ring = LogRing() - val reports = FilePendingReportStore(root, nowMs = { CAPTURED_AT }) + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) val capture = captureController(root, ring, reports, UnconfinedTestDispatcher(testScheduler)) val identity = MutableIdentity(CHILD) val transitions = DefaultIdentityTransitionBarrier() @@ -69,12 +75,305 @@ class DiagnosticsPrivacyIntegrationTest { assertEquals(ADULT.profileId, report.binding.profileId) } + @Test + fun publishedManualReportDoesNotHideFrozenRawCleanupFailureAndRestartRetriesIt() = runTest { + val root = temporaryFolder.newFolder() + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + var failFrozenCleanup = false + val logger = DiagnosticsFileLogger( + noBackupFilesDir = root, + writerDispatcher = dispatcher, + deleteRecursively = { target -> + if (failFrozenCleanup && target.name == "generation-1") false else target.deleteRecursively() + }, + directorySync = {}, + ) + val capture = FileDiagnosticsCaptureController( + logBuffer = LogRing(), + fileLogger = logger, + reports = reports, + deviceSnapshots = DeviceSnapshotCollector(StableProbe, nowRfc3339 = { "2026-07-22T00:00:00Z" }), + deviceSnapshotCache = DeviceSnapshotCache(), + environment = ENVIRONMENT, + nowMs = { CAPTURED_AT }, + sessionIdFactory = { "manual-cleanup-boundary" }, + ) + val active = capture.start(ADULT) + PrairieLog.i(DiagnosticsLogCategory.OTHER, "CleanupBoundary", "raw captured line") + failFrozenCleanup = true + + assertFailsWith { capture.stop(active, ADULT) } + + assertEquals(1, reports.list(ADULT.binding).size, "bounded report was published first") + val rawGeneration = root.resolve("client-diagnostics/logs/generation-1") + assertTrue(rawGeneration.isDirectory, "failed cleanup must remain observable") + + DiagnosticsFileLogger(root, writerDispatcher = dispatcher, directorySync = {}) + + assertFalse(rawGeneration.exists(), "restart reconciliation must remove detached raw bytes") + } + + @Test + fun hostedManualBundleOmitsSourceServerAccountAndProfileIdentity() = runTest { + val root = temporaryFolder.newFolder() + val ring = LogRing() + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val context = DiagnosticsCaptureContext( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, LOCAL_HOSTED_OWNER), + profileId = null, + profileEligible = true, + noticeVersion = 1, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 3, + localServerId = SOURCE_SERVER_ID, + credentialFingerprint = "f".repeat(64), + sourceProfileId = SOURCE_PROFILE_ID, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val playbackSessions = DiagnosticsPlaybackSessionTracker().apply { + open(context.identityKey) + record(PRIVATE_PLAYBACK_SESSION_ID) + } + val capture = captureController( + root, + ring, + reports, + UnconfinedTestDispatcher(testScheduler), + playbackSessions, + ) + PrairieLog.installSink(ring) + PrairieLog.i( + DiagnosticsLogCategory.NETWORK, + "PrivacyTest", + "request to $SOURCE_SERVER_URL profile=$SOURCE_PROFILE_ID account=$SOURCE_ACCOUNT_ID", + ) + PrairieLog.i( + DiagnosticsLogCategory.PLAYBACK, + "PrivacyTest", + "playback_session_id=$PRIVATE_PLAYBACK_SESSION_ID", + mapOf( + "decoder" to PrairieLogAttribute.Text("safe-decoder"), + "buffered_ms" to PrairieLogAttribute.Integer(1_200), + ), + ) + + val report = requireNotNull(capture.captureNow(context)) + assertTrue(report.manifest.playbackSessionIds.isEmpty(), "hosted capture must strip playback ids") + val capturedLogs = report.directory.resolve("logs.jsonl").readText() + assertTrue(capturedLogs.contains(SOURCE_PROFILE_ID)) + assertTrue(capturedLogs.contains(SOURCE_ACCOUNT_ID)) + val framed = report.withCurrentConsent(DiagnosticsConsentMode.ALWAYS, context.noticeVersion) + val bundle = FileDiagnosticsBundleBuilder().build( + framed, + redactionTokens = listOf(SOURCE_SERVER_URL, SOURCE_PROFILE_ID, SOURCE_ACCOUNT_ID), + ) + val outerManifest = bundle.manifestBytes.decodeToString() + val archive = GZIPInputStream(ByteArrayInputStream(bundle.bytes)).use { it.readBytes() }.decodeToString() + + assertEquals(DiagnosticsDestinationKind.HOSTED, report.binding.destinationKind) + assertEquals(null, bundle.manifest.report.profileId) + assertEquals(HOSTED_DIAGNOSTICS_COLLECTOR_ID, bundle.manifest.destination.serverInstanceId) + assertTrue(archive.contains("android-decoder"), "collector-v1 playback decoder family must remain") + assertFalse(archive.contains("buffered_ms"), "extended Android attributes must not ship hosted") + listOf( + SOURCE_SERVER_URL, + SOURCE_SERVER_ID, + SOURCE_PROFILE_ID, + SOURCE_ACCOUNT_ID, + LOCAL_HOSTED_OWNER, + PRIVATE_PLAYBACK_SESSION_ID, + ).forEach { identity -> + assertFalse(outerManifest.contains(identity), "outer manifest: $identity") + assertFalse(archive.contains(identity), "embedded manifest/logs: $identity") + } + } + + @Test + fun hostedCrashBundleStripsSourceAndPlaybackIdentityAndForcesPromptConsent() = runTest { + val root = temporaryFolder.newFolder() + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val context = DiagnosticsCaptureContext( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, LOCAL_HOSTED_OWNER), + profileId = null, + profileEligible = true, + noticeVersion = 1, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 9, + localServerId = SOURCE_SERVER_ID, + credentialFingerprint = "f".repeat(64), + sourceProfileId = SOURCE_PROFILE_ID, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + ledger.beginRun(context, CAPTURED_AT - 10_000, "hosted-crash-session") + val marker = JvmCrashMarkerRecord( + occurredAtEpochMs = CAPTURED_AT, + threadName = "main", + threadId = 1, + throwableType = "java.lang.IllegalStateException", + stack = NETWORK_CRASH_STACK, + binding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = LOCAL_HOSTED_OWNER, + profileId = null, + ownershipGeneration = context.ownershipGeneration, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + captureSessionId = "hosted-crash-session", + runToken = RUN_TOKEN, + foreground = true, + playbackSessionIds = listOf(PRIVATE_PLAYBACK_SESSION_ID), + deviceSnapshotJson = DEVICE_JSON, + logLines = emptyList(), + logDroppedCount = 0, + logTornCount = 0, + logGeneration = context.ownershipGeneration, + truncated = false, + ) + val collector = ExitInfoCollector( + source = AndroidExitInfoSource { emptyList() }, + ledger = ledger, + reports = reports, + markers = InMemoryMarkers(marker), + environment = ENVIRONMENT, + deviceSnapshotBytes = { DEVICE_JSON.encodeToByteArray() }, + noticeVersion = { context.noticeVersion }, + consentMode = { org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.ALWAYS }, + ) + + val report = collector.collect().single() + assertTrue(report.manifest.playbackSessionIds.isEmpty()) + assertEquals(null, report.manifest.report.profileId) + val framed = report.withCurrentConsent(DiagnosticsConsentMode.ALWAYS, context.noticeVersion) + val bundle = FileDiagnosticsBundleBuilder().build(framed, redactionTokens = emptyList()) + assertEquals( + org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.PROMPT, + bundle.manifest.consent.mode, + ) + val outerManifest = bundle.manifestBytes.decodeToString() + val archive = GZIPInputStream(ByteArrayInputStream(bundle.bytes)).use { it.readBytes() }.decodeToString() + listOf( + SOURCE_SERVER_ID, + SOURCE_PROFILE_ID, + LOCAL_HOSTED_OWNER, + PRIVATE_PLAYBACK_SESSION_ID, + "192.168.1.44", + "silo.home.arpa", + "/data/user/0/org.prairieserver.prairie", + ).forEach { identity -> + assertFalse(outerManifest.contains(identity), "outer manifest: $identity") + assertFalse(archive.contains(identity), "embedded manifest/logs: $identity") + } + } + + @Test + fun hostedNativeExitBundleOmitsProcessAndBuildFingerprintIdentityFromProductionArtifacts() = runTest { + val root = temporaryFolder.newFolder() + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val context = DiagnosticsCaptureContext( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, LOCAL_HOSTED_OWNER), + profileId = null, + profileEligible = true, + noticeVersion = 1, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 11, + localServerId = SOURCE_SERVER_ID, + credentialFingerprint = "f".repeat(64), + sourceProfileId = SOURCE_PROFILE_ID, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + ledger.beginRun(context, CAPTURED_AT - 10_000, "hosted-native-session") + val collector = ExitInfoCollector( + source = AndroidExitInfoSource { + listOf( + object : AndroidExitInfoRecord { + override val reason = AndroidExitReason.NATIVE_CRASH + override val timestampMs = CAPTURED_AT + override val pid = 123 + override val processName = "org.prairieserver.prairie" + override val status = 6 + override val processStateSummary = RUN_TOKEN.encodeToByteArray() + override fun trace(maxBytes: Int) = "opaque native trace".encodeToByteArray() + }, + ) + }, + ledger = ledger, + reports = reports, + markers = object : JvmCrashMarkerSource { + override fun records() = emptyList() + override fun delete(marker: JvmCrashMarkerRecord) = Unit + }, + environment = ENVIRONMENT, + deviceSnapshotBytes = { DEVICE_WITH_BUILD_FINGERPRINT.encodeToByteArray() }, + noticeVersion = { context.noticeVersion }, + consentMode = { org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode.ALWAYS }, + ) + + val report = collector.collect().single() + val rawSummary = report.directory.resolve("crash/summary.json").readText() + val rawDevice = report.directory.resolve("device.json").readText() + assertTrue(rawSummary.contains("process_hash"), rawSummary) + assertTrue(rawDevice.contains("build_fingerprint_hash"), rawDevice) + + val bundle = FileDiagnosticsBundleBuilder().build(report, redactionTokens = emptyList()) + val hostedSummary = bundle.sanitizedEntries.getValue("crash/summary.json").decodeToString() + val hostedDevice = bundle.sanitizedEntries.getValue("device.json").decodeToString() + assertFalse(hostedSummary.contains("process_hash"), hostedSummary) + assertFalse(hostedSummary.contains("org.prairieserver.prairie"), hostedSummary) + assertFalse(hostedDevice.contains("build_fingerprint_hash"), hostedDevice) + assertFalse(hostedDevice.contains("a".repeat(32)), hostedDevice) + assertFalse("crash/tombstone.pb" in bundle.manifest.archive.entries) + } + @Test fun jvmMarkerAndExitInfoProduceOneCoordinatorReport() = runTest { val root = temporaryFolder.newFolder() - val reports = FilePendingReportStore(root, nowMs = { CAPTURED_AT + 1_000 }) - val ledger = DiagnosticsRunLedger(root, tokenFactory = { RUN_TOKEN }) - ledger.beginRun(ADULT, CAPTURED_AT - 10_000, "capture-1") + val reports = FilePendingReportStore( + root, + nowMs = { CAPTURED_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val adult = ADULT.copy(ownershipGeneration = 0) + ledger.beginRun(adult, CAPTURED_AT - 10_000, "capture-1") val marker = JvmCrashMarkerRecord( occurredAtEpochMs = CAPTURED_AT, threadName = "main", @@ -82,10 +381,10 @@ class DiagnosticsPrivacyIntegrationTest { throwableType = "java.lang.IllegalStateException", stack = "java.lang.IllegalStateException: boom", binding = PendingReportBinding( - serverInstanceId = ADULT.binding.serverInstanceId, - accountUserId = ADULT.binding.accountUserId, - profileId = ADULT.profileId, - ownershipGeneration = ADULT.ownershipGeneration, + serverInstanceId = adult.binding.serverInstanceId, + accountUserId = adult.binding.accountUserId, + profileId = adult.profileId, + ownershipGeneration = adult.ownershipGeneration, ), captureSessionId = "capture-1", runToken = RUN_TOKEN, @@ -95,7 +394,7 @@ class DiagnosticsPrivacyIntegrationTest { logLines = emptyList(), logDroppedCount = 0, logTornCount = 0, - logGeneration = ADULT.ownershipGeneration, + logGeneration = adult.ownershipGeneration, truncated = false, ) val markers = InMemoryMarkers(marker) @@ -106,12 +405,12 @@ class DiagnosticsPrivacyIntegrationTest { markers = markers, environment = ENVIRONMENT, deviceSnapshotBytes = { DEVICE_JSON.encodeToByteArray() }, - noticeVersion = { ADULT.noticeVersion }, + noticeVersion = { adult.noticeVersion }, ) val transitions = DefaultIdentityTransitionBarrier() val coordinator = coordinator( root = root, - identity = MutableIdentity(ADULT), + identity = MutableIdentity(adult), transitions = transitions, capture = NoOpCapture, reports = reports, @@ -123,7 +422,7 @@ class DiagnosticsPrivacyIntegrationTest { coordinator.start() coordinator.refresh() - assertEquals(1, reports.list(ADULT.binding).size) + assertEquals(1, reports.list(adult.binding).size) assertEquals(1, coordinator.state.value.pending.size) assertTrue(markers.deleted) } @@ -142,7 +441,7 @@ class DiagnosticsPrivacyIntegrationTest { PreferenceDataStoreFactory.create { root.resolve("settings-${System.nanoTime()}.preferences_pb") }, - DiagnosticsBindingPurger { }, + DiagnosticsBindingPurger { _, _ -> }, ) return DefaultDiagnosticsCoordinator( scope = scope, @@ -163,15 +462,17 @@ class DiagnosticsPrivacyIntegrationTest { ring: LogRing, reports: PendingReportStore, dispatcher: kotlinx.coroutines.CoroutineDispatcher, + playbackSessions: DiagnosticsPlaybackSessionTracker = DiagnosticsPlaybackSessionTracker(), ) = FileDiagnosticsCaptureController( logBuffer = ring, - fileLogger = DiagnosticsFileLogger(root, dispatcher), + fileLogger = DiagnosticsFileLogger(root, dispatcher, directorySync = {}), reports = reports, deviceSnapshots = DeviceSnapshotCollector(StableProbe, nowRfc3339 = { "2026-07-22T00:00:00Z" }), deviceSnapshotCache = DeviceSnapshotCache(), environment = ENVIRONMENT, nowMs = { CAPTURED_AT }, sessionIdFactory = { "manual-session" }, + playbackSessions = playbackSessions, ) private fun jvmExit() = object : AndroidExitInfoRecord { @@ -203,7 +504,7 @@ class DiagnosticsPrivacyIntegrationTest { override suspend fun stop(active: ActiveDiagnosticsCapture, context: DiagnosticsCaptureContext): PendingReport? = null override suspend fun cancel(active: ActiveDiagnosticsCapture) = Unit override suspend fun captureNow(context: DiagnosticsCaptureContext): PendingReport? = null - override suspend fun purge(binding: DiagnosticsBinding) = Unit + override suspend fun purgeCurrentEvidence() = Unit } private object StableProbe : DiagnosticsDeviceProbe { @@ -226,6 +527,19 @@ class DiagnosticsPrivacyIntegrationTest { const val CAPTURED_AT = 1_700_000_000_000L const val RUN_TOKEN = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const val DEVICE_JSON = "{\"captured_at\":\"2026-07-22T00:00:00Z\"}" + val DEVICE_WITH_BUILD_FINGERPRINT = + """{"captured_at":"2026-07-22T00:00:00Z","identity":{"manufacturer":"Google","build_fingerprint_hash":"${"a".repeat(32)}"}}""" + const val SOURCE_SERVER_ID = "private-server-id" + const val SOURCE_SERVER_URL = "https://private-silo.example" + const val SOURCE_PROFILE_ID = "private-profile-id" + const val SOURCE_ACCOUNT_ID = "private-account-id" + const val LOCAL_HOSTED_OWNER = "local-hosted-owner-hash" + const val PRIVATE_PLAYBACK_SESSION_ID = "private-playback-session-id" + const val NETWORK_CRASH_STACK = + "java.net.ConnectException: Failed to connect to /192.168.1.44:8096; " + + "Unable to resolve host \"silo.home.arpa\"; " + + "file=/data/user/0/org.prairieserver.prairie/files/diagnostics.log\n" + + " at java.net.Socket.connect(Socket.java:42)" val ENVIRONMENT = ExitReportEnvironment( appVersion = "1.0", appBuild = "1", @@ -239,8 +553,8 @@ class DiagnosticsPrivacyIntegrationTest { profileEligible = false, noticeVersion = 2, status = DiagnosticsAvailabilityStatus.AVAILABLE, - ownershipGeneration = 1, + ownershipGeneration = 0, ) - val ADULT = CHILD.copy(profileId = "adult", profileEligible = true, ownershipGeneration = 2) + val ADULT = CHILD.copy(profileId = "adult", profileEligible = true, ownershipGeneration = 1) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactorTest.kt index a5f4d29d1..3978ee167 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRedactorTest.kt @@ -64,6 +64,23 @@ class DiagnosticsRedactorTest { ) } + @Test + fun websocketUrlsAndPrivatePathIdsUseTheSameRedactionBoundary() { + val output = redactor.sanitize( + "wss://secret.example/items/42?access_token=secret " + + "ws://media.internal/users/0123456789abcdef#private", + ) + + assertFalse(output.contains("secret.example"), output) + assertFalse(output.contains("media.internal"), output) + assertFalse(output.contains("access_token"), output) + assertFalse(output.contains("/items/42"), output) + assertFalse(output.contains("0123456789abcdef"), output) + assertTrue(output.contains("wss://host_"), output) + assertTrue(output.contains("/items/{id}"), output) + assertTrue(output.contains("/users/{id}"), output) + } + @Test fun structurallyValidJwtIsRedactedWithoutRedactingDottedCodecNames() { val jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJl" diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedgerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedgerTest.kt index 7bfdc9f80..6de1bf8f8 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedgerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsRunLedgerTest.kt @@ -6,6 +6,7 @@ import org.junit.rules.TemporaryFolder import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -18,7 +19,12 @@ class DiagnosticsRunLedgerTest { fun publishesOnlyOpaqueTokenAndPersistsIdentityMappingLocally() = runTest { val published = mutableListOf() val root = temporaryFolder.newFolder("ledger") - val ledger = DiagnosticsRunLedger(root, ProcessStateSummaryPublisher { published += it }) + val ledger = DiagnosticsRunLedger( + root, + ProcessStateSummaryPublisher { published += it }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) val context = context("server-secret", "user-secret", "profile-secret", generation = 4) val token = ledger.beginRun(context, processStartedAtEpochMs = 100, captureSessionId = "capture-secret") @@ -30,7 +36,11 @@ class DiagnosticsRunLedgerTest { } assertTrue(token.matches(Regex("[0-9a-f]{32}")), token) - val restored = DiagnosticsRunLedger(root).find(token) + val restored = DiagnosticsRunLedger( + root, + directorySync = {}, + atomicRename = ::testAtomicRename, + ).find(token) assertEquals(context.binding, restored?.binding) assertEquals("profile-secret", restored?.profileId) assertEquals("capture-secret", restored?.captureSessionId) @@ -44,6 +54,8 @@ class DiagnosticsRunLedgerTest { noBackupFilesDir = temporaryFolder.newFolder("bounded"), maxRecords = 2, tokenFactory = { "a".repeat(31) + (tokenCounter++).toString(16) }, + directorySync = {}, + atomicRename = ::testAtomicRename, ) val first = ledger.beginRun(context("s", "u", null, 1), 1, "c1") @@ -58,7 +70,11 @@ class DiagnosticsRunLedgerTest { @Test fun purgeBindingRemovesOnlyOwnedRuns() = runTest { - val ledger = DiagnosticsRunLedger(temporaryFolder.newFolder("purge")) + val ledger = DiagnosticsRunLedger( + temporaryFolder.newFolder("purge"), + directorySync = {}, + atomicRename = ::testAtomicRename, + ) val a = ledger.beginRun(context("server-a", "user", null, 1), 1, "a") val b = ledger.beginRun(context("server-b", "user", null, 1), 2, "b") @@ -68,6 +84,58 @@ class DiagnosticsRunLedgerTest { assertEquals("b", ledger.find(b)?.captureSessionId) } + @Test + fun clearRemovesCommittedAndTemporaryLedgersAndPropagatesDirectorySyncFailure() = runTest { + val root = temporaryFolder.newFolder("strict-clear") + var failSync = false + val ledger = DiagnosticsRunLedger( + root, + directorySync = { if (failSync) error("injected ledger fsync failure") }, + atomicRename = ::testAtomicRename, + ) + ledger.beginRun(context("server", "user", null, 1), 1, "capture") + val temporary = root.resolve("client-diagnostics/run-ledger.json.tmp") + temporary.writeText("private stale bytes") + failSync = true + + assertFailsWith { ledger.clear() } + assertFalse(root.resolve("client-diagnostics/run-ledger.json").exists()) + + failSync = false + ledger.clear() + assertFalse(temporary.exists()) + } + + @Test + fun failedAtomicReplacementPreservesPriorLedgerAndSyncedTemporary() = runTest { + val root = temporaryFolder.newFolder("rename-failure") + var failReplacement = false + var tokenCounter = 0 + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { "b".repeat(31) + (tokenCounter++).toString(16) }, + directorySync = {}, + atomicRename = { source, target -> + if (failReplacement && target.exists()) error("simulated atomic rename failure") + testAtomicRename(source, target) + }, + ) + val first = ledger.beginRun(context("server", "user", null, 1), 1, "first") + failReplacement = true + + assertFailsWith { + ledger.beginRun(context("server", "user", null, 1), 2, "second") + } + + val restored = DiagnosticsRunLedger( + root, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + assertEquals("first", restored.find(first)?.captureSessionId) + assertTrue(root.resolve("client-diagnostics/run-ledger.json.tmp").isFile) + } + private fun context( server: String, user: String, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStoreTest.kt index c8a6816c0..c9b402932 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsSettingsStoreTest.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.common.diagnostics import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -9,8 +11,13 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus +import org.prairieserver.prairie.network.api.HostedDiagnosticsAvailability +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities @OptIn(ExperimentalCoroutinesApi::class) class DiagnosticsSettingsStoreTest { @@ -73,11 +80,13 @@ class DiagnosticsSettingsStoreTest { store.setConsent(bindingA, DiagnosticsConsentMode.NEVER, noticeVersion = 1) assertEquals(listOf(bindingA), purger.bindings) + assertEquals(listOf(bindingA to true), purger.calls) assertFalse(store.debugLogging()) store.setConsent(bindingA, DiagnosticsConsentMode.ALWAYS, noticeVersion = 1) store.purgeBinding(bindingA) assertEquals(listOf(bindingA, bindingA), purger.bindings) + assertEquals(listOf(bindingA to true, bindingA to true), purger.calls) assertEquals(DiagnosticsConsentMode.ASK, store.consent(bindingA, 1).mode) } @@ -91,6 +100,229 @@ class DiagnosticsSettingsStoreTest { assertEquals(listOf("other"), store.sentHistory(bindingB).map { it.shortId }) } + @Test + fun lateWorkerSuccessCannotReviveHistoryAfterTurnOffWins() = runTest { + val store = newStore() + + store.setConsent(bindingA, DiagnosticsConsentMode.NEVER, noticeVersion = 1) + store.recordSent(bindingA, "late-ready", sentAtEpochMs = 10, state = "ready") + + assertTrue(store.sentHistory(bindingA).isEmpty()) + } + + @Test + fun hostedDestinationDefaultsOnAndCapabilityCacheContainsNoCredential() = runTest { + val store = newStore() + val capabilities = HostedDiagnosticsCapabilities( + status = HostedDiagnosticsAvailability.AVAILABLE, + collectorId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + acceptedSchemaVersions = listOf(1), + maxBundleBytes = 10L * 1_024 * 1_024, + maxManifestBytes = 64L * 1_024, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + consentNoticeVersion = 1, + ) + + assertEquals(DiagnosticsDestinationKind.HOSTED, store.destinationKind()) + store.setDestinationKind(DiagnosticsDestinationKind.SELF_HOSTED) + assertEquals(DiagnosticsDestinationKind.SELF_HOSTED, store.destinationKind()) + store.cacheHostedCapabilities(capabilities) + assertEquals(capabilities, store.hostedCapabilities()) + } + + @Test + fun serverBindingIndexSurvivesStoreReconstructionAndPurgesOnlyTheTargetWithoutLiveCapture() = runTest { + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-index.preferences_pb") + } + val first = DiagnosticsSettingsStore(dataStore, RecordingBindingPurger()) + first.cacheContext(context(bindingB, "local-b")) + first.cacheContext(context(bindingA, "local-a")) + first.cacheHostedBindingOwner("local-b", "hosted-owner-b") + + val purger = RecordingBindingPurger() + val reconstructed = DiagnosticsSettingsStore(dataStore, purger) + assertEquals(listOf(bindingA), reconstructed.bindingsForLocalServer("local-a")) + assertEquals(listOf(bindingB), reconstructed.bindingsForLocalServer("local-b")) + + reconstructed.purgeLocalServer("local-b") + + assertEquals(listOf(bindingB to false), purger.calls) + assertEquals(listOf(bindingA), reconstructed.bindingsForLocalServer("local-a")) + assertTrue(reconstructed.bindingsForLocalServer("local-b").isEmpty()) + assertNull(reconstructed.hostedBindingOwner("local-b")) + } + + @Test + fun failedServerPurgeRetainsTheDurableIndexForRetry() = runTest { + val binding = bindingB + val purger = DiagnosticsBindingPurger { _, _ -> error("injected") } + val store = newStore(purger) + store.cacheContext(context(binding, "local-b")) + + assertFailsWith { store.purgeLocalServer("local-b") } + + assertEquals(listOf(binding), store.bindingsForLocalServer("local-b")) + } + + @Test + fun removingTheFirstUnindexedLegacyServerPurgesAllOnceAndMarksTheIndexComplete() = runTest { + var allEvidenceCalls = 0 + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-legacy-unindexed.preferences_pb") + } + val store = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = RecordingBindingPurger(), + allEvidencePurger = DiagnosticsAllEvidencePurger { includeLiveCapture -> + assertFalse(includeLiveCapture) + allEvidenceCalls += 1 + }, + ) + + store.purgeLocalServer("legacy-inactive-server") + + assertEquals(1, allEvidenceCalls) + assertTrue(store.bindingsForLocalServer("legacy-inactive-server").isEmpty()) + + store.cacheContext(context(bindingA, "local-a")) + store.purgeLocalServer("new-server-without-diagnostics") + + assertEquals(1, allEvidenceCalls, "the legacy fallback must never erase unrelated evidence twice") + assertEquals(listOf(bindingA), store.bindingsForLocalServer("local-a")) + } + + @Test + fun accountScopedPurgeNeverUsesLegacyFallbackAgainstOtherServers() = runTest { + var allEvidenceCalls = 0 + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-scoped-purge.preferences_pb") + } + val purger = RecordingBindingPurger() + val store = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = purger, + allEvidencePurger = DiagnosticsAllEvidencePurger { allEvidenceCalls += 1 }, + ) + store.cacheContext(context(bindingB, "local-b")) + + store.purgeLocalServer( + localServerId = "local-a", + allowLegacyAllEvidenceFallback = false, + ) + + assertEquals(0, allEvidenceCalls) + assertTrue(purger.calls.isEmpty()) + assertEquals(listOf(bindingB), store.bindingsForLocalServer("local-b")) + } + + @Test + fun crashAfterNeverCommitLeavesDurableErasureForReconstructedStore() = runTest { + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-never-crash.preferences_pb") + } + val firstPurger = RecordingBindingPurger() + val first = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = firstPurger, + afterErasureIntentPersisted = { error("simulated process death") }, + ) + + assertFailsWith { + first.setConsent(bindingA, DiagnosticsConsentMode.NEVER, noticeVersion = 1) + } + assertEquals(DiagnosticsConsentMode.NEVER, first.consent(bindingA, 1).mode) + assertEquals(listOf(bindingA), first.pendingErasureBindings()) + assertTrue(firstPurger.calls.isEmpty()) + + val recoveredPurger = RecordingBindingPurger() + val recovered = DiagnosticsSettingsStore(dataStore, recoveredPurger) + recovered.retryPendingErasures(currentBinding = bindingA) + + assertEquals(listOf(bindingA to true), recoveredPurger.calls) + assertTrue(recovered.pendingErasureBindings().isEmpty()) + assertEquals(DiagnosticsConsentMode.NEVER, recovered.consent(bindingA, 1).mode) + } + + @Test + fun purgeFailureKeepsNeverErasurePendingUntilRestartRetrySucceeds() = runTest { + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-never-failure.preferences_pb") + } + val failing = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = DiagnosticsBindingPurger { _, _ -> error("disk unavailable") }, + ) + + assertFailsWith { + failing.setConsent(bindingA, DiagnosticsConsentMode.NEVER, noticeVersion = 1) + } + assertEquals(DiagnosticsConsentMode.NEVER, failing.consent(bindingA, 1).mode) + assertEquals(listOf(bindingA), failing.pendingErasureBindings()) + + val recoveredPurger = RecordingBindingPurger() + val recovered = DiagnosticsSettingsStore(dataStore, recoveredPurger) + recovered.retryPendingErasures(currentBinding = bindingA) + + assertEquals(listOf(bindingA to true), recoveredPurger.calls) + assertTrue(recovered.pendingErasureBindings().isEmpty()) + } + + @Test + fun corruptIndexesFailClosedThenRepairWithoutPermanentlyBlockingConsent() = runTest { + val scope = TestScope(UnconfinedTestDispatcher(testScheduler)) + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { + temporaryFolder.newFile("diagnostics-corrupt-index.preferences_pb") + } + dataStore.edit { preferences -> + preferences[stringPreferencesKey("diagnostics.erasure_pending")] = "not-json" + preferences[stringPreferencesKey("diagnostics.binding_index")] = "not-json" + } + var allEvidencePurges = 0 + val store = DiagnosticsSettingsStore( + dataStore = dataStore, + bindingPurger = RecordingBindingPurger(), + allEvidencePurger = DiagnosticsAllEvidencePurger { includeLiveCapture -> + assertTrue(includeLiveCapture) + allEvidencePurges += 1 + }, + ) + val pending = PendingReportBinding( + serverInstanceId = bindingA.serverInstanceId, + accountUserId = bindingA.accountUserId, + ownershipGeneration = 1, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + + assertFalse(store.permitsUpload(pending, noticeVersion = 1, requireAlwaysConsent = false)) + assertTrue(store.bindingsForLocalServer("local-a").isEmpty()) + + store.setConsent(bindingA, DiagnosticsConsentMode.ASK, noticeVersion = 1) + + assertEquals(1, allEvidencePurges) + assertTrue(store.pendingErasureBindings().isEmpty()) + assertTrue(store.permitsUpload(pending, noticeVersion = 1, requireAlwaysConsent = false)) + } + + private fun context(binding: DiagnosticsBinding, localServerId: String) = DiagnosticsCaptureContext( + binding = binding, + profileId = "profile", + profileEligible = true, + noticeVersion = 1, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 0, + acceptedSchemaVersions = setOf(1), + maxBundleBytes = 1_024, + maxManifestBytes = 1_024, + retentionDays = 7, + localServerId = localServerId, + ) + private fun newStore( purger: DiagnosticsBindingPurger = RecordingBindingPurger(), historyLimit: Int = 20, @@ -103,9 +335,10 @@ class DiagnosticsSettingsStoreTest { } private class RecordingBindingPurger : DiagnosticsBindingPurger { - val bindings = mutableListOf() - override suspend fun purge(binding: DiagnosticsBinding) { - bindings += binding + val calls = mutableListOf>() + val bindings: List get() = calls.map { it.first } + override suspend fun purge(binding: DiagnosticsBinding, includeLiveCapture: Boolean) { + calls += binding to includeLiveCapture } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsTestFileOperations.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsTestFileOperations.kt new file mode 100644 index 000000000..061e1e55f --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsTestFileOperations.kt @@ -0,0 +1,14 @@ +package org.prairieserver.prairie.common.diagnostics + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +internal fun testAtomicRename(source: File, target: File) { + Files.move( + source.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploaderTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploaderTest.kt index e394386e8..68bf26229 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploaderTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/DiagnosticsUploaderTest.kt @@ -1,5 +1,9 @@ package org.prairieserver.prairie.common.diagnostics +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -20,16 +24,42 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsReport import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResponse import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.DiagnosticsUploadAuthorization +import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.api.DiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApiResult +import org.prairieserver.prairie.network.api.HostedDiagnosticsAvailability +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities +import org.prairieserver.prairie.network.api.HostedDiagnosticsCreateReportRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsCreateReportResponse +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationResponse +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportState +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportStatusResponse import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +@OptIn(ExperimentalCoroutinesApi::class) class DiagnosticsUploaderTest { @get:Rule val temporaryFolder = TemporaryFolder() + @Test + fun hostedWireIdCanonicalizesTheLocalUuidWithoutChangingItsIdentity() { + assertEquals( + "01234567-89ab-4def-8123-456789abcdef", + "0123456789ab4def8123456789abcdef".toHostedWireReportIdOrNull(), + ) + assertNull("not-a-local-report-id".toHostedWireReportIdOrNull()) + } + @Test fun profileSwitchDuringBuildPreventsPost() = runTest { val fixture = fixture() @@ -106,12 +136,72 @@ class DiagnosticsUploaderTest { assertNull(fixture.store.load(fixture.report.id)) } + @Test + fun selfHostedSuccessAfterSignOutCannotDeleteOrRecordOldIdentityEvidence() = runTest { + val fixture = fixture() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + val mutationRequested = CompletableDeferred() + val mutationStarted = CompletableDeferred() + fixture.api.result = DiagnosticsUploadResult.Success(DiagnosticsUploadResponse("report-1", "ABC123")) + fixture.api.onUploadSuspending = { + uploadStarted.complete(Unit) + releaseUpload.await() + } + + val upload = async { fixture.uploader.upload(fixture.report.id) } + uploadStarted.await() + val transition = async { + mutationRequested.complete(Unit) + fixture.identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationStarted.complete(Unit) + fixture.identity.current = null + } + } + mutationRequested.await() + runCurrent() + assertFalse(mutationStarted.isCompleted, "sign out must wait for an already-started POST") + releaseUpload.complete(Unit) + transition.await() + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, upload.await()) + assertEquals(1, fixture.api.uploadCalls) + assertNotNull(fixture.store.load(fixture.report.id)) + assertTrue(fixture.sent.shortIds.isEmpty()) + } + + @Test + fun selfHostedServerSwitchThatWinsBeforePostPreventsAnyNetworkCall() = runTest { + val fixture = fixture() + val preflightResolveStarted = CompletableDeferred() + val releasePreflightResolve = CompletableDeferred() + fixture.identity.beforeReturn = { call -> + if (call == 2) { + preflightResolveStarted.complete(Unit) + releasePreflightResolve.await() + } + } + + val upload = async { fixture.uploader.upload(fixture.report.id) } + preflightResolveStarted.await() + fixture.identityTransitions.changing(IdentityTransitionKind.SERVER_SWITCH) { + fixture.identity.current = fixture.identity.current?.copy( + binding = DiagnosticsBinding("server-2", "user-2"), + ownershipGeneration = fixture.identityTransitions.generation.value, + ) + } + releasePreflightResolve.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, upload.await()) + assertEquals(0, fixture.api.uploadCalls) + assertNotNull(fixture.store.load(fixture.report.id)) + } + @Test fun anotherEligibleProfileOnTheSameAccountCanSendWithCapturedAttribution() = runTest { val fixture = fixture() fixture.identity.current = fixture.identity.current?.copy( profileId = "profile-2", - ownershipGeneration = 8, ) fixture.api.result = DiagnosticsUploadResult.Success(DiagnosticsUploadResponse("report-1", "ABC123")) @@ -233,7 +323,7 @@ class DiagnosticsUploaderTest { "archive_mismatch" to DiagnosticsUploadDecision.KeptInvalid, "stale_report" to DiagnosticsUploadDecision.KeptInvalid, "stale_consent" to DiagnosticsUploadDecision.KeptConsentReviewRequired, - "unauthorized" to DiagnosticsUploadDecision.KeptInvalid, + "unauthorized" to DiagnosticsUploadDecision.KeptRetryable, "api_key_not_allowed" to DiagnosticsUploadDecision.KeptInvalid, "forbidden" to DiagnosticsUploadDecision.KeptInvalid, ) @@ -289,10 +379,635 @@ class DiagnosticsUploaderTest { assertEquals(PendingReportStatus.RETRYABLE, assertNotNull(fixture.store.load(fixture.report.id)).state.status) } + @Test + fun hostedProcessingIsRetainedAndPolledBeforeCapabilityOrAutomaticConsentGates() = runTest { + val fixture = hostedFixture() + + assertEquals( + DiagnosticsUploadDecision.KeptConsentReviewRequired, + fixture.uploader.uploadAutomatically(fixture.report.id), + ) + assertEquals(0, fixture.api.capabilitiesCalls) + assertTrue(fixture.api.createdRequests.isEmpty()) + + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + val processing = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals("ABC123", processing.state.hostedRemoteShortId) + assertEquals(PendingReportStatus.PROCESSING, processing.state.status) + assertNull(processing.state.errorCode) + assertTrue(fixture.sent.shortIds.isEmpty(), "processing is not a durable success for the user") + + fixture.api.capabilities = fixture.api.capabilities.copy(status = HostedDiagnosticsAvailability.DISABLED) + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + assertEquals( + DiagnosticsUploadDecision.Uploaded("ABC123"), + fixture.uploader.uploadAutomatically(fixture.report.id), + ) + assertEquals(1, fixture.api.capabilitiesCalls, "an accepted report must poll before live capability gating") + assertEquals(listOf("ready"), fixture.sent.states) + assertNull(fixture.store.load(fixture.report.id)) + assertEquals(fixture.report.binding.binding, fixture.store.hostedReadyBinding(fixture.report.id)) + } + + @Test + fun firstHostedUploadRequiresLiveSourceAccountAttestation() = runTest { + val fixture = hostedFixture() + fixture.identity.uploadAttestationAllowed = false + + val decision = fixture.uploader.upload(fixture.report.id) + + assertEquals(DiagnosticsUploadDecision.KeptUnavailable, decision) + assertEquals(1, fixture.identity.uploadAttestationCalls) + assertEquals(1, fixture.api.capabilitiesCalls) + assertTrue(fixture.api.createdRequests.isEmpty()) + assertNotNull(fixture.store.load(fixture.report.id)) + } + + @Test + fun hostedProcessingReadyRaceWithDeleteKeepsIntentUntilRemoteErasure() = runTest { + val fixture = hostedFixture() + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + val statusStarted = CompletableDeferred() + val releaseStatus = CompletableDeferred() + fixture.api.beforeReportStatus = { + statusStarted.complete(Unit) + releaseStatus.await() + } + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + + val polling = async { fixture.uploader.uploadAutomatically(fixture.report.id) } + statusStarted.await() + fixture.store.stageHostedDeletionAndDelete(fixture.report.id) + assertEquals(listOf(fixture.report.id), fixture.store.hostedDeletionIntents()) + releaseStatus.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, polling.await()) + assertNull(fixture.store.load(fixture.report.id)) + assertEquals(listOf(fixture.report.id), fixture.store.hostedDeletionIntents()) + val deleter = DefaultHostedDiagnosticsReportDeleter(fixture.api, fixture.installations) + assertTrue(deleter.delete(fixture.report.id)) + fixture.store.completeHostedDeletion(fixture.report.id) + assertTrue(fixture.store.hostedDeletionIntents().isEmpty()) + assertEquals(listOf(requireNotNull(fixture.report.id.toHostedWireReportIdOrNull())), fixture.api.deleteReportIds) + assertEquals( + DiagnosticsUploadDecision.KeptInvalid, + fixture.uploader.uploadAutomatically(fixture.report.id), + "evidence covered by a winning deletion must never become re-uploadable", + ) + } + + @Test + fun hostedReadyAfterAccountReplacementCannotReviveOldBookkeeping() = runTest { + val fixture = hostedFixture() + val statusStarted = CompletableDeferred() + val releaseStatus = CompletableDeferred() + fixture.api.beforeReportStatus = { + statusStarted.complete(Unit) + releaseStatus.await() + } + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + + val upload = async { fixture.uploader.upload(fixture.report.id) } + statusStarted.await() + val mutationStarted = CompletableDeferred() + val transition = async { + fixture.identityTransitions.changing(IdentityTransitionKind.ACCOUNT_REPLACE) { + mutationStarted.complete(Unit) + fixture.store.purge(fixture.report.binding.binding) + fixture.identity.current = fixture.identity.current?.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "replacement-device"), + ownershipGeneration = fixture.identityTransitions.generation.value, + ) + } + } + runCurrent() + assertFalse(mutationStarted.isCompleted, "account replacement must wait for an already-started status call") + releaseStatus.complete(Unit) + transition.await() + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, upload.await()) + assertNull(fixture.store.load(fixture.report.id)) + assertEquals(listOf(fixture.report.id), fixture.store.hostedDeletionIntents()) + assertTrue(fixture.sent.shortIds.isEmpty()) + assertNull(fixture.store.retryAfterDeadline(fixture.report.binding.binding)) + } + + @Test + fun hostedRetryResponseAfterTurnOffCannotRecreateRetryAfterMetadata() = runTest { + val fixture = hostedFixture() + val uploadStarted = CompletableDeferred() + val releaseUpload = CompletableDeferred() + val mutationRequested = CompletableDeferred() + val mutationStarted = CompletableDeferred() + fixture.api.beforeUploadBundle = { + uploadStarted.complete(Unit) + releaseUpload.await() + } + fixture.api.uploadFailure = HostedDiagnosticsApiResult.Failure( + httpStatus = 429, + errorCode = "rate_limited", + message = "slow down", + retryAfterSeconds = 120, + ) + + val upload = async { fixture.uploader.upload(fixture.report.id) } + uploadStarted.await() + val transition = async { + mutationRequested.complete(Unit) + fixture.identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + mutationStarted.complete(Unit) + fixture.store.purge(fixture.report.binding.binding) + fixture.identity.current = null + } + } + mutationRequested.await() + runCurrent() + assertFalse(mutationStarted.isCompleted, "turn off must wait for an already-started PUT") + releaseUpload.complete(Unit) + transition.await() + + assertEquals(DiagnosticsUploadDecision.KeptRetryable, upload.await()) + assertNull(fixture.store.load(fixture.report.id)) + assertNull(fixture.store.retryAfterDeadline(fixture.report.binding.binding)) + assertEquals(listOf(fixture.report.id), fixture.store.hostedDeletionIntents()) + } + + @Test + fun hostedDeleteRetainsIntentWhenUuidIsLiveUnderAnotherInstallation() = runTest { + val fixture = hostedFixture( + credentials = HostedDiagnosticsCredentials("old-installation", "old-installation-token"), + ) + fixture.api.deleteResult = HostedDiagnosticsApiResult.Failure( + httpStatus = 404, + errorCode = "report_not_found", + message = "report is not owned by this installation", + ) + val deleter = DefaultHostedDiagnosticsReportDeleter(fixture.api, fixture.installations) + + assertFalse(deleter.delete(fixture.report.id)) + + assertEquals(listOf("old-installation-token"), fixture.api.deleteInstallationTokens) + assertEquals(listOf(requireNotNull(fixture.report.id.toHostedWireReportIdOrNull())), fixture.api.deleteReportIds) + } + + @Test + fun hostedIdentityChangeWhileInstallationRegistrationIsSuspendedPreventsCreate() = runTest { + val fixture = hostedFixture(credentials = null) + val registrationStarted = CompletableDeferred() + val releaseRegistration = CompletableDeferred() + fixture.api.beforeCreateInstallation = { + registrationStarted.complete(Unit) + releaseRegistration.await() + } + + val uploading = async { fixture.uploader.upload(fixture.report.id) } + registrationStarted.await() + fixture.identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + fixture.identity.current = fixture.identity.current?.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "different-account"), + ownershipGeneration = fixture.identityTransitions.generation.value, + ) + } + releaseRegistration.complete(Unit) + + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, uploading.await()) + assertTrue(fixture.api.createReportIds.isEmpty()) + assertNotNull(fixture.store.load(fixture.report.id)) + assertTrue( + fixture.store.loadHostedEnvelope(fixture.report.id) is HostedEnvelopeLoadResult.Available, + "the exact sanitized retry envelope remains durable", + ) + } + + @Test + fun hostedCreateRequestSerializesAgainstIdentityMutation() = runTest { + val fixture = hostedFixture() + val createStarted = CompletableDeferred() + val releaseCreate = CompletableDeferred() + val mutationRequested = CompletableDeferred() + val mutationStarted = CompletableDeferred() + var createReturned = false + fixture.api.beforeCreateReport = { + createStarted.complete(Unit) + releaseCreate.await() + createReturned = true + } + + val uploading = async { fixture.uploader.upload(fixture.report.id) } + createStarted.await() + val transition = async { + mutationRequested.complete(Unit) + fixture.identityTransitions.changing(IdentityTransitionKind.SERVER_SWITCH) { + assertTrue(createReturned, "identity mutation must wait for the guarded create request") + mutationStarted.complete(Unit) + fixture.identity.current = fixture.identity.current?.copy( + binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "different-server-account"), + ownershipGeneration = fixture.identityTransitions.generation.value, + ) + } + } + mutationRequested.await() + runCurrent() + assertFalse(mutationStarted.isCompleted) + + releaseCreate.complete(Unit) + assertEquals(DiagnosticsUploadDecision.KeptIdentityChanged, uploading.await()) + transition.await() + assertEquals(1, fixture.api.createReportIds.size) + assertTrue(fixture.api.uploadReportIds.isEmpty(), "PUT must not start after identity mutation wins") + assertTrue(mutationStarted.isCompleted) + } + + @Test + fun hostedOrdinaryCredentialRefreshDuringRegistrationRemainsAllowed() = runTest { + val fixture = hostedFixture(credentials = null) + fixture.api.beforeCreateInstallation = { + fixture.identity.current = fixture.identity.current?.copy( + credentialFingerprint = "rotated-refresh-credential", + ) + } + + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + assertEquals(1, fixture.api.createReportIds.size) + } + + @Test + fun hostedExactCreateEnvelopeIsFrozenUntilStaleConsentThenReframedFromSanitizedEvidence() = runTest { + val fixture = hostedFixture( + artifacts = mapOf( + "device.json" to """{"token":"old-source-token","safe":"kept"}""".encodeToByteArray(), + ), + redactionValues = listOf("old-source-token"), + ) + fixture.api.createReportNetworkErrorsRemaining = 1 + + assertEquals(DiagnosticsUploadDecision.KeptRetryable, fixture.uploader.upload(fixture.report.id)) + val originalEnvelope = (fixture.store.loadHostedEnvelope(fixture.report.id) as HostedEnvelopeLoadResult.Available).bundle + assertTrue(originalEnvelope.sanitizedEntries.values.none { it.decodeToString().contains("old-source-token") }) + assertEquals(1, fixture.redactionTokens.calls) + + fixture.redactionTokens.values = listOf("rotated-token-that-must-not-rebuild-evidence") + fixture.api.capabilities = fixture.api.capabilities.copy( + consentNoticeVersion = 2, + maxBundleBytes = 1, + maxManifestBytes = 1, + ) + fixture.api.createReportFailure = HostedDiagnosticsApiResult.Failure( + httpStatus = 409, + errorCode = "stale_consent", + message = "consent notice changed", + ) + + assertEquals(DiagnosticsUploadDecision.KeptConsentReviewRequired, fixture.uploader.upload(fixture.report.id)) + val retriedEnvelope = (fixture.store.loadHostedEnvelope(fixture.report.id) as HostedEnvelopeLoadResult.Available).bundle + assertContentEquals(originalEnvelope.manifestBytes, retriedEnvelope.manifestBytes) + assertContentEquals(originalEnvelope.bytes, retriedEnvelope.bytes) + assertEquals(fixture.api.createdRequests[0], fixture.api.createdRequests[1]) + assertEquals(1, fixture.redactionTokens.calls, "an ambiguous exact retry must not read rotating secrets again") + assertTrue(assertNotNull(fixture.store.load(fixture.report.id)).state.hostedConsentRefreshRequired) + + fixture.api.createReportFailure = null + fixture.api.capabilities = fixture.api.capabilities.copy( + maxBundleBytes = 10L * 1_024 * 1_024, + maxManifestBytes = 64L * 1_024, + ) + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + val reframed = (fixture.store.loadHostedEnvelope(fixture.report.id) as HostedEnvelopeLoadResult.Available).bundle + assertEquals(2, reframed.manifest.consent.noticeVersion) + assertFalse(reframed.manifest.archive.sha256 == originalEnvelope.manifest.archive.sha256) + assertTrue(reframed.sanitizedEntries.values.none { it.decodeToString().contains("rotated-token") }) + assertEquals(1, fixture.redactionTokens.calls, "reframing must use only the cached sanitized members") + assertEquals(2, fixture.api.createdRequests[2].manifest.consent.noticeVersion) + + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + assertEquals( + DiagnosticsUploadDecision.Uploaded("ABC123"), + fixture.uploader.uploadAutomatically(fixture.report.id), + ) + assertNull(fixture.store.load(fixture.report.id)) + } + + @Test + fun hostedCreateConflictReconcilesTheDurablyAcceptedReport() = runTest { + val fixture = hostedFixture() + fixture.api.createReportFailure = HostedDiagnosticsApiResult.Failure( + httpStatus = 409, + errorCode = "report_conflict", + message = "report already exists", + ) + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.PROCESSING), + ) + + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + + val retained = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals(PendingReportStatus.PROCESSING, retained.state.status) + assertEquals("ABC123", retained.state.hostedRemoteShortId) + assertEquals(listOf(requireNotNull(fixture.report.id.toHostedWireReportIdOrNull())), fixture.api.statusReportIds) + } + + @Test + fun ambiguousHostedPutFailuresRetryTheExactCreateEnvelopeWithAFreshToken() = runTest { + listOf( + HostedDiagnosticsApiResult.Failure(401, "invalid_upload_token", "upload claim expired"), + HostedDiagnosticsApiResult.Failure(409, "upload_cancelled", "stale claim was recovered"), + HostedDiagnosticsApiResult.Failure(202, "invalid_response", "accepted receipt was malformed"), + ).forEach { ambiguousFailure -> + val fixture = hostedFixture() + fixture.api.nextUploadToken = "expired-upload-token" + fixture.api.uploadFailure = ambiguousFailure + + assertEquals( + DiagnosticsUploadDecision.KeptRetryable, + fixture.uploader.upload(fixture.report.id), + ambiguousFailure.errorCode, + ) + assertNotNull(fixture.store.load(fixture.report.id)) + + fixture.api.uploadFailure = null + fixture.api.nextUploadToken = "fresh-upload-token" + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + assertEquals( + DiagnosticsUploadDecision.Uploaded("ABC123"), + fixture.uploader.upload(fixture.report.id), + ambiguousFailure.errorCode, + ) + assertEquals(fixture.api.createdRequests[0], fixture.api.createdRequests[1]) + assertEquals(listOf("expired-upload-token", "fresh-upload-token"), fixture.api.uploadTokens) + assertNull(fixture.store.load(fixture.report.id)) + } + } + + @Test + fun invalidHostedInstallationRotatesCredentialAndPreservesRetryEnvelope() = runTest { + val fixture = hostedFixture( + credentials = HostedDiagnosticsCredentials("stale-installation", "stale-installation-token"), + ) + fixture.api.invalidInstallationToken = "stale-installation-token" + + assertEquals( + DiagnosticsUploadDecision.KeptRetryable, + fixture.uploader.upload(fixture.report.id), + ) + assertEquals(listOf("stale-installation-token"), fixture.api.createReportTokens) + assertEquals(1, fixture.api.installationCreateCalls) + assertEquals( + HostedDiagnosticsCredentials("installation-1", "installation-token"), + fixture.installations.current(), + ) + assertEquals( + listOf( + HostedDiagnosticsCredentials("installation-1", "installation-token"), + HostedDiagnosticsCredentials("stale-installation", "stale-installation-token"), + ), + fixture.installations.credentialsForOutstanding(), + ) + assertNotNull(fixture.store.load(fixture.report.id)?.state?.hostedEnvelopeGeneration) + + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + assertEquals(listOf("stale-installation-token", "installation-token"), fixture.api.createReportTokens) + } + + @Test + fun hostedStatusUsesRetainedCredentialAfterInstallationRotation() = runTest { + val stale = HostedDiagnosticsCredentials("stale-installation", "stale-installation-token") + val fixture = hostedFixture(credentials = stale) + assertEquals(DiagnosticsUploadDecision.HostedProcessing("ABC123"), fixture.uploader.upload(fixture.report.id)) + assertNotNull(fixture.installations.recoverIfInvalid(stale)) + fixture.api.reportStatusResultsByToken["installation-token"] = HostedDiagnosticsApiResult.Failure( + httpStatus = 404, + errorCode = "report_not_found", + message = "report is not owned by this installation", + ) + fixture.api.reportStatusResultsByToken["stale-installation-token"] = HostedDiagnosticsApiResult.Success( + fixture.api.status(fixture.report, HostedDiagnosticsReportState.READY), + ) + + assertEquals( + DiagnosticsUploadDecision.Uploaded("ABC123"), + fixture.uploader.uploadAutomatically(fixture.report.id), + ) + assertEquals( + listOf("stale-installation-token", "installation-token", "stale-installation-token"), + fixture.api.statusInstallationTokens, + ) + } + + @Test + fun hostedRejectedAndInternalWireStatesRetainLocalEvidence() = runTest { + listOf( + HostedDiagnosticsReportState.REJECTED to "privacy_artifact_rejected", + HostedDiagnosticsReportState.UPLOADED to "invalid_response", + ).forEach { (remoteState, expectedCode) -> + val fixture = hostedFixture() + assertEquals( + DiagnosticsUploadDecision.HostedProcessing("ABC123"), + fixture.uploader.upload(fixture.report.id), + ) + fixture.api.reportStatusResultOverride = HostedDiagnosticsApiResult.Success( + fixture.api.status( + fixture.report, + remoteState, + errorCode = if (remoteState == HostedDiagnosticsReportState.REJECTED) expectedCode else null, + ), + ) + + assertEquals(DiagnosticsUploadDecision.KeptInvalid, fixture.uploader.uploadAutomatically(fixture.report.id)) + val retained = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals(PendingReportStatus.PERMANENT_FAILURE, retained.state.status) + assertEquals(expectedCode, retained.state.errorCode) + } + } + + @Test + fun hostedExplicitReceiptIdentityMismatchIsPermanentAndRetainsLocalEvidence() = runTest { + val fixture = hostedFixture() + fixture.api.uploadReceiptOverride = HostedDiagnosticsReportStatusResponse( + reportId = "11111111-1111-4111-8111-111111111111", + shortId = "ABC123", + state = HostedDiagnosticsReportState.PROCESSING, + ) + + assertEquals(DiagnosticsUploadDecision.KeptInvalid, fixture.uploader.upload(fixture.report.id)) + val retained = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals(PendingReportStatus.PERMANENT_FAILURE, retained.state.status) + assertEquals("invalid_response", retained.state.errorCode) + assertTrue(fixture.api.statusReportIds.isEmpty(), "a mismatched success receipt must not be polled") + } + + @Test + fun hostedPrivacyPolicyErrorsArePermanentButNeverDeleteLocalEvidence() = runTest { + listOf( + "hosted_consent_required", + "privacy_artifact_rejected", + "upload_attempt_limit_exceeded", + ).forEach { errorCode -> + val fixture = hostedFixture() + fixture.api.createReportFailure = HostedDiagnosticsApiResult.Failure( + httpStatus = 422, + errorCode = errorCode, + message = "collector rejected the envelope", + ) + + assertEquals(DiagnosticsUploadDecision.KeptInvalid, fixture.uploader.upload(fixture.report.id), errorCode) + val retained = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals(PendingReportStatus.PERMANENT_FAILURE, retained.state.status) + assertEquals(errorCode, retained.state.errorCode) + } + } + + @Test + fun hostedManifestAndCompressionLimitsMapToTooLargeAndRetainLocalEvidence() = runTest { + listOf( + 413 to "manifest_too_large", + 422 to "compression_ratio_exceeded", + ).forEach { (httpStatus, errorCode) -> + val fixture = hostedFixture() + fixture.api.createReportFailure = HostedDiagnosticsApiResult.Failure( + httpStatus = httpStatus, + errorCode = errorCode, + message = "collector size policy rejected the envelope", + ) + + assertEquals(DiagnosticsUploadDecision.KeptTooLarge, fixture.uploader.upload(fixture.report.id), errorCode) + val retained = assertNotNull(fixture.store.load(fixture.report.id)) + assertEquals(PendingReportStatus.PERMANENT_FAILURE, retained.state.status) + assertEquals(errorCode, retained.state.errorCode) + } + } + + private fun hostedFixture( + artifacts: Map = mapOf("device.json" to "{}".encodeToByteArray()), + redactionValues: List = listOf("source-access"), + credentials: HostedDiagnosticsCredentials? = HostedDiagnosticsCredentials( + "installation-1", + "installation-token", + ), + ): HostedFixture { + val store = FilePendingReportStore( + temporaryFolder.newFolder(), + nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val binding = DiagnosticsBinding(HOSTED_DIAGNOSTICS_COLLECTOR_ID, "anonymous-hosted-device") + val hostedManifest = manifest().copy( + report = manifest().report.copy(profileId = null), + destination = DiagnosticsDestination(HOSTED_DIAGNOSTICS_COLLECTOR_ID), + consent = DiagnosticsConsent(ManifestConsentMode.MANUAL, 1), + playbackSessionIds = emptyList(), + ) + val report = store.save( + PendingReportCapture( + binding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = binding.accountUserId, + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + manifest = hostedManifest, + artifacts = artifacts, + fingerprint = "hosted-fingerprint", + capturedAtEpochMs = CAPTURED_AT, + ), + ) + val hostedApi = FakeHostedDiagnosticsApi() + val environment = ExitReportEnvironment( + appVersion = "1.0", + appBuild = "1", + platform = DiagnosticsPlatform.ANDROID, + osVersion = "36", + deviceSummary = hostedManifest.deviceSummary, + ) + val installations = HostedDiagnosticsInstallationManager( + InMemoryHostedCredentialStore(credentials), + hostedApi, + environment, + ) + val sent = FakeSentRecorder() + val redactionTokens = RecordingRedactionTokenProvider(redactionValues) + val staleConsent = FakeStaleConsentHandler() + val identityTransitions = DefaultIdentityTransitionBarrier() + val identity = FakeIdentityResolver( + DiagnosticsCaptureContext( + binding = binding, + profileId = null, + profileEligible = true, + noticeVersion = 1, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 0, + localServerId = "source-server", + credentialFingerprint = "source-credential", + sourceProfileId = "adult-source-profile", + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + ) + val uploader = DefaultDiagnosticsUploader( + reports = store, + identity = identity, + identityTransitions = identityTransitions, + bundleBuilder = FileDiagnosticsBundleBuilder(), + api = FakeDiagnosticsApi(), + hostedApi = hostedApi, + hostedInstallations = installations, + hostedCapabilities = HostedDiagnosticsCapabilitiesRepository(InMemoryHostedCapabilitiesStore(), hostedApi), + redactionTokens = redactionTokens, + selfHostedAuthorization = DiagnosticsSelfHostedAuthorizationProvider { null }, + sentRecorder = sent, + consentProvider = FakeConsentProvider(DiagnosticsConsentMode.ASK), + staleConsentHandler = staleConsent, + nowMs = { CAPTURED_AT + 1_000 }, + ) + return HostedFixture( + store, + report, + hostedApi, + installations, + redactionTokens, + sent, + staleConsent, + identity, + identityTransitions, + uploader, + ) + } + private fun fixture(maxBundleBytes: Long = 1_024 * 1_024): Fixture { val store = FilePendingReportStore( noBackupFilesDir = temporaryFolder.newFolder(), nowMs = { CAPTURED_AT }, + directorySync = {}, + atomicRename = ::testAtomicRename, ) val report = store.save( PendingReportCapture( @@ -309,18 +1024,29 @@ class DiagnosticsUploaderTest { val sent = FakeSentRecorder() val consent = FakeConsentProvider() val staleConsent = FakeStaleConsentHandler() + val identityTransitions = DefaultIdentityTransitionBarrier() val uploader = DefaultDiagnosticsUploader( reports = store, identity = identity, + identityTransitions = identityTransitions, bundleBuilder = builder, api = api, - redactionTokens = DiagnosticsRedactionTokenProvider { listOf("secret-token") }, + redactionTokens = DiagnosticsRedactionTokenProvider { _ -> listOf("secret-token") }, + selfHostedAuthorization = DiagnosticsSelfHostedAuthorizationProvider { + DiagnosticsUploadAuthorization( + serverId = "local-server-1", + serverUrl = "https://silo.example", + accessToken = "access-token", + activeProfileId = identity.current?.profileId, + identityGeneration = identityTransitions.generation.value, + ) + }, sentRecorder = sent, consentProvider = consent, staleConsentHandler = staleConsent, nowMs = { CAPTURED_AT + 1_000 }, ) - return Fixture(store, report, identity, builder, api, sent, consent, staleConsent, uploader) + return Fixture(store, report, identity, identityTransitions, builder, api, sent, consent, staleConsent, uploader) } private fun context(maxBundleBytes: Long) = DiagnosticsCaptureContext( @@ -329,10 +1055,11 @@ class DiagnosticsUploaderTest { profileEligible = true, noticeVersion = 2, status = DiagnosticsAvailabilityStatus.AVAILABLE, - ownershipGeneration = 7, + ownershipGeneration = 0, acceptedSchemaVersions = setOf(1), maxBundleBytes = maxBundleBytes, maxManifestBytes = 64 * 1_024, + localServerId = "local-server-1", ) private fun manifest() = DiagnosticsManifest( @@ -356,7 +1083,22 @@ class DiagnosticsUploaderTest { ) private class FakeIdentityResolver(var current: DiagnosticsCaptureContext?) : DiagnosticsIdentityResolver { - override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? = current + var beforeReturn: suspend (Int) -> Unit = {} + private var resolveCalls: Int = 0 + var uploadAttestationAllowed = true + var uploadAttestationCalls = 0 + + override suspend fun resolve(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + val captured = current + resolveCalls += 1 + beforeReturn(resolveCalls) + return captured + } + + override suspend fun resolveForUpload(requirePersistentCapture: Boolean): DiagnosticsCaptureContext? { + uploadAttestationCalls += 1 + return if (uploadAttestationAllowed) resolve(requirePersistentCapture) else null + } } private class FakeBundleBuilder : DiagnosticsBundleBuilder { @@ -375,9 +1117,11 @@ class DiagnosticsUploaderTest { private class FakeDiagnosticsApi : DiagnosticsApi { var result: DiagnosticsUploadResult = DiagnosticsUploadResult.NetworkError(IllegalStateException("offline")) var onUpload: () -> Unit = {} + var onUploadSuspending: suspend () -> Unit = {} var uploadCalls = 0 var capturedProfileId: String? = null var capturedManifest: DiagnosticsManifest? = null + var capturedAuthorization: DiagnosticsUploadAuthorization? = null override suspend fun getStatus() = error("unused") override suspend fun upload( manifestJson: ByteArray, @@ -385,6 +1129,7 @@ class DiagnosticsUploaderTest { capturedProfileId: String?, ): DiagnosticsUploadResult { onUpload() + onUploadSuspending() uploadCalls += 1 this.capturedProfileId = capturedProfileId capturedManifest = org.prairieserver.prairie.model.diagnostics.decodeDiagnosticsManifest( @@ -392,12 +1137,188 @@ class DiagnosticsUploaderTest { ) return result } + + override suspend fun upload( + manifestJson: ByteArray, + bundleBytes: ByteArray, + capturedProfileId: String?, + authorization: DiagnosticsUploadAuthorization, + ): DiagnosticsUploadResult { + capturedAuthorization = authorization + return upload(manifestJson, bundleBytes, capturedProfileId) + } } private class FakeSentRecorder : DiagnosticsSentRecorder { val shortIds = mutableListOf() - override suspend fun record(binding: DiagnosticsBinding, shortId: String, sentAtEpochMs: Long) { + val states = mutableListOf() + override suspend fun record(binding: DiagnosticsBinding, shortId: String, sentAtEpochMs: Long, state: String) { shortIds += shortId + states += state + } + } + + private class InMemoryHostedCredentialStore( + private var credentials: HostedDiagnosticsCredentials?, + ) : HostedDiagnosticsCredentialStore { + private var fallback: HostedDiagnosticsCredentials? = null + override suspend fun load(): HostedDiagnosticsCredentials? = credentials + override suspend fun save(credentials: HostedDiagnosticsCredentials) { + this.credentials = credentials + } + override suspend fun loadFallbacks(): List = listOfNotNull(fallback) + override suspend fun saveFallback(credentials: HostedDiagnosticsCredentials) { + fallback = credentials + } + override suspend fun clear() { + credentials = null + } + } + + private class InMemoryHostedCapabilitiesStore : HostedDiagnosticsCapabilitiesStore { + private var capabilities: HostedDiagnosticsCapabilities? = null + override suspend fun load(): HostedDiagnosticsCapabilities? = capabilities + override suspend fun save(capabilities: HostedDiagnosticsCapabilities) { + this.capabilities = capabilities + } + } + + private class FakeHostedDiagnosticsApi : HostedDiagnosticsApi { + var createdRequest: HostedDiagnosticsCreateReportRequest? = null + var invalidInstallationToken: String? = null + var createReportFailure: HostedDiagnosticsApiResult.Failure? = null + var createReportNetworkErrorsRemaining: Int = 0 + var uploadFailure: HostedDiagnosticsApiResult.Failure? = null + var uploadReceiptOverride: HostedDiagnosticsReportStatusResponse? = null + var reportStatusResultOverride: HostedDiagnosticsApiResult? = null + val reportStatusResultsByToken = mutableMapOf< + String, + HostedDiagnosticsApiResult, + >() + var beforeReportStatus: suspend () -> Unit = {} + var beforeUploadBundle: suspend () -> Unit = {} + var beforeCreateInstallation: suspend () -> Unit = {} + var beforeCreateReport: suspend () -> Unit = {} + var deleteResult: HostedDiagnosticsApiResult = HostedDiagnosticsApiResult.Success(Unit) + var nextUploadToken: String = "upload-token" + var installationCreateCalls: Int = 0 + var capabilitiesCalls: Int = 0 + val createdRequests = mutableListOf() + val createReportTokens = mutableListOf() + val createReportIds = mutableListOf() + val uploadReportIds = mutableListOf() + val uploadTokens = mutableListOf() + val statusReportIds = mutableListOf() + val statusInstallationTokens = mutableListOf() + val deleteInstallationTokens = mutableListOf() + val deleteReportIds = mutableListOf() + var capabilities = HostedDiagnosticsCapabilities( + status = HostedDiagnosticsAvailability.AVAILABLE, + collectorId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + acceptedSchemaVersions = listOf(1), + maxBundleBytes = 10L * 1_024 * 1_024, + maxManifestBytes = 64L * 1_024, + retentionDays = 30, + consentNoticeVersion = 1, + ) + + override suspend fun capabilities(): HostedDiagnosticsApiResult { + capabilitiesCalls += 1 + return HostedDiagnosticsApiResult.Success(capabilities) + } + override suspend fun createInstallation(request: HostedDiagnosticsInstallationRequest): + HostedDiagnosticsApiResult { + installationCreateCalls += 1 + beforeCreateInstallation() + return HostedDiagnosticsApiResult.Success( + HostedDiagnosticsInstallationResponse("installation-1", "installation-token"), + ) + } + override suspend fun createReport( + installationToken: String, + request: HostedDiagnosticsCreateReportRequest, + ): HostedDiagnosticsApiResult { + beforeCreateReport() + createReportTokens += installationToken + createReportIds += request.reportId + createdRequests += request + if (createReportNetworkErrorsRemaining > 0) { + createReportNetworkErrorsRemaining -= 1 + return HostedDiagnosticsApiResult.NetworkError(IllegalStateException("create response was lost")) + } + createReportFailure?.let { return it } + if (installationToken == invalidInstallationToken) { + return HostedDiagnosticsApiResult.Failure( + httpStatus = 401, + errorCode = "invalid_installation_token", + message = "installation token is invalid", + ) + } + createdRequest = request + return HostedDiagnosticsApiResult.Success( + HostedDiagnosticsCreateReportResponse(request.reportId, "ABC123", nextUploadToken, "2026-08-18T00:00:00Z"), + ) + } + override suspend fun uploadBundle( + installationToken: String, + reportId: String, + uploadToken: String, + bundle: ByteArray, + ): HostedDiagnosticsApiResult { + beforeUploadBundle() + uploadReportIds += reportId + uploadTokens += uploadToken + uploadFailure?.let { return it } + return HostedDiagnosticsApiResult.Success( + uploadReceiptOverride ?: HostedDiagnosticsReportStatusResponse( + reportId, + "ABC123", + HostedDiagnosticsReportState.PROCESSING, + ), + ) + } + override suspend fun reportStatus( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult { + statusReportIds += reportId + statusInstallationTokens += installationToken + beforeReportStatus() + reportStatusResultsByToken[installationToken]?.let { return it } + reportStatusResultOverride?.let { return it } + return HostedDiagnosticsApiResult.Success( + HostedDiagnosticsReportStatusResponse(reportId, "ABC123", HostedDiagnosticsReportState.PROCESSING), + ) + } + override suspend fun deleteReport( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult { + deleteInstallationTokens += installationToken + deleteReportIds += reportId + return deleteResult + } + + fun status( + report: PendingReport, + state: HostedDiagnosticsReportState, + errorCode: String? = null, + ) = HostedDiagnosticsReportStatusResponse( + reportId = requireNotNull(report.id.toHostedWireReportIdOrNull()), + shortId = "ABC123", + state = state, + errorCode = errorCode, + ) + } + + private class RecordingRedactionTokenProvider( + var values: List, + ) : DiagnosticsRedactionTokenProvider { + var calls: Int = 0 + + override suspend fun tokens(destinationKind: DiagnosticsDestinationKind): List { + calls += 1 + return values } } @@ -420,6 +1341,7 @@ class DiagnosticsUploaderTest { val store: FilePendingReportStore, val report: PendingReport, val identity: FakeIdentityResolver, + val identityTransitions: DefaultIdentityTransitionBarrier, val builder: FakeBundleBuilder, val api: FakeDiagnosticsApi, val sent: FakeSentRecorder, @@ -428,6 +1350,19 @@ class DiagnosticsUploaderTest { val uploader: DefaultDiagnosticsUploader, ) + private data class HostedFixture( + val store: FilePendingReportStore, + val report: PendingReport, + val api: FakeHostedDiagnosticsApi, + val installations: HostedDiagnosticsInstallationManager, + val redactionTokens: RecordingRedactionTokenProvider, + val sent: FakeSentRecorder, + val staleConsent: FakeStaleConsentHandler, + val identity: FakeIdentityResolver, + val identityTransitions: DefaultIdentityTransitionBarrier, + val uploader: DefaultDiagnosticsUploader, + ) + private companion object { val BINDING = DiagnosticsBinding("server-1", "user-1") val PENDING_BINDING = PendingReportBinding("server-1", "user-1", "profile-1", 7) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollectorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollectorTest.kt index 70a6ee244..d98772dfc 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollectorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/ExitInfoCollectorTest.kt @@ -1,6 +1,9 @@ package org.prairieserver.prairie.common.diagnostics +import java.io.File import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import org.junit.Rule import org.junit.rules.TemporaryFolder import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus @@ -11,6 +14,7 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -137,13 +141,203 @@ class ExitInfoCollectorTest { assertEquals(1, fixture.store.list(BINDING).size) } + @Test + fun renderedHostedJvmMarkerMatchesHostedRunAndIsCollectedThenDeleted() = runTest { + val root = temporaryFolder.newFolder() + val hostedBinding = DiagnosticsBinding( + HOSTED_DIAGNOSTICS_COLLECTOR_ID, + "anonymous-hosted-device", + ) + val context = DiagnosticsCaptureContext( + binding = hostedBinding, + profileId = null, + profileEligible = true, + noticeVersion = 2, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + ledger.beginRun(context, EXIT_AT - 10_000, "hosted-capture") + val marker = Json.decodeFromString( + CrashMarkerRenderer().render( + thread = Thread.currentThread(), + throwable = IllegalStateException("hosted crash"), + runtime = CrashRuntimeSnapshot( + binding = PendingReportBinding( + serverInstanceId = hostedBinding.serverInstanceId, + accountUserId = hostedBinding.accountUserId, + profileId = null, + ownershipGeneration = context.ownershipGeneration, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ), + captureSessionId = "hosted-capture", + runToken = RUN_TOKEN, + foreground = true, + playbackSessionIds = listOf("private-playback-session"), + deviceSnapshotJson = DEVICE_JSON, + ), + occurredAtEpochMs = EXIT_AT, + ).decodeToString(), + ) + assertEquals(DiagnosticsDestinationKind.HOSTED, marker.binding?.destinationKind) + val markers = FakeMarkerSource(listOf(marker)) + val store = FilePendingReportStore( + root, + nowMs = { EXIT_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val collector = ExitInfoCollector( + source = AndroidExitInfoSource { + listOf(exit(reason = AndroidExitReason.JVM_CRASH, timestampMs = EXIT_AT + 100)) + }, + ledger = ledger, + reports = store, + markers = markers, + environment = ExitReportEnvironment( + appVersion = "1.0", + appBuild = "1", + platform = DiagnosticsPlatform.ANDROID, + osVersion = "Android 36", + deviceSummary = DiagnosticsDeviceSummary("Google", "Pixel", "Android 36", "phone"), + ), + deviceSnapshotBytes = { DEVICE_JSON.encodeToByteArray() }, + noticeVersion = { 2 }, + ) + + val report = collector.collect().single() + + assertEquals(DiagnosticsDestinationKind.HOSTED, report.binding.destinationKind) + assertEquals(hostedBinding, report.binding.binding) + assertNull(report.binding.profileId) + assertTrue(report.manifest.playbackSessionIds.isEmpty()) + assertFalse(report.directory.resolve("manifest.json").readText().contains("private-playback-session")) + assertEquals(listOf(marker), markers.deleted) + assertEquals(listOf(report.id), store.list(hostedBinding).map(PendingReport::id)) + assertTrue(collector.collect().isEmpty()) + } + + @Test + fun recentRealMarkerMatchesLedgerPersistsReportAndIsDeletedDurably() = runTest { + val root = temporaryFolder.newFolder() + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + ledger.beginRun( + context = DiagnosticsCaptureContext( + binding = BINDING, + profileId = "profile-1", + profileEligible = true, + noticeVersion = 2, + status = DiagnosticsAvailabilityStatus.AVAILABLE, + ownershipGeneration = 7, + ), + processStartedAtEpochMs = EXIT_AT - 10_000, + captureSessionId = "capture-1", + ) + FileCrashMarkerWriter( + noBackupFilesDir = root, + nowMs = { EXIT_AT }, + nanoTime = { 1 }, + ).write( + Thread.currentThread(), + IllegalStateException("recent crash"), + CrashRuntimeSnapshot( + binding = PendingReportBinding("server-1", "user-1", "profile-1", 7), + captureSessionId = "capture-1", + runToken = RUN_TOKEN, + deviceSnapshotJson = DEVICE_JSON, + ), + ) + val markerDirectory = root.resolve("client-diagnostics/crash-markers") + var directorySyncs = 0 + val markers = FileJvmCrashMarkerSource( + noBackupFilesDir = root, + nowMs = { EXIT_AT + 1_000 }, + fileGate = JvmCrashMarkerFileGate(), + deleteFile = File::delete, + syncDirectory = { directorySyncs += 1 }, + listFiles = File::listFiles, + ) + val store = FilePendingReportStore( + root, + nowMs = { EXIT_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val collector = ExitInfoCollector( + source = AndroidExitInfoSource { emptyList() }, + ledger = ledger, + reports = store, + markers = markers, + environment = ExitReportEnvironment( + appVersion = "1.0", + appBuild = "1", + platform = DiagnosticsPlatform.ANDROID, + osVersion = "Android 36", + deviceSummary = DiagnosticsDeviceSummary("Google", "Pixel", "Android 36", "phone"), + ), + deviceSnapshotBytes = { DEVICE_JSON.encodeToByteArray() }, + noticeVersion = { 2 }, + ) + + val report = collector.collect().single() + + assertEquals(DiagnosticsCrashSource.UEH, report.manifest.crash?.source) + assertTrue(markerDirectory.listFiles().orEmpty().isEmpty()) + assertEquals(1, directorySyncs) + assertEquals(listOf(report.id), store.list(BINDING).map(PendingReport::id)) + } + + @Test + fun terminallyUnresolvableJvmMarkersAreDeletedInsteadOfRetained() = runTest { + val missingToken = marker(runToken = null) + val missingLedger = marker(runToken = "f".repeat(32)) + val mismatchedBinding = marker( + binding = PendingReportBinding("different-server", "different-user", "profile-1", 7), + ) + val markers = FakeMarkerSource(listOf(missingToken, missingLedger, mismatchedBinding)) + val fixture = fixture(records = emptyList(), markers = markers) + + assertTrue(fixture.collector.collect().isEmpty()) + assertEquals(listOf(missingToken, missingLedger, mismatchedBinding), markers.deleted) + assertTrue(fixture.store.list(BINDING).isEmpty()) + } + + @Test + fun markerDeletionFailurePropagatesAfterSuccessfulReportPersistence() = runTest { + val marker = marker() + val markers = object : JvmCrashMarkerSource { + override fun records() = listOf(marker) + override fun delete(marker: JvmCrashMarkerRecord) = error("delete failed") + } + val fixture = fixture(records = emptyList(), markers = markers) + + assertFailsWith { fixture.collector.collect() } + assertEquals(1, fixture.store.list(BINDING).size) + } + private suspend fun fixture( records: List, - markers: FakeMarkerSource = FakeMarkerSource(emptyList()), + markers: JvmCrashMarkerSource = FakeMarkerSource(emptyList()), breadcrumbs: DiagnosticsBreadcrumbSource = DiagnosticsBreadcrumbSource.None, ): Fixture { val root = temporaryFolder.newFolder() - val ledger = DiagnosticsRunLedger(root, tokenFactory = { RUN_TOKEN }) + val ledger = DiagnosticsRunLedger( + root, + tokenFactory = { RUN_TOKEN }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) ledger.beginRun( context = DiagnosticsCaptureContext( binding = BINDING, @@ -156,7 +350,12 @@ class ExitInfoCollectorTest { processStartedAtEpochMs = EXIT_AT - 10_000, captureSessionId = "capture-1", ) - val store = FilePendingReportStore(root, nowMs = { EXIT_AT + 1_000 }) + val store = FilePendingReportStore( + root, + nowMs = { EXIT_AT + 1_000 }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) val collector = ExitInfoCollector( source = AndroidExitInfoSource { records }, ledger = ledger, @@ -196,6 +395,27 @@ class ExitInfoCollectorTest { } } + private fun marker( + runToken: String? = RUN_TOKEN, + binding: PendingReportBinding = PendingReportBinding("server-1", "user-1", "profile-1", 7), + ) = JvmCrashMarkerRecord( + occurredAtEpochMs = EXIT_AT, + threadName = "main", + threadId = 1, + throwableType = "java.lang.IllegalStateException", + stack = "java.lang.IllegalStateException: crash", + binding = binding, + captureSessionId = "capture-1", + runToken = runToken, + playbackSessionIds = emptyList(), + deviceSnapshotJson = DEVICE_JSON, + logLines = emptyList(), + logDroppedCount = 0, + logTornCount = 0, + logGeneration = 7, + truncated = false, + ) + private class FakeMarkerSource(private val markers: List) : JvmCrashMarkerSource { val deleted = mutableListOf() override fun records(): List = markers.filterNot(deleted::contains) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsTest.kt new file mode 100644 index 000000000..2084543c7 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/HostedDiagnosticsTest.kt @@ -0,0 +1,187 @@ +package org.prairieserver.prairie.common.diagnostics + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.EncryptedTokenManagerImpl +import org.prairieserver.prairie.network.api.HostedDiagnosticsApi +import org.prairieserver.prairie.network.api.HostedDiagnosticsApiResult +import org.prairieserver.prairie.network.api.HostedDiagnosticsCapabilities +import org.prairieserver.prairie.network.api.HostedDiagnosticsCreateReportRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsCreateReportResponse +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationRequest +import org.prairieserver.prairie.network.api.HostedDiagnosticsInstallationResponse +import org.prairieserver.prairie.network.api.HostedDiagnosticsReportStatusResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class HostedDiagnosticsTest { + @Test + fun cachedCapabilitiesMustIncludeHostedSchemaV1() = runTest { + val v2Only = HostedDiagnosticsCapabilities( + status = org.prairieserver.prairie.network.api.HostedDiagnosticsAvailability.AVAILABLE, + collectorId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + acceptedSchemaVersions = listOf(2), + maxBundleBytes = 10L * 1_024 * 1_024, + maxManifestBytes = 64L * 1_024, + retentionDays = HOSTED_DIAGNOSTICS_RETENTION_DAYS, + consentNoticeVersion = 1, + ) + val repository = HostedDiagnosticsCapabilitiesRepository( + store = InMemoryCapabilitiesStore(v2Only), + api = RecordingOfflineHostedApi(), + ) + + assertEquals(listOf(1), repository.local().acceptedSchemaVersions) + } + + @Test + fun cachedResolutionDoesNotContactCollectorButLiveCaptureFailsClosedOffline() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("hosted-offline-${System.nanoTime()}", Context.MODE_PRIVATE) + val transitions = DefaultIdentityTransitionBarrier() + val registry = AndroidServerRegistry(prefs, transitions) + val serverId = registry.addOrUpdate("https://private-silo.example") + registry.addOrUpdate("https://saved-private-silo.example:9443") + registry.switchTo(serverId) + val tokens = EncryptedTokenManagerImpl(prefs, registry, transitions) + tokens.saveTokens("source-access", "source-refresh", 3_600) + tokens.setProfileId("adult-profile") + val offlineApi = RecordingOfflineHostedApi() + val capabilities = HostedDiagnosticsCapabilitiesRepository( + store = InMemoryCapabilitiesStore(), + api = offlineApi, + ) + var accountId = "account-a" + val bindingOwners = InMemoryBindingOwnerStore() + val resolver = HostedDiagnosticsIdentityResolver( + tokenManager = tokens, + identityTransitions = transitions, + registry = registry, + accountProvider = DiagnosticsAccountProvider { accountId }, + profileProvider = DiagnosticsProfileProvider { false }, + capabilities = capabilities, + bindingOwners = bindingOwners, + ) + + val resolved = resolver.resolve(requirePersistentCapture = true) + + assertEquals(0, offlineApi.calls, "capture path must not contact the public collector") + assertEquals(DiagnosticsDestinationKind.HOSTED, resolved?.destinationKind) + assertEquals(HOSTED_DIAGNOSTICS_COLLECTOR_ID, resolved?.binding?.serverInstanceId) + assertEquals(null, resolved?.profileId, "hosted manifest attribution must remain empty") + assertEquals("adult-profile", resolved?.sourceProfileId, "source profile is local gate state only") + assertTrue(resolved?.profileEligible == true) + assertTrue(1 in resolved.orThrow().acceptedSchemaVersions) + assertEquals(30, resolved.retentionDays) + assertNull(resolver.resolveForCapture(requirePersistentCapture = true)) + assertEquals(1, offlineApi.calls, "live capture must attest the public collector") + + val redactionTokens = DestinationAwareDiagnosticsRedactionTokenProvider(tokens, registry) { + listOf("installation-token", "fallback-installation-token") + } + val hostedTokens = redactionTokens.tokens(DiagnosticsDestinationKind.HOSTED) + assertTrue("https://private-silo.example" in hostedTokens) + assertTrue("private-silo.example" in hostedTokens) + assertTrue("https://saved-private-silo.example:9443" in hostedTokens) + assertTrue("saved-private-silo.example" in hostedTokens) + assertTrue(serverId in hostedTokens) + assertTrue("adult-profile" in hostedTokens) + assertTrue("installation-token" in hostedTokens) + assertTrue("fallback-installation-token" in hostedTokens) + val selfHostedTokens = redactionTokens.tokens(DiagnosticsDestinationKind.SELF_HOSTED) + assertFalse("https://private-silo.example" in selfHostedTokens) + assertFalse(serverId in selfHostedTokens) + assertFalse("adult-profile" in selfHostedTokens) + + tokens.saveTokens("other-account-access", "other-account-refresh", 3_600) + val rotatedCredential = resolver.resolve(requirePersistentCapture = true) + assertEquals( + resolved.binding.accountUserId, + rotatedCredential?.binding?.accountUserId, + "token rotation must not change the hosted binding", + ) + accountId = "account-b" + val otherAccount = resolver.resolveForUpload(requirePersistentCapture = true) + assertNotEquals(resolved.binding.accountUserId, otherAccount?.binding?.accountUserId) + assertEquals(1, offlineApi.calls, "account isolation must not add collector calls") + } + + @Test + fun installationCredentialsRoundTripThroughDedicatedSecureStoreAbstraction() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("hosted-credentials-${System.nanoTime()}", Context.MODE_PRIVATE) + val store: HostedDiagnosticsCredentialStore = EncryptedPreferencesHostedDiagnosticsCredentialStore(prefs) + val credentials = HostedDiagnosticsCredentials("service-installation", "service-token") + + assertNull(store.load()) + store.save(credentials) + assertEquals(credentials, store.load()) + val fallback = HostedDiagnosticsCredentials("fallback-installation", "fallback-token") + store.saveFallback(fallback) + assertEquals(listOf(fallback), store.loadFallbacks()) + assertFalse(prefs.contains("access_token")) + assertFalse(prefs.contains("refresh_token")) + assertFalse(prefs.contains("profile_token")) + store.clear() + assertNull(store.load()) + } + + private class InMemoryCapabilitiesStore( + private var value: HostedDiagnosticsCapabilities? = null, + ) : HostedDiagnosticsCapabilitiesStore { + override suspend fun load(): HostedDiagnosticsCapabilities? = value + override suspend fun save(capabilities: HostedDiagnosticsCapabilities) { + value = capabilities + } + } + + private class InMemoryBindingOwnerStore : HostedDiagnosticsBindingOwnerStore { + private val owners = mutableMapOf() + override suspend fun load(localServerId: String): String? = owners[localServerId] + override suspend fun save(localServerId: String, owner: String) { + owners[localServerId] = owner + } + } + + private class RecordingOfflineHostedApi : HostedDiagnosticsApi { + var calls: Int = 0 + private fun offline(): HostedDiagnosticsApiResult { + calls += 1 + return HostedDiagnosticsApiResult.NetworkError(IllegalStateException("offline")) + } + + override suspend fun capabilities() = offline() + override suspend fun createInstallation(request: HostedDiagnosticsInstallationRequest) = + offline() + override suspend fun createReport( + installationToken: String, + request: HostedDiagnosticsCreateReportRequest, + ) = offline() + override suspend fun uploadBundle( + installationToken: String, + reportId: String, + uploadToken: String, + bundle: ByteArray, + ) = offline() + override suspend fun reportStatus( + installationToken: String, + reportId: String, + ) = offline() + override suspend fun deleteReport( + installationToken: String, + reportId: String, + ) = offline() + } +} + +private fun DiagnosticsCaptureContext?.orThrow(): DiagnosticsCaptureContext = checkNotNull(this) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStoreTest.kt index 8738c4601..8d15bcccd 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PendingReportStoreTest.kt @@ -1,6 +1,8 @@ package org.prairieserver.prairie.common.diagnostics import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission import kotlinx.serialization.json.Json import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -109,6 +111,67 @@ class PendingReportStoreTest { assertTrue(store.list(binding.binding).isEmpty()) } + @Test + fun futureDatedCaptureIsRejectedAndFutureIndexValuesArePruned() { + val now = day(10) + val store = newStore(nowMs = { now }, retentionMs = day(7)) + store.markThrottled("future", atEpochMs = day(11)) + + assertFailsWith { + store.save(capture(day = 11, fingerprint = "future")) + } + + assertFalse(store.hasSeenFingerprint("future")) + assertFalse(store.isThrottled("future", windowMs = day(7))) + assertTrue(store.list(binding.binding).isEmpty()) + } + + @Test + fun negativeClockFailsClosedBeforeReportOrIndexMutation() { + val store = newStore(nowMs = { -1L }, retentionMs = day(7)) + + assertFailsWith { + store.save(capture(day = 0, fingerprint = "negative-clock")) + } + assertFailsWith { + store.markThrottled("negative-clock", atEpochMs = 0) + } + } + + @Test + fun negativeClockCannotPublishHostedErasureAuthorityOrDeleteRawEvidence() { + var now = day(10) + val hostedBinding = hostedBinding() + val store = newStore(nowMs = { now }, retentionMs = day(7)) + val report = store.save(capture(day = 10, fingerprint = "negative-authority", binding = hostedBinding)) + now = -1 + + assertFailsWith { + store.stageHostedDeletionAndDelete(report.id) + } + + assertTrue(report.directory.isDirectory) + assertTrue(store.hostedDeletionIntents().isEmpty()) + } + + @Test + fun correctedClockPrunesFutureHostedEvidenceButRetainsItsErasureAuthority() { + var now = day(20) + val hostedBinding = hostedBinding() + val store = newStore(nowMs = { now }, retentionMs = day(7)) + val report = store.save(capture(day = 20, fingerprint = "future-hosted", binding = hostedBinding)) + store.markHostedProcessing(report.id, "ABC123") + + now = day(10) + + assertTrue(store.list(hostedBinding.binding).isEmpty()) + assertFalse(report.directory.exists()) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + + store.purge(hostedBinding.binding) + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + } + @Test fun stateDeleteAndBindingPurgeArePersistentAndScoped() { val store = newStore(nowMs = { day(10) }) @@ -141,15 +204,639 @@ class PendingReportStoreTest { assertNull(store.retryAfterDeadline(binding)) } + @Test + fun interruptedHostedEnvelopeStagingIsDiscardedAndTreatedAsMissing() { + val store = newStore(nowMs = { day(10) }) + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val report = store.save(capture(day = 10, fingerprint = "hosted", binding = hostedBinding)) + val staging = report.directory.resolve(".hosted-envelope-staging-${"a".repeat(32)}") + assertTrue(staging.mkdirs()) + staging.resolve("manifest.json").writeText("partial") + + assertEquals(HostedEnvelopeLoadResult.Missing, store.loadHostedEnvelope(report.id)) + assertFalse(staging.exists(), "an uncommitted generation must never become the retry envelope") + assertNull(store.load(report.id)?.state?.hostedEnvelopeGeneration) + } + + @Test + fun tamperedPublishedHostedMemberMakesTheCommittedEnvelopeCorrupt() { + val store = newStore(nowMs = { day(10) }) + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val report = store.save(capture(day = 10, fingerprint = "hosted-tamper", binding = hostedBinding)) + val bundle = FileDiagnosticsBundleBuilder().build(report, redactionTokens = emptyList()) + store.saveHostedEnvelope(report.id, bundle) + val generation = assertNotNull(store.load(report.id)?.state?.hostedEnvelopeGeneration) + report.directory.resolve(".hosted-envelope-$generation/entries/device.json").writeText("{\"tampered\":true}") + + assertEquals(HostedEnvelopeLoadResult.Corrupt, store.loadHostedEnvelope(report.id)) + } + + @Test + fun hostedEnvelopeSyncsNestedEntryDirectoriesBottomUpBeforePublishing() { + val synced = mutableListOf() + val store = newStore( + nowMs = { day(10) }, + directorySync = { directory -> synced += directory.name }, + ) + val report = store.save( + capture( + day = 10, + fingerprint = "hosted-nested-sync", + binding = hostedBinding(), + artifacts = mapOf( + "device.json" to "{}".encodeToByteArray(), + "logs.jsonl" to "{\"msg\":\"safe\"}\n".encodeToByteArray(), + "crash/stack.txt" to "safe stack".encodeToByteArray(), + ), + ), + ) + val bundle = FileDiagnosticsBundleBuilder().build(report, redactionTokens = emptyList()) + synced.clear() + + store.saveHostedEnvelope(report.id, bundle) + + val crashSync = synced.lastIndexOf("crash") + val entriesSync = synced.indexOfFirstAfter(crashSync) { it == "entries" } + val stagingSync = synced.indexOfFirstAfter(entriesSync) { it.startsWith(".hosted-envelope-staging-") } + assertTrue(crashSync >= 0, synced.toString()) + assertTrue(entriesSync > crashSync, synced.toString()) + assertTrue(stagingSync > entriesSync, synced.toString()) + assertTrue(store.loadHostedEnvelope(report.id) is HostedEnvelopeLoadResult.Available) + } + + @Test + fun hostedDeletionIntentIsDurableForEnvelopeOrRemoteIdentity() { + val store = newStore(nowMs = { day(10) }) + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val envelopeReport = store.save(capture(day = 8, fingerprint = "hosted-envelope", binding = hostedBinding)) + val bundle = FileDiagnosticsBundleBuilder().build(envelopeReport, redactionTokens = emptyList()) + store.saveHostedEnvelope(envelopeReport.id, bundle) + val interruptedCopy = temporaryFolder.newFolder("hosted-delete-interrupted") + envelopeReport.directory.copyRecursively(interruptedCopy, overwrite = true) + val remoteReport = store.save(capture(day = 9, fingerprint = "hosted-remote", binding = hostedBinding)) + store.markHostedProcessing(remoteReport.id, "ABC123") + val localOnly = store.save(capture(day = 10, fingerprint = "hosted-local", binding = hostedBinding)) + + store.stageHostedDeletionAndDelete(envelopeReport.id) + store.stageHostedDeletionAndDelete(remoteReport.id) + store.stageHostedDeletionAndDelete(localOnly.id) + + assertNull(store.load(envelopeReport.id)) + assertNull(store.load(remoteReport.id)) + assertNull(store.load(localOnly.id)) + assertEquals( + listOf(envelopeReport.id, remoteReport.id, localOnly.id).sorted(), + store.hostedDeletionIntents(), + ) + + // Simulate a process stopping after the atomic intent write but before + // local evidence removal by restoring the report bytes while leaving + // the durable UUID intent in place. + interruptedCopy.copyRecursively(envelopeReport.directory, overwrite = true) + assertTrue(envelopeReport.directory.isDirectory) + + val restarted = newStore(nowMs = { day(11) }) + assertFalse(envelopeReport.directory.exists()) + assertEquals( + listOf(envelopeReport.id, remoteReport.id, localOnly.id).sorted(), + restarted.hostedDeletionIntents(), + ) + restarted.completeHostedDeletion(envelopeReport.id) + assertEquals(listOf(localOnly.id, remoteReport.id).sorted(), restarted.hostedDeletionIntents()) + restarted.completeHostedDeletion(remoteReport.id) + restarted.completeHostedDeletion(localOnly.id) + assertTrue(restarted.hostedDeletionIntents().isEmpty()) + } + + @Test + fun failedIntentReplacementPreservesPriorAuthorityAndNewRawEvidence() { + val files = temporaryFolder.root.resolve("store") + val initial = newStore(nowMs = { day(10) }) + val prior = initial.save( + capture(day = 9, fingerprint = "prior-intent", binding = hostedBinding()), + ) + initial.stageHostedDeletionAndDelete(prior.id) + assertFalse(prior.directory.exists()) + assertEquals(listOf(prior.id), initial.hostedDeletionIntents()) + + val restarted = FilePendingReportStore( + noBackupFilesDir = files, + nowMs = { day(11) }, + directorySync = {}, + atomicRename = { source, target -> + if (target.name == "hosted-deletion-intents.json" && target.exists()) { + error("simulated atomic rename failure") + } + testAtomicRename(source, target) + }, + ) + val pending = restarted.save( + capture(day = 10, fingerprint = "new-intent", binding = hostedBinding()), + ) + + assertFailsWith { + restarted.stageHostedDeletionAndDelete(pending.id) + } + + assertFalse(prior.directory.exists(), "prior raw evidence was already removed") + assertTrue(pending.directory.isDirectory, "new raw evidence must remain when intent publication fails") + assertEquals(listOf(prior.id), restarted.hostedDeletionIntents()) + val stateDirectory = files.resolve("client-diagnostics") + assertTrue(stateDirectory.resolve("hosted-deletion-intents.json.tmp").isFile) + assertFalse(stateDirectory.resolve("hosted-deletion-intents.json").readText().contains(pending.id)) + } + + @Test + fun startupStrictlyRemovesStagingAndMalformedUuidEvidence() { + val root = temporaryFolder.root.resolve("store/client-diagnostics/pending") + assertTrue(root.mkdirs()) + val staging = root.resolve(".staging-${"a".repeat(32)}") + assertTrue(staging.mkdirs()) + staging.resolve("logs.jsonl").writeText("raw staging evidence") + val malformed = root.resolve("b".repeat(32)) + assertTrue(malformed.mkdirs()) + malformed.resolve("logs.jsonl").writeText("raw malformed evidence") + + newStore(nowMs = { day(10) }) + + assertFalse(staging.exists()) + assertFalse(malformed.exists()) + } + + @Test + fun corruptBindingAfterHostedCreatePreservesUuidAsDeletionIntent() { + val store = newStore(nowMs = { day(10) }) + val report = store.save( + capture(day = 10, fingerprint = "hosted-corrupt-binding", binding = hostedBinding()), + ) + store.markHostedProcessing(report.id, "ABC123") + report.directory.resolve("binding.json").writeText("{not-json") + + val restarted = newStore(nowMs = { day(11) }) + + assertFalse(report.directory.exists()) + assertEquals(listOf(report.id), restarted.hostedDeletionIntents()) + restarted.completeHostedDeletion(report.id) + assertTrue(restarted.hostedDeletionIntents().isEmpty()) + } + + @Test + fun corruptDeletionIntentLedgerNeverReexposesInterruptedHostedEvidence() { + val files = temporaryFolder.newFolder("corrupt-intent-store") + val store = FilePendingReportStore( + files, + nowMs = { day(10) }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val report = store.save( + capture(day = 10, fingerprint = "corrupt-intent", binding = hostedBinding()), + ) + val evidence = temporaryFolder.newFolder("corrupt-intent-evidence") + report.directory.copyRecursively(evidence, overwrite = true) + store.stageHostedDeletionAndDelete(report.id) + evidence.copyRecursively(report.directory, overwrite = true) + files.resolve("client-diagnostics/hosted-deletion-intents.json").writeText("{not-json") + + val restarted = FilePendingReportStore( + files, + nowMs = { day(11) }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + + assertFailsWith { restarted.load(report.id) } + assertFailsWith { restarted.list(hostedBinding().binding) } + assertFailsWith { restarted.hostedDeletionIntents() } + assertTrue(report.directory.resolve("logs.jsonl").isFile) + } + + @Test + fun corruptReadyReceiptLedgerNeverReexposesInterruptedHostedEvidence() { + val files = temporaryFolder.newFolder("corrupt-receipt-store") + val store = FilePendingReportStore( + files, + nowMs = { day(10) }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + val hostedBinding = hostedBinding() + val report = store.save( + capture(day = 10, fingerprint = "corrupt-receipt", binding = hostedBinding), + ) + val evidence = temporaryFolder.newFolder("corrupt-receipt-evidence") + report.directory.copyRecursively(evidence, overwrite = true) + store.recordHostedReadyAndDelete(report.id, hostedBinding, "ABC123") + evidence.copyRecursively(report.directory, overwrite = true) + files.resolve("client-diagnostics/hosted-ready-receipts.json").writeText("{not-json") + + val restarted = FilePendingReportStore( + files, + nowMs = { day(11) }, + directorySync = {}, + atomicRename = ::testAtomicRename, + ) + + assertFailsWith { restarted.load(report.id) } + assertFailsWith { restarted.list(hostedBinding.binding) } + assertTrue(report.directory.resolve("logs.jsonl").isFile) + } + + @Test + fun destructivePurgeFailsClosedWhenStagingOrMalformedEvidenceCannotBeDeleted() { + var blockedName: String? = null + val store = newStore( + nowMs = { day(10) }, + deleteRecursively = { file -> + if (file.name == blockedName) false else file.deleteRecursively() + }, + ) + val root = temporaryFolder.root.resolve("store/client-diagnostics/pending") + + val staging = root.resolve(".staging-${"c".repeat(32)}") + assertTrue(staging.mkdirs()) + staging.resolve("logs.jsonl").writeText("raw staging evidence") + blockedName = staging.name + assertFailsWith { store.purge(binding.binding) } + assertTrue(staging.exists()) + + blockedName = null + store.purge(binding.binding) + val malformed = root.resolve("d".repeat(32)) + assertTrue(malformed.mkdirs()) + malformed.resolve("logs.jsonl").writeText("raw malformed evidence") + blockedName = malformed.name + assertFailsWith { store.purge(binding.binding) } + assertTrue(malformed.exists()) + } + + @Test + fun destructivePurgeFailsClosedWhenPendingRootCannotBeEnumerated() { + var failEnumeration = false + val store = newStore( + nowMs = { day(10) }, + listFiles = { directory -> if (failEnumeration) null else directory.listFiles() }, + ) + store.save(capture(day = 10, fingerprint = "enumeration")) + failEnumeration = true + + assertFailsWith { store.purge(binding.binding) } + } + + @Test + fun hostedDeletionAbortsBeforeRawRemovalWhenIntentDirectorySyncFailsAndRetriesSafely() { + var failClientStateSync = false + val store = newStore( + nowMs = { day(10) }, + directorySync = { directory -> + if (failClientStateSync && directory.name == "client-diagnostics") { + error("injected intent directory fsync failure") + } + }, + ) + val report = store.save( + capture(day = 10, fingerprint = "intent-fsync", binding = hostedBinding()), + ) + failClientStateSync = true + + assertFailsWith { store.stageHostedDeletionAndDelete(report.id) } + assertTrue(report.directory.resolve("logs.jsonl").isFile) + assertFalse( + temporaryFolder.root.resolve("store/client-diagnostics/hosted-deletion-intents.json").isFile, + "an unsynced intent must not be treated as committed", + ) + + failClientStateSync = false + store.stageHostedDeletionAndDelete(report.id) + assertFalse(report.directory.exists()) + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + } + + @Test + fun hostedDeletionPropagatesPostRemovalDirectorySyncFailureWithoutLosingIntent() { + var failPendingRootSync = false + val store = newStore( + nowMs = { day(10) }, + directorySync = { directory -> + if (failPendingRootSync && directory.name == "pending") { + error("injected raw deletion directory fsync failure") + } + }, + ) + val report = store.save( + capture(day = 10, fingerprint = "raw-fsync", binding = hostedBinding()), + ) + failPendingRootSync = true + + assertFailsWith { store.stageHostedDeletionAndDelete(report.id) } + assertFalse(report.directory.exists()) + assertTrue( + temporaryFolder.root.resolve("store/client-diagnostics/hosted-deletion-intents.json") + .readText() + .contains(report.id), + ) + + failPendingRootSync = false + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + store.completeHostedDeletion(report.id) + assertTrue(store.hostedDeletionIntents().isEmpty()) + } + + @Test + fun startupCleanupFailureIsContainedButEveryLaterBoundaryRetriesStrictly() { + val root = temporaryFolder.root.resolve("store/client-diagnostics/pending") + assertTrue(root.mkdirs()) + val staging = root.resolve(".staging-${"e".repeat(32)}") + assertTrue(staging.mkdirs()) + staging.resolve("logs.jsonl").writeText("raw startup evidence") + var failDeletion = true + + val store = newStore( + nowMs = { day(10) }, + deleteRecursively = { file -> + if (failDeletion && file == staging) false else file.deleteRecursively() + }, + ) + + assertTrue(staging.exists(), "construction must remain available so the identity gate can install") + assertFailsWith { store.list(binding.binding) } + assertFailsWith { store.purge(binding.binding) } + + failDeletion = false + store.purge(binding.binding) + assertFalse(staging.exists()) + } + + @Test + fun explicitDeleteStagesUnsentHostedUuidBeforeAPartialLocalDeletion() { + val hostedBinding = hostedBinding() + var blockedId: String? = null + var failDeletion = false + val store = newStore( + nowMs = { day(10) }, + deleteRecursively = { file -> + if (failDeletion && file.name == blockedId) { + file.resolve("manifest.json").delete() + false + } else { + file.deleteRecursively() + } + }, + ) + val report = store.save(capture(day = 10, fingerprint = "unsent-partial", binding = hostedBinding)) + blockedId = report.id + failDeletion = true + + assertFailsWith { store.stageHostedDeletionAndDelete(report.id) } + assertTrue(report.directory.resolve("logs.jsonl").isFile) + assertTrue( + temporaryFolder.root.resolve("store/client-diagnostics/hosted-deletion-intents.json") + .readText() + .contains(report.id), + ) + + val restarted = newStore(nowMs = { day(11) }) + assertFalse(report.directory.exists()) + assertEquals(listOf(report.id), restarted.hostedDeletionIntents()) + } + + @Test + fun hostedExpiryRetainsHandoffAuthorityUntilTurnOffStagesErasure() { + var now = day(10) + val hostedBinding = hostedBinding() + val store = newStore(nowMs = { now }, retentionMs = day(7)) + val report = store.save(capture(day = 10, fingerprint = "hosted-expiry", binding = hostedBinding)) + store.markHostedProcessing(report.id, "ABC123") + + now = day(18) + assertTrue(store.list(hostedBinding.binding).isEmpty()) + assertFalse(report.directory.exists()) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + assertTrue(store.hostedReadyReports().isEmpty()) + assertTrue(store.hostedDeletionIntents().isEmpty()) + + store.purge(hostedBinding.binding) + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + } + + @Test + fun hostedQuotaEvictionRetainsHandoffAuthorityUntilTurnOffStagesErasure() { + val hostedBinding = hostedBinding() + val store = newStore(nowMs = { day(10) }, maxReportsPerBinding = 1, retentionMs = day(30)) + val evicted = store.save(capture(day = 9, fingerprint = "hosted-evicted", binding = hostedBinding)) + store.markHostedProcessing(evicted.id, "ABC123") + + val retained = store.save(capture(day = 10, fingerprint = "hosted-retained", binding = hostedBinding)) + + assertNull(store.load(evicted.id)) + assertNotNull(store.load(retained.id)) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(evicted.id)) + assertTrue(store.hostedReadyReports().isEmpty()) + assertTrue(store.hostedDeletionIntents().isEmpty()) + + store.purge(hostedBinding.binding) + assertEquals(listOf(evicted.id, retained.id).sorted(), store.hostedDeletionIntents()) + } + + @Test + fun legacyUnindexedPurgeAllDeletesEveryReportAndPreservesHostedErasureAuthority() { + val store = newStore(nowMs = { day(10) }) + val selfHosted = store.save(capture(day = 9, fingerprint = "legacy-self-hosted")) + val hostedBinding = hostedBinding() + val hosted = store.save(capture(day = 10, fingerprint = "legacy-hosted", binding = hostedBinding)) + store.markHostedProcessing(hosted.id, "ABC123") + + store.purgeAll() + + assertNull(store.load(selfHosted.id)) + assertNull(store.load(hosted.id)) + assertEquals(listOf(hosted.id), store.hostedDeletionIntents()) + } + + @Test + fun hostedDeletionIntentCannotCompleteWhileLocalRemovalKeepsFailing() { + val store = newStore(nowMs = { day(10) }) + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val report = store.save(capture(day = 10, fingerprint = "hosted-delete-failure", binding = hostedBinding)) + store.markHostedProcessing(report.id, "ABC123") + val interruptedCopy = temporaryFolder.newFolder("hosted-delete-failure-copy") + report.directory.copyRecursively(interruptedCopy, overwrite = true) + store.stageHostedDeletionAndDelete(report.id) + interruptedCopy.copyRecursively(report.directory, overwrite = true) + + val originalPermissions = Files.getPosixFilePermissions(report.directory.toPath()) + Files.setPosixFilePermissions( + report.directory.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE, + ), + ) + try { + val restarted = newStore(nowMs = { day(11) }) + + assertTrue(report.directory.exists(), "persistent removal failure must be observable") + assertNull(restarted.load(report.id), "queued evidence must never become uploadable") + assertTrue(restarted.hostedDeletionIntents().isEmpty(), "remote DELETE must wait for local removal") + assertFailsWith { restarted.completeHostedDeletion(report.id) } + assertTrue( + temporaryFolder.root.resolve("store/client-diagnostics/hosted-deletion-intents.json") + .readText() + .contains(report.id), + "failed local removal must leave the durable intent queued", + ) + } finally { + if (report.directory.exists()) { + Files.setPosixFilePermissions(report.directory.toPath(), originalPermissions) + } + } + + val recovered = newStore(nowMs = { day(12) }) + assertFalse(report.directory.exists()) + assertEquals(listOf(report.id), recovered.hostedDeletionIntents()) + recovered.completeHostedDeletion(report.id) + assertTrue(recovered.hostedDeletionIntents().isEmpty()) + } + + @Test + fun hostedReadyReceiptSurvivesEvidenceDeletionAndRestartUntilErasureCompletes() { + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val store = newStore(nowMs = { day(10) }) + val report = store.save(capture(day = 10, fingerprint = "hosted-ready", binding = hostedBinding)) + store.markHostedProcessing(report.id, "ABC123") + val interruptedCopy = temporaryFolder.newFolder("hosted-ready-interrupted") + report.directory.copyRecursively(interruptedCopy, overwrite = true) + + store.recordHostedReadyAndDelete(report.id, hostedBinding, "ABC123") + + assertNull(store.load(report.id)) + assertFalse(report.directory.exists()) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + assertTrue(store.hostedDeletionIntents().isEmpty()) + + // Simulate stopping after the atomic UUID receipt write but before raw + // evidence removal. Startup must hide and finish removing the evidence. + interruptedCopy.copyRecursively(report.directory, overwrite = true) + assertTrue(report.directory.isDirectory) + val restarted = newStore(nowMs = { day(11) }) + assertFalse(report.directory.exists()) + assertNull(restarted.load(report.id)) + assertEquals(hostedBinding.binding, restarted.hostedReadyBinding(report.id)) + assertEquals("ABC123", restarted.hostedReadyReports().single().shortId) + + restarted.stageHostedDeletionAndDelete(report.id) + assertEquals(listOf(report.id), restarted.hostedDeletionIntents()) + assertEquals(hostedBinding.binding, restarted.hostedReadyBinding(report.id)) + assertTrue(restarted.hostedReadyReports().isEmpty()) + restarted.completeHostedDeletion(report.id) + assertTrue(restarted.hostedDeletionIntents().isEmpty()) + assertNull(restarted.hostedReadyBinding(report.id)) + } + + @Test + fun purgeStagesErasureForReceiptAfterReadyEvidenceWasRemoved() { + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + val store = newStore(nowMs = { day(10) }) + val report = store.save(capture(day = 10, fingerprint = "hosted-ready-purge", binding = hostedBinding)) + store.markHostedProcessing(report.id, "ABC123") + store.recordHostedReadyAndDelete(report.id, hostedBinding) + + store.purge(hostedBinding.binding) + + assertNull(store.load(report.id)) + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + } + + @Test + fun hostedReadyReceiptExpiryOrClockJumpTransitionsToDurableErasureInsteadOfDiscardingAuthority() { + val hostedBinding = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, + ) + var now = day(10) + val store = newStore(nowMs = { now }) + val report = store.save(capture(day = 10, fingerprint = "hosted-ready-retention", binding = hostedBinding)) + store.recordHostedReadyAndDelete(report.id, hostedBinding) + + now = day(47) + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + now += 1 + assertEquals(hostedBinding.binding, store.hostedReadyBinding(report.id)) + assertEquals(listOf(report.id), store.hostedDeletionIntents()) + + store.completeHostedDeletion(report.id) + assertNull(store.hostedReadyBinding(report.id)) + assertTrue(store.hostedDeletionIntents().isEmpty()) + } + private fun newStore( nowMs: () -> Long, maxReportsPerBinding: Int = 3, retentionMs: Long = day(7), + deleteRecursively: (File) -> Boolean = File::deleteRecursively, + listFiles: (File) -> Array? = File::listFiles, + directorySync: (File) -> Unit = {}, ): FilePendingReportStore = FilePendingReportStore( noBackupFilesDir = temporaryFolder.root.resolve("store"), nowMs = nowMs, maxReportsPerBinding = maxReportsPerBinding, retentionMs = retentionMs, + deleteRecursively = deleteRecursively, + listFiles = listFiles, + directorySync = directorySync, + atomicRename = ::testAtomicRename, + ) + + private fun hostedBinding() = PendingReportBinding( + serverInstanceId = HOSTED_DIAGNOSTICS_COLLECTOR_ID, + accountUserId = "anonymous-hosted-device", + profileId = null, + ownershipGeneration = 7, + destinationKind = DiagnosticsDestinationKind.HOSTED, ) private fun capture( @@ -190,5 +877,10 @@ class PendingReportStoreTest { private companion object { fun day(value: Int): Long = value * 24L * 60 * 60 * 1_000 + + private fun List.indexOfFirstAfter(startIndex: Int, predicate: (String) -> Boolean): Int { + val relative = drop(startIndex + 1).indexOfFirst(predicate) + return if (relative < 0) -1 else startIndex + 1 + relative + } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLogTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLogTest.kt index 199a23996..649e22657 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLogTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/diagnostics/PrairieLogTest.kt @@ -86,6 +86,49 @@ class PrairieLogTest { } } + /** + * The whole point of naming the route: #199 added + * `DiagnosticsFocusLogger.contentEntryFailed(route)` without registering + * the attribute, so the warning meant to make silent focus failures visible + * threw the moment it fired in a debug build. A strict render of exactly + * that shape is the regression guard. + */ + @Test + fun strictRendererAcceptsTheFocusRouteAttribute() { + val renderer = DiagnosticsLogRenderer(redactor, strictAttributeRegistry = true) + + val rendered = assertNotNull( + renderer.render( + DiagnosticsLogLevel.WARNING, + DiagnosticsLogCategory.FOCUS, + "TvShell", + "content entry failed", + mapOf( + "target" to PrairieLogAttribute.Text("content"), + "action" to PrairieLogAttribute.Text("entry"), + "route" to PrairieLogAttribute.Text("main/movies"), + ), + ), + ) + + assertTrue(rendered.contains("main/movies"), rendered) + } + + @Test + fun strictRendererStillRejectsAFocusRouteOfTheWrongKind() { + val renderer = DiagnosticsLogRenderer(redactor, strictAttributeRegistry = true) + + assertFailsWith { + renderer.render( + DiagnosticsLogLevel.WARNING, + DiagnosticsLogCategory.FOCUS, + "TvShell", + "content entry failed", + mapOf("route" to PrairieLogAttribute.Integer(7)), + ) + } + } + @Test fun strictRendererAcceptsRegisteredSeekPerformanceAttributes() { val renderer = DiagnosticsLogRenderer(redactor, strictAttributeRegistry = true) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerHttpStatusTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerHttpStatusTest.kt index 0c835d682..49e585705 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerHttpStatusTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerHttpStatusTest.kt @@ -6,6 +6,9 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.prairieserver.prairie.network.PrairieAuthUnavailableException class DownloadWorkerHttpStatusTest { @Test @@ -68,3 +71,32 @@ class DownloadWorkerHttpStatusTest { assertIs(downloadHttpStatusFailure(HttpStatusCode.Conflict)) } } + +class DownloadWorkerAuthFailureTest { + @Test + fun `a repudiated session is retriable, not a permanent download failure`() { + assertTrue( + downloadAuthFailureIsRetriable( + PrairieAuthUnavailableException(PrairieAuthUnavailableException.CREDENTIALS_REPUDIATED), + ), + ) + assertTrue( + downloadAuthFailureIsRetriable( + PrairieAuthUnavailableException(PrairieAuthUnavailableException.REQUIRED_AUTH_UNAVAILABLE), + ), + ) + } + + @Test + fun `a genuine client error stays permanent`() { + // PrairieAuthUnavailableException extends IllegalStateException, which is + // what downloadHttpStatusFailure returns for a 404. Widening the auth + // predicate to that supertype would make every 404 retry forever. + assertFalse( + downloadAuthFailureIsRetriable( + downloadHttpStatusFailure(HttpStatusCode.NotFound)!!, + ), + ) + assertFalse(downloadAuthFailureIsRetriable(IOException("socket closed"))) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt new file mode 100644 index 000000000..6ce5d2353 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorkerProgressTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.common.downloads + +import org.prairieserver.prairie.model.download.DownloadRecord +import org.prairieserver.prairie.model.download.DownloadStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +class DownloadWorkerProgressTest { + private val record = DownloadRecord( + id = "download-1", + contentId = "content-1", + mediaFileId = 7, + fileSize = 10_000, + bytesSent = 4_000, + kind = "download", + status = DownloadStatus.Downloading.wire, + createdAt = "2026-07-28T00:00:00Z", + ) + + @Test + fun `permanent failure explicitly clears stale progress`() { + val failed = record.withWorkerStatus( + status = DownloadStatus.Failed.wire, + bytesSent = 0, + fileSize = 0, + ) + + assertEquals(DownloadStatus.Failed.wire, failed.status) + assertEquals(0, failed.bytesSent) + assertEquals(0, failed.fileSize) + } + + @Test + fun `omitted progress preserves resume state`() { + val downloading = record.withWorkerStatus( + status = DownloadStatus.Downloading.wire, + ) + + assertEquals(4_000, downloading.bytesSent) + assertEquals(10_000, downloading.fileSize) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt index af784e41a..385057af9 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt @@ -7,12 +7,14 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -374,6 +376,8 @@ class OrphanedServerDataPurgerTest { val finishInitialPurge = CompletableDeferred() val initialRowsPurged = CompletableDeferred() val rowsPurged = CompletableDeferred() + val secondPurgeStarted = CompletableDeferred() + val allowSecondPurge = CompletableDeferred() val purger = OrphanedServerDataPurger( registry = registry, purgeDao = db.serverPurgeDao(), @@ -387,6 +391,10 @@ class OrphanedServerDataPurgerTest { initialPurgeStarted.complete(Unit) finishInitialPurge.await() } + if (orphanId == serverId) { + secondPurgeStarted.complete(Unit) + allowSecondPurge.await() + } db.serverPurgeDao().deleteAllRowsForServer(orphanId) if (orphanId == "preexisting-orphan") { initialRowsPurged.complete(Unit) @@ -400,21 +408,31 @@ class OrphanedServerDataPurgerTest { val observer = purger.start() var observerFailure: Throwable? = null observer.invokeOnCompletion { observerFailure = it } - initialPurgeStarted.await() - registry.remove(serverId) - assertTrue(registry.entries.value.none { it.id == serverId }) - finishInitialPurge.complete(Unit) - initialRowsPurged.await() - assertTrue(observer.isActive, "purge observer stopped: $observerFailure") - assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) - assertTrue( - db.downloadDao().get(serverId, "p1", 10) != null, - "removed server was unexpectedly part of the startup orphan snapshot", - ) - - rowsPurged.await() - assertNull(db.downloadDao().get(serverId, "p1", 10)) - observer.cancel() + try { + initialPurgeStarted.await() + registry.remove(serverId) + assertTrue(registry.entries.value.none { it.id == serverId }) + finishInitialPurge.complete(Unit) + initialRowsPurged.await() + secondPurgeStarted.await() + assertTrue(observer.isActive, "purge observer stopped: $observerFailure") + assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) + assertTrue( + db.downloadDao().get(serverId, "p1", 10) != null, + "second-pass deletion must remain gated until the snapshot assertion completes", + ) + + allowSecondPurge.complete(Unit) + rowsPurged.await() + assertNull(db.downloadDao().get(serverId, "p1", 10)) + } finally { + withContext(NonCancellable) { + finishInitialPurge.complete(Unit) + allowSecondPurge.complete(Unit) + observer.cancel() + observer.join() + } + } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchTogetherRealtimeWebSocketTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchTogetherRealtimeWebSocketTest.kt index 87a0edad9..d60648531 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchTogetherRealtimeWebSocketTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/WatchTogetherRealtimeWebSocketTest.kt @@ -22,6 +22,7 @@ import org.prairieserver.prairie.network.CleartextOriginNotApprovedException import org.prairieserver.prairie.network.DefaultWatchTogetherRealtimeClient import org.prairieserver.prairie.network.RoomRealtimeEvent import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.ProfileIdentity import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.TokenManagerImpl import org.prairieserver.prairie.network.canonicalHttpOrigin @@ -314,6 +315,10 @@ class WatchTogetherRealtimeWebSocketTest { override suspend fun getProfileId(): String = if (activeB) "profile-b" else "profile-a" + // See PrairieAuthPluginPinTest: the delegated default would bypass these. + override suspend fun getProfileIdentity(): ProfileIdentity = + ProfileIdentity(getProfileId(), getProfileToken()) + override suspend fun getProfileToken(): String { val token = if (activeB) "PROFILE_B" else "PROFILE_A" activeB = true diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt index 4f9e3f4b7..ebc77fe6e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/PairingReceiverTest.kt @@ -126,7 +126,7 @@ private class FakeDeviceLogin : DeviceLoginPort { expiresIn = 600, interval = 5, deviceName = "Test TV", - devicePlatform = "Android TV", + devicePlatform = "android-tv", ) val APPROVED_RESPONSE = DeviceLoginPollResponse( status = "approved", @@ -193,8 +193,11 @@ class PairingReceiverTest { // Let the receiver begin against the candidate URL and observe Awaiting. repeat(10) { yield() } + // The header spelling, not "Android TV"/"android_tv"/"androidtv": the + // server's platform classifier buckets anything else as mobile, so all + // three TV login entry points must report the one string. assertEquals( - FakeDeviceLogin.BeginCall("https://srv.test", "Test TV", "Android TV"), + FakeDeviceLogin.BeginCall("https://srv.test", "Test TV", "android-tv"), login.beganWith, ) assertEquals(emptyList(), auth.committedSessions) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt index 66ac07de9..bb6d4f798 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/pairing/RegistryPairingAuthPortTest.kt @@ -2,15 +2,22 @@ package org.prairieserver.prairie.common.pairing import android.content.Context import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.prairieserver.prairie.network.AndroidServerRegistry +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.EncryptedTokenManagerImpl +import org.prairieserver.prairie.network.IdentityTransitionKind import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertFailsWith +import kotlin.test.assertTrue import org.prairieserver.prairie.network.CleartextOriginConsent import org.prairieserver.prairie.network.CleartextOriginNotApprovedException @@ -48,7 +55,7 @@ class RegistryPairingAuthPortTest { prefs.edit().clear().commit() val registry = AndroidServerRegistry(prefs) val tokens = EncryptedTokenManagerImpl(prefs, registry) - val serverUrl = "https://prairie.example" + val serverUrl = "https://silo.example" val serverId = registry.addOrUpdate(serverUrl, fetchedName = "Old Server Name") registry.rename(serverId, "Living Room") registry.switchTo(serverId) @@ -75,4 +82,233 @@ class RegistryPairingAuthPortTest { assertNull(tokens.getProfileId()) assertNull(tokens.getProfileToken()) } + + @Test + fun sameServerReplacementWaitsForASuspendedCreateFence() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-create-fence", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val transitions = DefaultIdentityTransitionBarrier() + val registry = AndroidServerRegistry(prefs, transitions) + val serverUrl = "https://silo.example" + val serverId = registry.addOrUpdate(serverUrl) + registry.switchTo(serverId) + val tokens = EncryptedTokenManagerImpl(prefs, registry, transitions) + tokens.saveTokens("old-access", "old-refresh", 3600) + val createStarted = CompletableDeferred() + val releaseCreate = CompletableDeferred() + val expectedGeneration = transitions.generation.value + val create = async { + transitions.withCurrentGeneration(expectedGeneration) { + createStarted.complete(Unit) + releaseCreate.await() + checkNotNull(tokens.getAccessToken()) + } + } + createStarted.await() + + val replacement = async { + RegistryPairingAuthPort(tokens, registry).persistApprovedSession( + serverUrl = serverUrl, + serverName = null, + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 7200, + ) + } + runCurrent() + + assertFalse(replacement.isCompleted) + assertEquals("old-access", tokens.getAccessToken()) + releaseCreate.complete(Unit) + assertEquals("old-access", create.await()) + replacement.await() + assertEquals("new-access", tokens.getAccessToken()) + assertTrue(transitions.generation.value > expectedGeneration) + } + + @Test + fun accountPurgeFailureAbortsSameServerCredentialReplacement() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-purge-failure", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val transitions = DefaultIdentityTransitionBarrier() + val registry = AndroidServerRegistry(prefs, transitions) + val serverUrl = "https://silo.example" + val serverId = registry.addOrUpdate(serverUrl) + registry.switchTo(serverId) + val tokens = EncryptedTokenManagerImpl(prefs, registry, transitions) + tokens.saveTokens("old-access", "old-refresh", 3600) + tokens.setProfileIdentity("old-profile", "old-profile-token") + registry.setProfileId(serverId, "old-profile") + transitions.installGate { transition -> + if (transition.kind == IdentityTransitionKind.ACCOUNT_REPLACE) { + error("injected diagnostics purge failure") + } + } + + assertFailsWith { + RegistryPairingAuthPort(tokens, registry).persistApprovedSession( + serverUrl = serverUrl, + serverName = null, + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 7200, + ) + } + + assertEquals(serverId, registry.activeServerId.value) + assertEquals("old-access", tokens.getAccessToken()) + assertEquals("old-refresh", tokens.getRefreshToken()) + assertEquals("old-profile", tokens.getProfileId()) + assertEquals("old-profile-token", tokens.getProfileToken()) + } + + @Test + fun processDeathAfterAtomicCommitReconstructsOneCompleteNewIdentity() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-account-commit-reconstruction", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val registry = AndroidServerRegistry(prefs) + val oldId = registry.addOrUpdate("https://old.example") + val newId = registry.addOrUpdate("https://new.example") + registry.switchTo(oldId) + val simulatedDeath = EncryptedTokenManagerImpl( + prefs = prefs, + registry = registry, + afterAccountSessionCommit = { error("simulated process death") }, + ) + simulatedDeath.saveTokens("old-access", "old-refresh", 3600) + + assertFailsWith { + simulatedDeath.replaceAccountSession( + serverId = newId, + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 7200, + profileId = "new-profile", + profileToken = "new-profile-token", + ) + } + + val reconstructedRegistry = AndroidServerRegistry(prefs) + val reconstructedTokens = EncryptedTokenManagerImpl(prefs, reconstructedRegistry) + assertEquals(newId, reconstructedRegistry.activeServerId.value) + assertEquals("new-profile", reconstructedRegistry.activeEntry.value?.profileId) + assertEquals("new-access", reconstructedTokens.getAccessToken()) + assertEquals("new-refresh", reconstructedTokens.getRefreshToken()) + assertEquals("new-profile", reconstructedTokens.getProfileId()) + assertEquals("new-profile-token", reconstructedTokens.getProfileToken()) + } + + @Test + fun failedAtomicCommitLeavesOldRegistryAndCredentialsVisible() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-account-commit-failure", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val transitions = org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier() + val initialRegistry = AndroidServerRegistry(prefs, transitions) + val oldId = initialRegistry.addOrUpdate("https://old.example") + val newId = initialRegistry.addOrUpdate("https://new.example") + initialRegistry.switchTo(oldId) + val initialTokens = EncryptedTokenManagerImpl(prefs, initialRegistry, transitions) + initialTokens.saveTokens("old-access", "old-refresh", 3600) + initialTokens.setProfileIdentity("old-profile", "old-profile-token") + initialRegistry.setProfileId(oldId, "old-profile") + + val failingRegistry = AndroidServerRegistry(prefs, transitions, commitEditor = { false }) + val failingTokens = EncryptedTokenManagerImpl(prefs, failingRegistry, transitions) + var gateRan = false + transitions.installGate { transition -> + if (transition.kind == org.prairieserver.prairie.network.IdentityTransitionKind.ACCOUNT_REPLACE) gateRan = true + } + + assertFailsWith { + failingTokens.replaceAccountSession( + serverId = newId, + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 7200, + ) + } + + assertTrue(gateRan) + assertEquals(oldId, failingRegistry.activeServerId.value) + assertEquals("old-access", failingTokens.getAccessToken()) + assertEquals("old-refresh", failingTokens.getRefreshToken()) + assertEquals("old-profile", failingTokens.getProfileId()) + assertFalse(prefs.contains(AndroidServerRegistry.serverScopedKey(newId, "access_token"))) + } + + @Test + fun processDeathAfterAtomicSignOutReconstructsNoCredentialsOrProfile() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-sign-out-reconstruction", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val registry = AndroidServerRegistry(prefs) + val serverId = registry.addOrUpdate("https://signed-in.example") + registry.switchTo(serverId) + EncryptedTokenManagerImpl(prefs, registry).apply { + saveTokens("old-access", "old-refresh", 3600) + setProfileIdentity("old-profile", "old-profile-token") + } + registry.setProfileId(serverId, "old-profile") + val simulatedDeath = EncryptedTokenManagerImpl( + prefs = prefs, + registry = AndroidServerRegistry(prefs), + afterAccountSignOutCommit = { error("simulated process death") }, + ) + + assertFailsWith { simulatedDeath.signOutCurrentServer() } + + val reconstructedRegistry = AndroidServerRegistry(prefs) + val reconstructedTokens = EncryptedTokenManagerImpl(prefs, reconstructedRegistry) + assertEquals(serverId, reconstructedRegistry.activeServerId.value) + assertNull(reconstructedRegistry.activeEntry.value?.profileId) + assertNull(reconstructedTokens.getAccessToken()) + assertNull(reconstructedTokens.getRefreshToken()) + assertNull(reconstructedTokens.getProfileId()) + assertNull(reconstructedTokens.getProfileToken()) + } + + @Test + fun processDeathAfterAtomicServerRemovalReconstructsTargetAbsentWithoutTouchingOtherCredentials() = runTest { + val context = ApplicationProvider.getApplicationContext() + val prefs = context.getSharedPreferences("pairing-server-remove-reconstruction", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val initialRegistry = AndroidServerRegistry(prefs) + val serverA = initialRegistry.addOrUpdate("https://a.example") + val serverB = initialRegistry.addOrUpdate("https://b.example") + val initialTokens = EncryptedTokenManagerImpl(prefs, initialRegistry) + initialTokens.replaceAccountSession( + serverId = serverA, + accessToken = "a-access", + refreshToken = "a-refresh", + expiresIn = 3600, + ) + initialTokens.replaceAccountSession( + serverId = serverB, + accessToken = "b-access", + refreshToken = "b-refresh", + expiresIn = 3600, + ) + initialRegistry.switchTo(serverA) + initialTokens.switchActiveServer(serverA) + val simulatedDeathRegistry = AndroidServerRegistry( + prefs = prefs, + afterServerRemovalCommit = { error("simulated process death") }, + ) + + assertFailsWith { simulatedDeathRegistry.remove(serverB) } + + val reconstructedRegistry = AndroidServerRegistry(prefs) + val reconstructedTokens = EncryptedTokenManagerImpl(prefs, reconstructedRegistry) + assertEquals(serverA, reconstructedRegistry.activeServerId.value) + assertTrue(reconstructedRegistry.entries.value.none { it.id == serverB }) + assertEquals("a-access", reconstructedTokens.getAccessToken()) + assertEquals("a-refresh", reconstructedTokens.getRefreshToken()) + assertFalse( + prefs.all.keys.any { key -> key.startsWith(AndroidServerRegistry.serverScopedKey(serverB, "")) }, + ) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicyTest.kt new file mode 100644 index 000000000..71fb07e2a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicyTest.kt @@ -0,0 +1,92 @@ +package org.prairieserver.prairie.common.player + +import android.app.Activity +import androidx.annotation.OptIn +import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.SubtitleView +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +class AndroidSubtitleTextSizePolicyTest { + @Test + fun televisionUsesFixedCouchReadableSpLadder() { + val expected = mapOf( + SubtitleFontSizePreset.Small to 18f, + SubtitleFontSizePreset.Medium to 22f, + SubtitleFontSizePreset.Large to 26f, + SubtitleFontSizePreset.XLarge to 32f, + SubtitleFontSizePreset.XXLarge to 40f, + ) + + expected.forEach { (preset, sp) -> + assertEquals( + AndroidSubtitleTextSize.FixedSp(sp), + androidSubtitleTextSize(AndroidSubtitlePresentation.Television, preset), + ) + } + } + + @Test + fun phonePreservesExistingFractionalLadder() { + val expected = mapOf( + SubtitleFontSizePreset.Small to 22.5f / 720f, + SubtitleFontSizePreset.Medium to 29.25f / 720f, + SubtitleFontSizePreset.Large to 36f / 720f, + SubtitleFontSizePreset.XLarge to 45f / 720f, + SubtitleFontSizePreset.XXLarge to 54f / 720f, + ) + + expected.forEach { (preset, fraction) -> + assertEquals( + AndroidSubtitleTextSize.Fractional(fraction), + androidSubtitleTextSize(AndroidSubtitlePresentation.Phone, preset), + ) + } + } + + @Test + fun fixedSpTextSizeUsesMedia3AbsoluteSizing() { + val subtitleView = SubtitleView(Robolectric.buildActivity(Activity::class.java).setup().get()) + + applyAndroidSubtitleTextSize(subtitleView, AndroidSubtitleTextSize.FixedSp(32f)) + + assertEquals( + SubtitleViewTextSizeConfig(Cue.TEXT_SIZE_TYPE_ABSOLUTE, 32f), + subtitleView.textSizeConfig(), + ) + } + + @Test + fun fractionalTextSizeUsesMedia3ViewHeightSizing() { + val subtitleView = SubtitleView(Robolectric.buildActivity(Activity::class.java).setup().get()) + + applyAndroidSubtitleTextSize(subtitleView, AndroidSubtitleTextSize.Fractional(0.05f)) + + assertEquals( + SubtitleViewTextSizeConfig(Cue.TEXT_SIZE_TYPE_FRACTIONAL, 0.05f), + subtitleView.textSizeConfig(), + ) + } +} + +private data class SubtitleViewTextSizeConfig( + val type: Int, + val size: Float, +) + +private fun SubtitleView.textSizeConfig(): SubtitleViewTextSizeConfig { + val type = SubtitleView::class.java.getDeclaredField("defaultTextSizeType").apply { + isAccessible = true + }.getInt(this) + val size = SubtitleView::class.java.getDeclaredField("defaultTextSize").apply { + isAccessible = true + }.getFloat(this) + return SubtitleViewTextSizeConfig(type, size) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt new file mode 100644 index 000000000..ba7a7e70a --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerTeardownSourceTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.common.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AudiobookPlayerTeardownSourceTest { + private val viewModelSource = File( + requireNotNull(System.getProperty("user.dir")), + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt", + ).readText() + + private val onClearedSource = viewModelSource + .substringAfter("override fun onCleared() {") + .substringBefore("\n companion object") + + private val singleFileStartSource = viewModelSource + .substringAfter("private suspend fun startSingleFileSession(") + .substringBefore("private suspend fun startPartSession(") + + private val partStartSource = viewModelSource + .substringAfter("private suspend fun startPartSession(") + .substringBefore("private suspend fun retireActiveSession(") + + @Test + fun `onCleared reports both timelines through the retained lifecycle without blocking`() { + assertFalse(onClearedSource.contains("runBlocking")) + assertTrue(onClearedSource.contains("val state = _uiState.value")) + assertTrue(onClearedSource.contains("val sessionId = state.sessionId")) + assertTrue( + onClearedSource.contains( + "positionSec = sessionLocalPosition(state)", + ), + ) + assertTrue( + onClearedSource.contains( + "playbackSessionLifecycle.reportPosition(", + ), + ) + assertTrue(onClearedSource.contains("persistencePositionSec = state.positionSeconds")) + assertTrue(onClearedSource.contains("expectedSessionId = sessionId")) + assertTrue(onClearedSource.contains("playbackSessionLifecycle.stopAsync(")) + } + + @Test + fun `single file start cannot publish after stop or teardown invalidates it`() { + assertTrue(singleFileStartSource.contains("generation != startGeneration || isClosing")) + assertTrue(singleFileStartSource.contains("playbackSessionManager.stopSession")) + assertTrue(singleFileStartSource.indexOf("generation != startGeneration || isClosing") < + singleFileStartSource.indexOf("applyStartedSession(")) + } + + @Test + fun `audiobook starts delegate durable progress to the client timeline`() { + assertTrue(partStartSource.contains("startPosition = startPosition")) + assertTrue(partStartSource.contains("progressPersistence = ProgressPersistenceV3.CLIENT")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactoryTest.kt index b3c2f11fd..46fb69ffb 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactoryTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/DolbyVisionColorInfoExtractorsFactoryTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertSame class DolbyVisionColorInfoExtractorsFactoryTest { @Test fun transformTagCarriesExpectedColorRange() { - val tag = SiloMediaTransformTag( + val tag = PrairieMediaTransformTag( dolbyVisionMode = DolbyVisionTransformMode.DISABLED, expectedDynamicRange = "hlg", expectedColorRange = "pc", diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PipActionCapabilityTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PipActionCapabilityTest.kt index 8cda0f604..0f9c4f22e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PipActionCapabilityTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PipActionCapabilityTest.kt @@ -136,12 +136,16 @@ class PipActionCapabilityTest { "src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackService.kt", ).readText() + assertTrue( + source.contains(" return mediaSession\n }"), + "capability checks must not gate trusted MediaSession controllers", + ) assertTrue( source.contains( - "override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =\n" + - " mediaSession", + "if (isMediaButtonFallbackCaller(controllerInfo.connectionHints) && " + + "hasNothingToServeNow()) {", ), - "capability checks must not gate trusted MediaSession controllers", + "only the synthetic media-button caller may ever be refused a session", ) assertTrue( source.contains("return super.onStartCommand(intent, flags, startId)"), diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt index bd0eb444c..4bfde731a 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt @@ -2,140 +2,198 @@ package org.prairieserver.prairie.common.player import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class PlaybackBufferPolicyTest { - @Test - fun profilesExposeExpectedStartupAndRebufferTargets() { - val quick = PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart) - val balanced = PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced) - val smooth = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback) - - assertEquals(2_000, quick.bufferForPlaybackMs) - assertEquals(6_000, quick.bufferForPlaybackAfterRebufferMs) - assertEquals(3_000, balanced.bufferForPlaybackMs) - assertEquals(10_000, balanced.bufferForPlaybackAfterRebufferMs) - assertEquals(5_000, smooth.bufferForPlaybackMs) - assertEquals(15_000, smooth.bufferForPlaybackAfterRebufferMs) - } + private val roomy = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + private val lowRam = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + // The load control stops reading the socket once the buffer reaches + // maxBufferMs and does not resume until it drains below minBufferMs, so + // this gap IS how long the connection sits idle. An upstream proxy with a + // 60s send timeout drops it if the gap approaches that. This is the + // property the whole design exists to guarantee. @Test - fun profilesKeepBufferDurationsInValidOrder() { - PlaybackBufferMode.entries.forEach { mode -> - val policy = PlaybackBufferPolicy.forMode(mode) - assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, mode.name) - assertTrue(policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, mode.name) - assertTrue(policy.minBufferMs <= policy.maxBufferMs, mode.name) + fun `idle window is bounded for every device profile`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertEquals( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, + policy.maxBufferMs - policy.minBufferMs, + "idle window for $profile", + ) } } @Test - fun quickStartUsesHeapBoundedByteCapForHighBitrate4kDirectPlay() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice) + fun `idle window stays well under the proxy send timeout it guards against`() { + assertTrue( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS * 2 <= + PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window should keep a wide margin below the assumed timeout", + ) + } - assertEquals(128 * 1024 * 1024, policy.targetBufferBytes) - assertFalse(policy.prioritizeTimeOverSizeThresholds) - assertTrue(policy.maxBufferMs <= 60_000) + // The idle window is expressed in MEDIA time, but a proxy's send_timeout + // measures WALL CLOCK time, and DefaultLoadControl only scales + // minBufferUs for speeds ABOVE 1.0 — not below it. Audiobooks share this + // load control and the UI offers rates down to + // PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED (0.5x), where one + // media-time second of idle window takes two wall-clock seconds. This + // asserts the window still fits inside the assumed proxy timeout once + // stretched by that slowest rate, not just at 1.0x. + // + // The comparison is strict on purpose. Equality is not "fits" — a socket + // that goes quiet for exactly the timeout is a race the proxy wins about + // as often as we do. It also matters for what this test is FOR: with a + // non-strict comparison, reverting MAX_LOAD_IDLE_MS to the pre-fix 30_000 + // gives 60_000 <= 60_000 and the guard passes, silently readmitting the + // exact bug this work removed. + @Test + fun `idle window still fits the proxy timeout once stretched by the slowest playback speed`() { + val stretchedWallClockMs = + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS / PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED + + assertTrue( + stretchedWallClockMs < PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window ($stretchedWallClockMs ms wall clock at " + + "${PlaybackBufferPolicy.SLOWEST_PLAYBACK_SPEED}x) should still fit inside the " + + "assumed proxy timeout (${PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS} ms)", + ) } @Test - fun smoothPlaybackUsesHeapBoundedByteCapForHighBitrateRemuxes() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice) + fun `playback starts on a small cushion and recovers quickly after a stall`() { + val policy = PlaybackBufferPolicy.forConditions(roomy) + assertEquals(2_000, policy.bufferForPlaybackMs) + assertEquals(5_000, policy.bufferForPlaybackAfterRebufferMs) + } - assertEquals(192 * 1024 * 1024, policy.targetBufferBytes) - assertFalse(policy.prioritizeTimeOverSizeThresholds) - assertTrue(policy.maxBufferMs <= 180_000) + @Test + fun `depth stays within the declared floor and ceiling`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.minBufferMs >= PlaybackBufferPolicy.MIN_DEPTH_MS, "floor for $profile") + assertTrue(policy.minBufferMs <= PlaybackBufferPolicy.MAX_DEPTH_MS, "ceiling for $profile") + } } @Test - fun smoothPlaybackPrioritizesDeepForwardBufferingOverQuickStartup() { - val policy = PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback) + fun `startup thresholds never exceed the depth the policy asks for`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, "start for $profile") + assertTrue( + policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, + "rebuffer for $profile", + ) + } + } - assertTrue(policy.bufferForPlaybackMs >= PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced).bufferForPlaybackMs) - assertTrue(policy.bufferForPlaybackAfterRebufferMs >= policy.bufferForPlaybackMs * 3) - assertTrue(policy.minBufferMs >= policy.bufferForPlaybackAfterRebufferMs * 3) - assertTrue(policy.maxBufferMs >= policy.minBufferMs * 2) + @Test + fun `a small heap gets exactly half its heap as a buffer budget`() { + // Product ruling: half the heap, not a quarter. A quarter-heap rule + // gives a 96 MB heap only 24 MiB — the exact fixed floor this policy + // replaced, not an improvement on it. + val smallHeap = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = false) + val budgetBytes = PlaybackBufferPolicy.memoryBudgetBytes(smallHeap) + val halfHeapBytes = (smallHeap.memoryClassMb * 1024 * 1024) / 2 + + assertEquals(halfHeapBytes, budgetBytes) } @Test - fun allProfilesHaveFiniteTargetByteCaps() { - assertEquals(128 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice).targetBufferBytes) - assertEquals(160 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, roomyDevice).targetBufferBytes) - assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice).targetBufferBytes) + fun `NVIDIA Shield measured memoryClass gets half its heap, not a quarter`() { + // Measured via adb: the Shield reports memoryClass=192MB and is not + // flagged low-RAM. Under a quarter-heap rule it would get 48 MiB — + // LESS than the 96 MiB it shipped with before this policy existed. + val shield = PlaybackBufferDeviceProfile(memoryClassMb = 192, isLowRamDevice = false) + + assertEquals(96 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(shield)) } @Test - fun lowMemoryDevicesUseSmallerByteCaps() { - val lowMemory = PlaybackBufferDeviceProfile(memoryClassMb = 128, isLowRamDevice = true) + fun `Google TV Streamer measured memoryClass hits the ceiling at half its heap`() { + // Measured via adb: the Streamer reports memoryClass=384MB and is + // not flagged low-RAM. Half of that is exactly the 192 MiB ceiling. + val streamer = PlaybackBufferDeviceProfile(memoryClassMb = 384, isLowRamDevice = false) - assertEquals(32 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, lowMemory).targetBufferBytes) - assertEquals(48 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, lowMemory).targetBufferBytes) - assertEquals(64 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, lowMemory).targetBufferBytes) + assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(streamer)) } @Test - fun unknownDevicesUseConstrainedByteCapsUntilMemoryClassIsKnown() { - assertEquals( - 32 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.QuickStart, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, - ) - assertEquals( - 48 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.Balanced, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, - ) - assertEquals( - 64 * 1024 * 1024, - PlaybackBufferPolicy.forMode( - PlaybackBufferMode.SmoothPlayback, - PlaybackBufferDeviceProfile.Unknown, - ).targetBufferBytes, + fun `a bigger heap gets a bigger budget than a smaller one`() { + // Proportional scaling means the ceiling grows with the device + // instead of two devices past the old 384 MB tier boundary sharing + // the same flat 160 MiB cap. + val midHeap = PlaybackBufferDeviceProfile(memoryClassMb = 256, isLowRamDevice = false) + val bigHeap = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + + val midBudget = PlaybackBufferPolicy.memoryBudgetBytes(midHeap) + val bigBudget = PlaybackBufferPolicy.memoryBudgetBytes(bigHeap) + + assertTrue( + bigBudget > midBudget, + "a 512 MB heap ($bigBudget) should get more budget than a 256 MB heap ($midBudget)", ) } @Test - fun roomyDevicesKeepLargeByteCaps() { - assertEquals(128 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.QuickStart, roomyDevice).targetBufferBytes) - assertEquals(160 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, roomyDevice).targetBufferBytes) - assertEquals(192 * 1024 * 1024, PlaybackBufferPolicy.forMode(PlaybackBufferMode.SmoothPlayback, roomyDevice).targetBufferBytes) + fun `a low-RAM device with an unknown heap gets the conservative fixed fallback`() { + val unknownHeapLowRam = PlaybackBufferDeviceProfile(memoryClassMb = 0, isLowRamDevice = true) + + assertEquals(24 * 1024 * 1024, PlaybackBufferPolicy.memoryBudgetBytes(unknownHeapLowRam)) } @Test - fun bitrateAwareTargetScalesLowBitrateStreamsBelowDeviceCap() { + fun `a low-RAM device with a small known heap gets the smaller of the flat fallback and its proportional share`() { + // A low-RAM stick reporting a small but genuinely known memoryClass + // must not have that number thrown away in favor of the flat 24 MiB + // fallback — that would be the exact flaw (a fixed value ignoring + // what the device actually reports) the proportional rule exists to + // remove. 48MB is a real memoryClass a low-RAM device could report; + // half of it (24 MiB) ties the flat fallback, so use a heap small + // enough that the proportional share is strictly smaller. + val smallKnownHeapLowRam = + PlaybackBufferDeviceProfile(memoryClassMb = 32, isLowRamDevice = true) + val proportionalBytes = (32 * 1024 * 1024) / 2 + + assertTrue(proportionalBytes < 24 * 1024 * 1024, "test heap must undercut the flat fallback") assertEquals( - 35_937_500, - calculateBitrateTargetBufferBytes( - selectedBitrateBps = 5_000_000, - desiredForwardBufferMs = 50_000, - minimumBytes = 16 * 1024 * 1024, - maximumBytes = 160 * 1024 * 1024, - unknownBitrateFallbackBytes = 96 * 1024 * 1024, - ), + proportionalBytes, + PlaybackBufferPolicy.memoryBudgetBytes(smallKnownHeapLowRam), ) } @Test - fun bitrateAwareTargetClampsHighBitrateRemuxesToDeviceCap() { + fun `a low-RAM device with a larger known heap is still capped at the flat fallback`() { + // The flat 24 MiB fallback must still act as a ceiling on the + // low-RAM path: a low-RAM device reporting a heap large enough that + // half of it exceeds 24 MiB must not get more than the conservative + // fallback just because isLowRamDevice happened to be paired with a + // roomier-looking memoryClass. + val largerKnownHeapLowRam = + PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + assertEquals( - 160 * 1024 * 1024, - calculateBitrateTargetBufferBytes( - selectedBitrateBps = 100_000_000, - desiredForwardBufferMs = 50_000, - minimumBytes = 16 * 1024 * 1024, - maximumBytes = 160 * 1024 * 1024, - unknownBitrateFallbackBytes = 96 * 1024 * 1024, - ), + 24 * 1024 * 1024, + PlaybackBufferPolicy.memoryBudgetBytes(largerKnownHeapLowRam), ) } - private companion object { - val roomyDevice = PlaybackBufferDeviceProfile(memoryClassMb = 384, isLowRamDevice = false) + @Test + fun `constructing a policy with a wider idle window than MAX_LOAD_IDLE_MS throws`() { + assertFailsWith { + PlaybackBufferPolicy( + minBufferMs = 50_000, + maxBufferMs = 120_000, + bufferForPlaybackMs = 2_000, + bufferForPlaybackAfterRebufferMs = 5_000, + targetBufferBytes = 16 * 1024 * 1024, + prioritizeTimeOverSizeThresholds = false, + ) + } } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorAudioSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorAudioSupportTest.kt index 6484440ea..e8aed0cd6 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorAudioSupportTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorAudioSupportTest.kt @@ -5,72 +5,324 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities +import org.prairieserver.prairie.model.playback.AudioPassthroughEntry class PlaybackCapabilityDetectorAudioSupportTest { + private fun decoder(mime: String, codec: String, maxChannels: Int?, name: String = "c2.test") = + PlatformAudioDecodeCapability(mime, codec, name, maxChannels) + + private val sixChannelEac3 = listOf(decoder(MimeTypes.AUDIO_E_AC3, "eac3", 6)) + private val stereoOnlyEac3 = listOf(decoder(MimeTypes.AUDIO_E_AC3, "eac3", 2)) + + /** + * The live failure: four ERROR_CODE_DECODING_FAILED on Pixel 7 Pro and + * Pixel 10 Pro XL, every one a six-channel track, because the codec list + * claimed E-AC3 was decodable without ever asking how many channels the + * decoder took. + */ + @Test + fun `six channel E-AC3 is refused by a stereo-only decoder`() { + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, stereoOnlyEac3, ffmpegAvailable = false), + ) + } + @Test - fun `DTS HD is software decodable when FFmpeg renderer is available`() { + fun `two channel E-AC3 is accepted by the same decoder`() { assertTrue( - isSoftwareDecodableAudioMime( - mime = MimeTypes.AUDIO_DTS_HD, - ffmpegAvailable = true, - ), + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 2, stereoOnlyEac3, ffmpegAvailable = false), ) } @Test - fun `DTS HD is not software decodable without FFmpeg renderer`() { + fun `six channel E-AC3 is accepted by a six channel decoder`() { + assertTrue( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, sixChannelEac3, ffmpegAvailable = false), + ) + } + + /** Several decoders can expose one MIME; the widest is the one that runs. */ + @Test + fun `the widest decoder for a MIME decides`() { + val both = listOf( + decoder(MimeTypes.AUDIO_E_AC3, "eac3", 2, "c2.narrow"), + decoder(MimeTypes.AUDIO_E_AC3, "eac3", 6, "c2.wide"), + ) + assertTrue(canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, both, ffmpegAvailable = false)) + } + + /** A limit must never be borrowed across MIME types. */ + @Test + fun `a wide decoder for another codec does not vouch for this one`() { + val aacOnly = listOf(decoder(MimeTypes.AUDIO_AAC, "aac", 8)) + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, aacOnly, ffmpegAvailable = false), + ) + } + + /** A limit is still never borrowed from an unrelated codec. */ + @Test + fun `AAC support does not vouch for E-AC3`() { assertFalse( - isSoftwareDecodableAudioMime( - mime = MimeTypes.AUDIO_DTS_HD, + canDecodeAudio( + MimeTypes.AUDIO_E_AC3, + 6, + listOf(decoder(MimeTypes.AUDIO_AAC, "aac", 8)), ffmpegAvailable = false, ), ) } + /** + * A device that will not state a limit is not thereby claiming an unlimited + * one, but refusing everything it reports would reject tracks that play + * fine. Existence answers the question; the limit does not exist to compare. + */ @Test - fun `AAC remains platform software decodable without FFmpeg renderer`() { + fun `an unstated limit does not refuse the codec`() { + val unknown = listOf(decoder(MimeTypes.AUDIO_E_AC3, "eac3", null)) + assertTrue(canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, unknown, ffmpegAvailable = false)) + } + + @Test + fun `an unknown channel count asks only whether the codec exists`() { assertTrue( - isSoftwareDecodableAudioMime( - mime = MimeTypes.AUDIO_AAC, - ffmpegAvailable = false, - ), + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 0, stereoOnlyEac3, ffmpegAvailable = false), + ) + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 0, emptyList(), ffmpegAvailable = false), ) } @Test - fun `TV advertises platform decoders and leaves encoded support to passthrough`() { - assertEquals( - listOf("aac", "eac3"), - advertisedAudioDecodeCodecs( - platformCodecs = listOf("aac", "eac3"), - ffmpegAvailable = true, - isTv = true, - ), + fun `DTS HD is decodable when FFmpeg renderer is available`() { + assertTrue( + canDecodeAudio(MimeTypes.AUDIO_DTS_HD, 6, emptyList(), ffmpegAvailable = true), ) } @Test - fun `phone advertises FFmpeg audio decoders`() { - val codecs = advertisedAudioDecodeCodecs( - platformCodecs = listOf("aac", "eac3"), - ffmpegAvailable = true, - isTv = false, + fun `DTS HD is not decodable without FFmpeg renderer`() { + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_DTS_HD, 6, emptyList(), ffmpegAvailable = false), ) + } - assertTrue("truehd" in codecs) - assertTrue("dts_hd" in codecs) + /** + * EXTENSION_RENDERER_MODE_ON puts the platform renderer first, but order is + * only the tie-break: the track selector takes whichever renderer reports + * the greatest format support. MediaCodecAudioRenderer answers + * FORMAT_EXCEEDS_CAPABILITIES for a channel count its decoder will not + * take, and FfmpegAudioRenderer answers FORMAT_HANDLED — so FFmpeg wins + * this one and the track really does play. + */ + @Test + fun `FFmpeg rescues a channel count the platform decoder refuses`() { + assertTrue( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, stereoOnlyEac3, ffmpegAvailable = true), + ) } + /** Without it, the same device cannot play the track. */ @Test - fun `TV does not lose codecs backed by platform decoders`() { - assertEquals( - listOf("aac", "truehd"), - advertisedAudioDecodeCodecs( - platformCodecs = listOf("aac", "truehd"), - ffmpegAvailable = true, - isTv = true, + fun `without FFmpeg the platform channel limit stands`() { + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, stereoOnlyEac3, ffmpegAvailable = false), + ) + } + + @Test + fun `FFmpeg fills a gap the platform cannot cover`() { + assertTrue( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, emptyList(), ffmpegAvailable = true), + ) + } + + /** + * Media3 soft-matches JOC onto a plain E-AC3 decoder + * (MediaCodecUtil.getAlternativeCodecMimeType), so refusing it here would + * reject content the player would happily have handled. + */ + @Test + fun `JOC is accepted by a plain E-AC3 decoder, as Media3 does`() { + assertTrue( + platformCanDecodeAudio(MimeTypes.AUDIO_E_AC3_JOC, 6, sixChannelEac3), + ) + } + + @Test + fun `JOC still respects that decoders channel limit`() { + assertFalse( + platformCanDecodeAudio(MimeTypes.AUDIO_E_AC3_JOC, 6, stereoOnlyEac3), + ) + } + + /** Codec absence and an unusable layout are different verdicts. */ + @Test + fun `a channel-limited decoder still counts as having the codec`() { + assertTrue( + platformCanDecodeAudio(MimeTypes.AUDIO_E_AC3, 0, stereoOnlyEac3), + "asking with no channel count answers only whether the codec exists", + ) + assertFalse( + platformCanDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, stereoOnlyEac3), + ) + } + + @Test + fun `an absent codec is not decodable at all`() { + assertFalse( + canDecodeAudio(MimeTypes.AUDIO_E_AC3, 6, emptyList(), ffmpegAvailable = false), + ) + } + + @Test + fun `codec names map only for MIME types this project tracks`() { + assertEquals("eac3", platformAudioCodecName(MimeTypes.AUDIO_E_AC3)) + assertEquals("eac3_joc", platformAudioCodecName(MimeTypes.AUDIO_E_AC3_JOC)) + assertEquals(null, platformAudioCodecName(MimeTypes.AUDIO_DTS_HD)) + } +} + +/** + * The sink carrying a codec says nothing about it carrying that many channels + * of it. maxChannels is a maximum across ALL codecs, so a receiver taking + * 8-channel TrueHD but only 6-channel E-AC-3 reports 8 — and would wave through + * an 8-channel E-AC-3 track its own entry excludes. + */ +class SinkPassthroughLayoutTest { + + private val receiver = AudioPassthroughCapabilities( + passthroughCodecs = listOf("truehd", "eac3"), + maxChannels = 8, + entries = listOf( + AudioPassthroughEntry("truehd", channelCounts = listOf(2, 6, 8)), + AudioPassthroughEntry("eac3", channelCounts = listOf(2, 6)), + ), + ) + + @Test + fun `a layout the codec entry excludes is refused even under the aggregate maximum`() { + assertFalse( + sinkCanPassthrough("eac3", 8, receiver), + "8 <= maxChannels of 8, but this receiver's E-AC-3 entry stops at 6", + ) + } + + @Test + fun `a layout the codec entry lists is accepted`() { + assertTrue(sinkCanPassthrough("eac3", 6, receiver)) + assertTrue(sinkCanPassthrough("truehd", 8, receiver)) + } + + @Test + fun `a codec the sink does not carry is refused`() { + assertFalse(sinkCanPassthrough("dts_hd", 6, receiver)) + } + + /** + * Pre-API-29 routes cannot be probed per format. Refusing everything there + * would be worse than the imprecision, so the aggregate still decides. + */ + @Test + fun `without entries the aggregate maximum decides`() { + val old = AudioPassthroughCapabilities( + passthroughCodecs = listOf("eac3"), + maxChannels = 6, + entries = emptyList(), + ) + assertTrue(sinkCanPassthrough("eac3", 6, old)) + assertFalse(sinkCanPassthrough("eac3", 8, old)) + } + + @Test + fun `an unknown channel count asks only whether the codec is carried`() { + assertTrue(sinkCanPassthrough("eac3", 0, receiver)) + assertFalse(sinkCanPassthrough("dts", 0, receiver)) + } +} + +/** + * Entries record what was PROBED, not everything the sink accepts — only + * 2/6/8 are ever tried. Treating that list as exhaustive turns a partial probe + * into a refusal of layouts nobody asked about. + */ +class SinkPassthroughPartialEntriesTest { + + private val receiver = AudioPassthroughCapabilities( + passthroughCodecs = listOf("eac3", "eac3_joc"), + maxChannels = 8, + entries = listOf(AudioPassthroughEntry("eac3", channelCounts = listOf(2, 6))), + ) + + @Test + fun `a probed layout the entry omits is refused`() { + assertFalse( + sinkCanPassthrough("eac3", 8, receiver), + "8 is probed, so its absence is a real no", + ) + } + + @Test + fun `an unprobed layout under this codecs own ceiling is allowed`() { + assertTrue( + sinkCanPassthrough("eac3", 5, receiver), + "5 channels is never probed and sits under the 6 this codec proved", + ) + } + + /** + * The aggregate maxChannels is 8 here, but that 8 belongs to another codec. + * This sink was ASKED for 8-channel E-AC-3 and said no, so its E-AC-3 + * ceiling is 6 — borrowing the aggregate would accept 7 on a path that + * cannot carry it, and broken audio costs more than a transcode. + */ + @Test + fun `an unprobed layout above this codecs own ceiling is refused`() { + assertFalse( + sinkCanPassthrough("eac3", 7, receiver), + "7 would only pass by borrowing another codec's limit", + ) + assertFalse(sinkCanPassthrough("eac3", 9, receiver)) + } + + /** + * A JOC stream whose layout only the plain E-AC-3 entry covers must still + * pass: Android permits JOC through an E-AC-3 path. + */ + @Test + fun `JOC falls back to the plain E-AC-3 entry for its layout`() { + val jocNarrow = AudioPassthroughCapabilities( + passthroughCodecs = listOf("eac3_joc", "eac3"), + maxChannels = 8, + entries = listOf( + AudioPassthroughEntry("eac3_joc", channelCounts = listOf(2)), + AudioPassthroughEntry("eac3", channelCounts = listOf(2, 6)), ), ) + assertFalse( + sinkCanPassthrough("eac3_joc", 6, jocNarrow), + "its own entry stops at stereo", + ) + assertTrue( + sinkCanPassthrough("eac3", 6, jocNarrow), + "but the E-AC-3 path carries it, which is the fallback checkPlayability uses", + ) + } + + /** + * Derived from the probe list, not restated, so this asserts the value + * rather than a duplicate declaration. If the probe gains a layout, the + * reader learns about it automatically and this test is what notices. + */ + @Test + fun `the probed set is exactly what the capability manager probes`() { + assertEquals(setOf(2, 6, 8), PROBED_PASSTHROUGH_CHANNEL_COUNTS) + assertEquals( + PASSTHROUGH_LAYOUT_PROBES.map { it.channelCount }.toSet(), + PROBED_PASSTHROUGH_CHANNEL_COUNTS, + ) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt index 27734485b..e22ae5e46 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt @@ -1,7 +1,10 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.HdrCapabilities +import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_DV81 +import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_HDR10 import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -50,4 +53,40 @@ class PlaybackCapabilityDetectorDolbyVisionTest { "P5 has no backward-compatible base layer; without a DV decoder the Media3 route cannot render it.", ) } + + @Test + fun hdr10OutputAndPackagedConverterDoNotAdvertiseAnUnvalidatedClientTransformation() { + val transformations = advertisedClientDolbyVisionTransformations( + hdrDetails = HdrCapabilities( + hdr10 = true, + dolbyVisionProfiles = listOf(8), + ), + nativeRpuConverterAvailable = true, + ) + + assertTrue( + transformations.isEmpty(), + "Runtime prerequisites cannot be promoted to validated v3 capability claims.", + ) + } + + @Test + fun clientTransformationsRequireExactFixtureValidationAndRuntimePrerequisites() { + val transformations = advertisedClientDolbyVisionTransformations( + hdrDetails = HdrCapabilities( + hdr10 = true, + dolbyVisionProfiles = listOf(8), + ), + nativeRpuConverterAvailable = true, + fixtureValidatedTransformations = setOf( + CLIENT_DV7_TO_DV81, + CLIENT_DV7_TO_HDR10, + ), + ) + + assertEquals( + listOf(CLIENT_DV7_TO_DV81, CLIENT_DV7_TO_HDR10), + transformations.map { it.name }, + ) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallbackTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallbackTest.kt index aa0ae01c4..a7df28135 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallbackTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackColorRangeFallbackTest.kt @@ -1,7 +1,6 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.PlaybackDelivery -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlaybackRouteFamily import org.prairieserver.prairie.model.playback.PlaybackSourceMetadata @@ -47,7 +46,6 @@ class PlaybackColorRangeFallbackTest { PlaybackExecutionPlan( planId = "plan", delivery = delivery, - engine = PlaybackEngineKind.MEDIA3_DIRECT, routeFamily = PlaybackRouteFamily.PLATFORM_NATIVE, source = PlaybackSourceMetadata(colorRange = colorRange), ) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPlanningSnapshotRegistryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPlanningSnapshotRegistryTest.kt new file mode 100644 index 000000000..fc63bffb8 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPlanningSnapshotRegistryTest.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.playback.AudioPassthroughCapabilities +import org.prairieserver.prairie.model.playback.ClientCodecCapabilities +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlaybackPlanningSnapshotRegistryTest { + @Test + fun contextUsesTheRouteCapturedWithItsCapabilities() { + val registry = PlaybackPlanningSnapshotRegistry(maxSize = 2) + val plannedCapabilities = ClientCodecCapabilities( + audioPassthrough = AudioPassthroughCapabilities( + passthroughCodecs = listOf("truehd"), + maxChannels = 8, + ), + ) + val plannedRoute = AudioPlaybackRouteSnapshot( + sinkType = "hdmi", + routeGeneration = 7, + capabilities = requireNotNull(plannedCapabilities.audioPassthrough), + ) + registry.remember(plannedCapabilities, plannedRoute) + + val routeAfterInterleavedUpdate = AudioPlaybackRouteSnapshot( + sinkType = "bluetooth", + routeGeneration = 8, + capabilities = AudioPassthroughCapabilities(), + ) + + assertEquals( + plannedRoute, + registry.resolve(plannedCapabilities, routeAfterInterleavedUpdate), + ) + } + + @Test + fun anUnregisteredCapabilitySnapshotUsesTheCurrentRoute() { + val registry = PlaybackPlanningSnapshotRegistry(maxSize = 1) + val currentRoute = AudioPlaybackRouteSnapshot( + sinkType = "speaker", + routeGeneration = 3, + capabilities = AudioPassthroughCapabilities(), + ) + + assertEquals( + currentRoute, + registry.resolve(ClientCodecCapabilities(), currentRoute), + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt index 5f19a6059..6b17a1915 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackPublicationSettlementIntegrationTest.kt @@ -24,12 +24,12 @@ import org.prairieserver.prairie.model.personal.SyncProgressItem import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE +import org.prairieserver.prairie.model.playback.NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_FEATURE import org.prairieserver.prairie.model.playback.PlaybackDecisionOutcome import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol @@ -46,10 +46,8 @@ import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.network.api.HealthStatus import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.api.PlaybackApi -import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.PlaybackRepository -import org.prairieserver.prairie.repository.ProfileRepository import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -192,6 +190,7 @@ class PlaybackPublicationSettlementIntegrationTest { planA, features = listOf( PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, SEEK_REANCHOR_V3_FEATURE, ), ), @@ -210,6 +209,7 @@ class PlaybackPublicationSettlementIntegrationTest { ), features = listOf( PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, SEEK_REANCHOR_V3_FEATURE, ), ), @@ -500,7 +500,6 @@ class PlaybackPublicationSettlementIntegrationTest { ) val lifecycle = PlaybackSessionLifecycle( sessionManager = manager, - profileRepository = SettlementProfileRepository(), healthApi = SettlementHealthApi(), personalDataRepository = SettlementPersonalDataRepository(), scope = scope, @@ -552,7 +551,6 @@ class PlaybackPublicationSettlementIntegrationTest { ), session = ready.session, manageProgress = false, - renewMissingSessionWithLegacyStart = false, deferPublication = deferPublication, isCurrent = { true }, ) @@ -579,7 +577,7 @@ class PlaybackPublicationSettlementIntegrationTest { val playbackContext = ClientPlaybackContext( formFactor = "tv", appVersion = "test", - output = PlaybackOutputContext(outputRouteGeneration = 7), + output = PlaybackOutputContext(outputContextId = "7"), ) } } @@ -587,7 +585,10 @@ class PlaybackPublicationSettlementIntegrationTest { private companion object { fun response( plan: PlaybackPlanV3, - features: List = listOf(PLAYBACK_PLAN_V3_FEATURE), + features: List = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ), ): PlaybackDecisionResponseV3 = PlaybackDecisionResponseV3( protocolVersion = 3, @@ -599,9 +600,9 @@ class PlaybackPublicationSettlementIntegrationTest { fun plan(sessionId: String, fileId: Int): PlaybackPlanV3 = PlaybackPlanV3( planId = "plan-$sessionId", + planAttemptKey = "v3:test:$sessionId", sessionId = sessionId, delivery = PlaybackDelivery.SERVER_REMUX_HLS, - engine = PlaybackEngineKind.MEDIA3_HLS, stream = PlaybackStreamV3( url = "/stream/$sessionId/master.m3u8", protocol = PlaybackStreamProtocol.HLS, @@ -625,13 +626,6 @@ class PlaybackPublicationSettlementIntegrationTest { private fun PlaybackSessionLifecycle.activeSessionId(): String? = (state.value as? SessionState.Active)?.session?.sessionId -private class SettlementProfileRepository : ProfileRepository( - profileApi = ProfileApi(HttpClient()), - tokenManager = SettlementTokenManager, -) { - override suspend fun getActiveProfileId(): String = "profile-1" -} - private class SettlementHealthApi : HealthApi(HttpClient()) { override suspend fun checkHealth(): ApiResult = ApiResult.Success(HealthStatus(status = "ok")) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleLoggingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleLoggingTest.kt index 6c94cd62a..6027ce7a8 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleLoggingTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleLoggingTest.kt @@ -20,19 +20,23 @@ class PlaybackSessionLifecycleLoggingTest { } @Test - fun aNewStartWaitsForAnAsynchronousStopToFinish() { + fun takingOwnershipWaitsForAnAsynchronousStopToFinish() { + // The lifecycle no longer starts sessions — under protocol v3 planning + // belongs to the owner — so the two doors into ownership are direct + // adoption and epoch acquisition. Both must drain a queued teardown + // first, or an older screen's stop lands on the new session. val text = source.joinToString("\n") assertTrue(text.contains("private var pendingStopJob: Job?")) assertTrue(text.contains("private suspend fun awaitPendingStop()")) assertTrue( - text.substringAfter("suspend fun start(params: StartParams)") - .substringBefore("suspend fun adoptActiveSession(") + text.substringAfter("suspend fun adoptActiveSession(") + .substringBefore("suspend fun acquireOwnershipEpoch()") .contains("awaitPendingStop()"), ) assertTrue( - text.substringAfter("suspend fun adoptActiveSession(") - .substringBefore("private suspend fun startInternal(") + text.substringAfter("suspend fun acquireOwnershipEpoch()") + .substringBefore("suspend fun adoptActiveSessionIfCurrent(") .contains("awaitPendingStop()"), ) } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt index 6553438aa..7562c0bb4 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.common.diagnostics.DiagnosticsPlaybackSessionRecorder import org.prairieserver.prairie.model.personal.SyncProgressItem import org.prairieserver.prairie.model.playback.ClientCodecCapabilities +import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.network.ApiResult @@ -11,10 +12,8 @@ import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.network.api.HealthStatus import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.api.PlaybackApi -import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.PlaybackRepository -import org.prairieserver.prairie.repository.ProfileRepository import io.ktor.client.HttpClient import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -39,12 +38,16 @@ import kotlin.test.fail /** * Integration-flavor tests for [PlaybackSessionLifecycle]. Exercises the - * three transition paths the wrapper introduces: + * transition paths the wrapper owns: * - * - happy-path start -> Active and clean stop - * - 404 session_not_found mid-progress -> snapshot + re-start + * - adoption of an already-planned session -> Active and clean stop + * - 404 session_not_found mid-progress -> snapshot + a renewal handed to the owner * - NetworkError mid-progress -> Reconnecting + health-probe loop * + * The lifecycle does not start sessions: under protocol v3 planning belongs to + * `PlaybackSessionManager.startVideoSessionV3`, so every test here begins from + * [PlaybackSessionLifecycle.adoptActiveSession]. + * * Time is fully virtual via `runTest` + `advanceTimeBy` so we can verify the * 1s -> 2s -> 4s -> 8s -> 8s exponential backoff and the 90s outage timeout * without sleeping. @@ -53,26 +56,8 @@ import kotlin.test.fail class PlaybackSessionLifecycleTest { @Test - fun `start emits Loading then Active on success`() = runTest { - // We can't rely on StateFlow.collect to capture every intermediate - // value — StateFlow conflates writes that happen before a - // collector is ready to consume. Instead, hold sessionManager.startSession - // suspended at a gate and inspect state.value at each known boundary. - val gate = kotlinx.coroutines.CompletableDeferred>() - val sessionMgr = object : FakeSessionManager() { - override suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - disableProgressPersistence: Boolean, - ): ApiResult { - startCallCount++ - return gate.await() - } - } + fun `adoptActiveSession reports progress without starting duplicate session`() = runTest { + val sessionMgr = FakeSessionManager() val recordedSessions = mutableListOf() val lifecycle = newLifecycle( sessionMgr, @@ -80,171 +65,75 @@ class PlaybackSessionLifecycleTest { playbackSessions = DiagnosticsPlaybackSessionRecorder { recordedSessions += it }, ) - assertEquals(SessionState.Idle, lifecycle.state.value) - - // Launch start() onto the test scheduler. Its first real suspension - // is sessionManager.startSession, which awaits the gate. After - // advanceUntilIdle, state must be Loading. - val startJob = launch { lifecycle.start(defaultStartParams()) } - advanceUntilIdle() - assertEquals(SessionState.Loading, lifecycle.state.value) + lifecycle.adoptActiveSession( + params = defaultStartParams(startPosition = 12.0), + session = makeSession("sess-adopted"), + ) - // Resume — start() finishes with Active. - gate.complete(ApiResult.Success(makeSession("sess-1"))) - advanceUntilIdle() - startJob.join() + val active = lifecycle.state.value + assertTrue(active is SessionState.Active) + assertEquals("sess-adopted", (active as SessionState.Active).session.sessionId) + assertEquals(listOf("sess-adopted"), recordedSessions) - val terminal = lifecycle.state.value - assertTrue(terminal is SessionState.Active, "expected Active, got $terminal") - assertEquals("sess-1", (terminal as SessionState.Active).session.sessionId) - assertEquals(listOf("sess-1"), recordedSessions) + lifecycle.reportOwnedPosition(positionSec = 33.0, durationSec = 100.0, isPaused = false) + advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) - lifecycle.stop() + assertEquals(1, sessionMgr.progressCallCount) + assertEquals("sess-adopted", sessionMgr.lastProgressSessionId) + assertEquals(33.0, sessionMgr.lastProgressPosition) } @Test - fun `a stop during start does not strand the new session`() = runTest { - // start() runs its API call outside the lifecycle mutex, and for that - // whole window state is Loading with no adopted id — so stop()'s - // ownership guard has nothing to compare and tears down anyway. The - // start then published Active over a screen the user had dismissed, and - // because that stop never saw this session id, the session stayed alive - // on the server eating a concurrent-stream slot until it timed out. - val gate = kotlinx.coroutines.CompletableDeferred>() + fun `same-session replan preserves an in-flight progress report`() = runTest { + val reportEntered = CompletableDeferred() + val releaseReport = CompletableDeferred() + var reportWasCancelled = false val sessionMgr = object : FakeSessionManager() { - override suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - disableProgressPersistence: Boolean, - ): ApiResult { - startCallCount++ - return gate.await() + override suspend fun reportProgress( + sessionId: String, + position: Double, + isPaused: Boolean, + ): ApiResult { + progressCallCount++ + reportEntered.complete(Unit) + return try { + releaseReport.await() + ApiResult.Success(Unit) + } catch (cancellation: kotlinx.coroutines.CancellationException) { + reportWasCancelled = true + // Matches safeApiCall, which currently wraps cancellation + // as a network result instead of rethrowing it. + ApiResult.NetworkError(cancellation) + } } } - val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - - val startJob = launch { lifecycle.start(defaultStartParams()) } - advanceUntilIdle() - assertEquals(SessionState.Loading, lifecycle.state.value) - - // The user leaves. Nothing is Active yet, so the caller has no id to pass. - lifecycle.stop() - advanceUntilIdle() - - gate.complete(ApiResult.Success(makeSession("sess-late"))) - advanceUntilIdle() - startJob.join() - - assertEquals( - SessionState.Idle, - lifecycle.state.value, - "a dismissed screen must not be resurrected by its own in-flight start", - ) - assertEquals( - "sess-late", - sessionMgr.lastStoppedSessionId, - "the late session must be stopped, not left running on the server", - ) - } - - @Test - fun `a failed start finishing after stop does not publish stale failure`() = runTest { - val gate = kotlinx.coroutines.CompletableDeferred>() - val sessionMgr = object : FakeSessionManager() { - override suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - disableProgressPersistence: Boolean, - ): ApiResult = gate.await() - } - val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - - val startJob = launch { lifecycle.start(defaultStartParams()) } - advanceUntilIdle() - assertEquals(SessionState.Loading, lifecycle.state.value) - - lifecycle.stop() - gate.complete( - ApiResult.Error( - code = 500, - error = "start_failed", - message = "Start failed", - ), - ) - advanceUntilIdle() - startJob.join() - - assertEquals( - SessionState.Idle, - lifecycle.state.value, - "a completed teardown must remain terminal for its in-flight start", - ) - } - - @Test - fun `start emits Failed when profile id is null`() = runTest { + val healthApi = FakeHealthApi() val lifecycle = newLifecycle( - sessionMgr = FakeSessionManager(), - profileRepo = FakeProfileRepository(activeProfileId = null), + sessionMgr = sessionMgr, + healthApi = healthApi, scope = backgroundScope, ) - val terminal = lifecycle.start(defaultStartParams()) - advanceUntilIdle() - - assertTrue(terminal is SessionState.Failed) - assertTrue((terminal as SessionState.Failed).message.contains("profile", ignoreCase = true)) - } - - @Test - fun `start emits Failed on session API NetworkError`() = runTest { - val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.NetworkError(RuntimeException("boom")) - } - val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - - val terminal = lifecycle.start(defaultStartParams()) - advanceUntilIdle() - - assertTrue(terminal is SessionState.Failed) - } - - @Test - fun `adoptActiveSession reports progress without starting duplicate session`() = runTest { - val sessionMgr = FakeSessionManager() - val recordedSessions = mutableListOf() - val lifecycle = newLifecycle( - sessionMgr, - scope = backgroundScope, - playbackSessions = DiagnosticsPlaybackSessionRecorder { recordedSessions += it }, - ) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-replan")) + lifecycle.reportOwnedPosition(42.0, 100.0, isPaused = false) + advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) + reportEntered.await() lifecycle.adoptActiveSession( - params = defaultStartParams(startPosition = 12.0), - session = makeSession("sess-adopted"), + params = defaultStartParams(startPosition = 42.0).copy(subtitleTrackIndex = 7), + session = makeSession("sess-replan"), + deferPublication = true, ) + yield() - assertEquals(0, sessionMgr.startCallCount) - val active = lifecycle.state.value - assertTrue(active is SessionState.Active) - assertEquals("sess-adopted", (active as SessionState.Active).session.sessionId) - assertEquals(listOf("sess-adopted"), recordedSessions) - - lifecycle.reportPosition(positionSec = 33.0, durationSec = 100.0, isPaused = false) - advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) + assertFalse(reportWasCancelled) + assertTrue(lifecycle.state.value is SessionState.Active) + assertEquals(0, healthApi.callCount) - assertEquals(0, sessionMgr.startCallCount) - assertEquals(1, sessionMgr.progressCallCount) - assertEquals("sess-adopted", sessionMgr.lastProgressSessionId) - assertEquals(33.0, sessionMgr.lastProgressPosition) + releaseReport.complete(Unit) + advanceUntilIdle() + assertFalse(reportWasCancelled) + assertEquals(0, healthApi.callCount) } @Test @@ -264,12 +153,11 @@ class PlaybackSessionLifecycleTest { stopSessionOnStop = false, ) - lifecycle.reportPosition(positionSec = 33.0, durationSec = 100.0, isPaused = false) + lifecycle.reportOwnedPosition(positionSec = 33.0, durationSec = 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) lifecycle.stop() advanceUntilIdle() - assertEquals(0, sessionMgr.startCallCount) assertEquals(0, sessionMgr.progressCallCount) assertEquals(0, sessionMgr.stopCallCount) assertTrue(personalRepo.syncCalls.isEmpty()) @@ -352,6 +240,100 @@ class PlaybackSessionLifecycleTest { assertEquals(listOf("sess-old"), stopped) } + @Test + fun `external session finalization returns before reporting finishes and stops afterward`() = runTest { + val reportEntered = CompletableDeferred() + val releaseReport = CompletableDeferred() + val stopCompleted = CompletableDeferred() + val calls = mutableListOf() + val sessionMgr = object : FakeSessionManager() { + override suspend fun reportProgress( + sessionId: String, + position: Double, + isPaused: Boolean, + ): ApiResult { + calls += "report:$sessionId:$position:$isPaused" + reportEntered.complete(Unit) + releaseReport.await() + return ApiResult.Success(Unit) + } + + override suspend fun stopSession(sessionId: String): ApiResult { + calls += "stop:$sessionId" + stopCompleted.complete(Unit) + return ApiResult.Success(Unit) + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + lifecycle.reportAndStopExternalSessionAsync( + sessionId = "audiobook-session", + positionSeconds = 42.25, + isPaused = true, + ) + + reportEntered.await() + assertEquals( + listOf("report:audiobook-session:42.25:true"), + calls, + "the non-blocking caller must return while progress reporting is suspended", + ) + + releaseReport.complete(Unit) + stopCompleted.await() + + assertEquals( + listOf( + "report:audiobook-session:42.25:true", + "stop:audiobook-session", + ), + calls, + ) + } + + @Test + fun `duplicate external session finalization is coalesced`() = runTest { + val reportEntered = CompletableDeferred() + val releaseReport = CompletableDeferred() + val stopCompleted = CompletableDeferred() + val sessionMgr = object : FakeSessionManager() { + override suspend fun reportProgress( + sessionId: String, + position: Double, + isPaused: Boolean, + ): ApiResult { + progressCallCount++ + reportEntered.complete(Unit) + releaseReport.await() + return ApiResult.Success(Unit) + } + + override suspend fun stopSession(sessionId: String): ApiResult { + val result = super.stopSession(sessionId) + stopCompleted.complete(Unit) + return result + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + repeat(2) { + lifecycle.reportAndStopExternalSessionAsync( + sessionId = "audiobook-session", + positionSeconds = 42.25, + isPaused = true, + ) + } + + reportEntered.await() + assertEquals(1, sessionMgr.progressCallCount) + + releaseReport.complete(Unit) + stopCompleted.await() + + assertEquals(1, sessionMgr.progressCallCount) + assertEquals(1, sessionMgr.stopCallCount) + } + @Test fun `cancelled adoption closes the allocated server session`() = runTest { val oldStopEntered = CompletableDeferred() @@ -389,13 +371,8 @@ class PlaybackSessionLifecycleTest { } @Test - fun `reportPosition with 404 triggers session-missing recovery and re-starts`() = runTest { + fun `reportPosition with 404 snapshots progress and hands renewal to the owner`() = runTest { val sessionMgr = FakeSessionManager().apply { - // Two distinct sessions back-to-back: original then renewed. - startResults = ArrayDeque(listOf( - ApiResult.Success(makeSession("sess-original")), - ApiResult.Success(makeSession("sess-renewed")), - )) // First reportProgress returns 404 to trigger recovery. progressResults = ArrayDeque(listOf( ApiResult.Error(404, "playback_session_not_found", "Playback session not found"), @@ -407,19 +384,29 @@ class PlaybackSessionLifecycleTest { personalRepo = personalRepo, scope = backgroundScope, ) + val renewals = mutableListOf() + backgroundScope.launch { lifecycle.missingSessionEvents.collect { renewals += it } } + advanceUntilIdle() - val first = lifecycle.start(defaultStartParams(startPosition = 0.0)) - assertTrue(first is SessionState.Active) - assertEquals("sess-original", (first as SessionState.Active).session.sessionId) + val startParams = defaultStartParams(startPosition = 0.0).copy( + audioTrackIndex = 2, + subtitleTrackIndex = 8, + qualityPreference = "original", + ) + lifecycle.adoptActiveSession( + params = startParams, + session = makeSession("sess-original"), + ) // Simulate the player advancing. - lifecycle.reportPosition(positionSec = 42.5, durationSec = 100.0, isPaused = false) + lifecycle.reportOwnedPosition(positionSec = 42.5, durationSec = 100.0, isPaused = false) - // Trigger the 10s reporter; first call returns 404 -> recovery -> re-start. + // Trigger the 10s reporter; the first call returns 404 -> recovery. advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() - // Snapshot was synced with forceOverwrite at position 42.5. + // Snapshot was synced with forceOverwrite at position 42.5. This write + // is what protects the resume point if the owner's replan then fails. val snapshot = personalRepo.syncCalls.firstOrNull() ?: fail("expected syncProgress to be called during recovery") assertEquals(1, snapshot.size) @@ -427,14 +414,12 @@ class PlaybackSessionLifecycleTest { assertEquals(42.5, snapshot.first().position) assertTrue(snapshot.first().forceOverwrite) - // New session is now active. - val state = lifecycle.state.value - assertTrue(state is SessionState.Active) - assertEquals("sess-renewed", (state as SessionState.Active).session.sessionId) - assertEquals(2, sessionMgr.startCallCount) - - // Last start call resumed at 42.5. - assertEquals(42.5, sessionMgr.lastStartPosition) + // The lifecycle hands the resume position to whoever owns planning; + // it does not start a replacement session itself. + assertEquals(1, renewals.size) + assertEquals("sess-original", renewals.single().staleSessionId) + assertEquals(42.5, renewals.single().positionSeconds, 0.0) + assertEquals(startParams, renewals.single().startParams) lifecycle.stop() } @@ -442,7 +427,6 @@ class PlaybackSessionLifecycleTest { @Test fun `reportPosition with NetworkError transitions to Reconnecting and probes health`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-1")) progressResults = ArrayDeque(listOf( ApiResult.NetworkError(RuntimeException("offline")), )) @@ -456,9 +440,9 @@ class PlaybackSessionLifecycleTest { } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - val active = lifecycle.start(defaultStartParams()) - assertTrue(active is SessionState.Active) - lifecycle.reportPosition(10.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-1")) + assertTrue(lifecycle.state.value is SessionState.Active) + lifecycle.reportOwnedPosition(10.0, 100.0, isPaused = false) // Trigger the 10s reporter -> NetworkError -> beginOutageRecovery. advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) @@ -491,7 +475,6 @@ class PlaybackSessionLifecycleTest { @Test fun `health probe Success transitions back to Active and clears notice`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-keepalive")) progressResults = ArrayDeque(listOf( ApiResult.NetworkError(RuntimeException("offline")), )) @@ -500,9 +483,9 @@ class PlaybackSessionLifecycleTest { results = ArrayDeque(listOf(ApiResult.Success(healthOk()))) } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - val active = lifecycle.start(defaultStartParams()) - assertTrue(active is SessionState.Active) - lifecycle.reportPosition(5.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-keepalive")) + assertTrue(lifecycle.state.value is SessionState.Active) + lifecycle.reportOwnedPosition(5.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() @@ -522,7 +505,6 @@ class PlaybackSessionLifecycleTest { @Test fun `health probe NetworkError repeats with exponential backoff up to 8s cap`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-1")) progressResults = ArrayDeque(listOf( ApiResult.NetworkError(RuntimeException("offline")), )) @@ -539,8 +521,8 @@ class PlaybackSessionLifecycleTest { )) } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(0.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-1")) + lifecycle.reportOwnedPosition(0.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() assertTrue(lifecycle.state.value is SessionState.Reconnecting) @@ -577,7 +559,6 @@ class PlaybackSessionLifecycleTest { @Test fun `outage recovery times out at 90s and transitions to Failed`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-1")) progressResults = ArrayDeque(listOf( ApiResult.NetworkError(RuntimeException("offline")), )) @@ -587,8 +568,8 @@ class PlaybackSessionLifecycleTest { alwaysReturn = ApiResult.NetworkError(RuntimeException("down")) } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(0.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-1")) + lifecycle.reportOwnedPosition(0.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() @@ -609,7 +590,6 @@ class PlaybackSessionLifecycleTest { @Test fun `health gateway error does not mark outage recovery reachable`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-proxy-down")) progressResults = ArrayDeque( listOf(ApiResult.NetworkError(RuntimeException("offline"))), ) @@ -623,8 +603,8 @@ class PlaybackSessionLifecycleTest { ) } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(0.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-proxy-down")) + lifecycle.reportOwnedPosition(0.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() @@ -649,7 +629,6 @@ class PlaybackSessionLifecycleTest { @Test fun `gateway progress error starts outage recovery`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-progress-503")) progressResults = ArrayDeque( listOf(ApiResult.Error(503, "unavailable", "origin down")), ) @@ -658,8 +637,8 @@ class PlaybackSessionLifecycleTest { results = ArrayDeque(listOf(ApiResult.Success(healthOk()))) } val lifecycle = newLifecycle(sessionMgr, healthApi = healthApi, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(12.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-progress-503")) + lifecycle.reportOwnedPosition(12.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() @@ -680,12 +659,11 @@ class PlaybackSessionLifecycleTest { @Test fun `stop clears state to Idle and cancels all jobs`() = runTest { val sessionMgr = FakeSessionManager().apply { - startResult = ApiResult.Success(makeSession("sess-1")) progressDefault = ApiResult.Success(Unit) } val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(15.0, 100.0, isPaused = false) + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-1")) + lifecycle.reportOwnedPosition(15.0, 100.0, isPaused = false) advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() @@ -706,87 +684,226 @@ class PlaybackSessionLifecycleTest { @Test fun `repeated 404s during recovery do not fire multiple renewals`() = runTest { - // To exercise the debounce we MUST keep the original session active - // while several 404s arrive for it. We do that by holding the - // renewal `startSession` call suspended on a gate — every reporter - // tick runs against `sess-original` and returns 404. With the - // debounce honored, only one recovery (one renewal start) fires. - val renewalGate = kotlinx.coroutines.CompletableDeferred>() - val sessionMgr = object : FakeSessionManager() { - override suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - disableProgressPersistence: Boolean, - ): ApiResult { - startCallCount++ - lastStartPosition = startPosition - return when (startCallCount) { - 1 -> ApiResult.Success(makeSession("sess-original")) - 2 -> renewalGate.await() // hold the renewal so reporter keeps polling sess-original - else -> ApiResult.Success(makeSession("sess-${startCallCount}")) - } - } - }.apply { + // The owner's replan is not instantaneous, so the adopted session stays + // Active while several more 404s arrive for it. Each reporter tick runs + // against `sess-original` and gets a 404; with the debounce honored the + // owner is told exactly once. + val sessionMgr = FakeSessionManager().apply { // Five 404s on tap — well more than reporter ticks we'll fire. progressResults = ArrayDeque(List(5) { ApiResult.Error(404, "playback_session_not_found", "Playback session not found") }) } val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) - lifecycle.start(defaultStartParams()) - lifecycle.reportPosition(7.0, 100.0, isPaused = false) + val renewals = mutableListOf() + backgroundScope.launch { lifecycle.missingSessionEvents.collect { renewals += it } } + advanceUntilIdle() + + lifecycle.adoptActiveSession(defaultStartParams(), makeSession("sess-original")) + lifecycle.reportOwnedPosition(7.0, 100.0, isPaused = false) - // Four reporter ticks — every tick reads state.value's session, which - // is still sess-original because the renewal start() is gated. Each - // tick returns 404 for sess-original; the debounce should keep us - // from launching multiple renewal coroutines. + // Four reporter ticks, each returning 404 for the still-adopted + // sess-original. The debounce should collapse them to one renewal. repeat(4) { advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100) advanceUntilIdle() } - // Exactly two start calls: original + one renewal — *not* one per 404. assertEquals( - 2, - sessionMgr.startCallCount, + listOf(7.0), + renewals.map(MissingSessionRenewal::positionSeconds), "expected exactly one renewal regardless of how many 404s arrived", ) - // Let the renewal finish so the test ends cleanly. - renewalGate.complete(ApiResult.Success(makeSession("sess-renewed"))) - advanceUntilIdle() - lifecycle.stop() } + // ------------------------------------------------------------------------ + // Exactly-once teardown (auto-advance) + // ------------------------------------------------------------------------ + + /** + * The auto-advance regression: the outgoing screen's deferred onCleared stop + * used to land after the incoming episode had captured its ownership epoch, + * bumping stopEpoch and getting the incoming adoption rejected. On device + * that surfaced as "Playback start was superseded." on every episode change. + */ + @Test + fun `gated duplicate teardown does not supersede the next item`() = runTest { + val sessionMgr = FakeSessionManager() + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + val gate = PlaybackTeardownGate(lifecycle) + + val epochA = lifecycle.acquireOwnershipEpoch() + assertTrue( + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-a"), + expectedOwnershipEpoch = epochA, + ), + ) + // Ordered pre-navigation stop of the outgoing episode. + gate.stopOrdered(expectedSessionId = "sess-a") + + // The incoming episode captures its epoch, and only THEN does the old + // screen's deferred onCleared fallback fire. + val epochB = lifecycle.acquireOwnershipEpoch() + gate.stopDetached(expectedSessionId = "sess-a") + + val adopted = lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-b"), + expectedOwnershipEpoch = epochB, + ) + + assertTrue(adopted, "next episode must adopt despite the late duplicate teardown") + assertEquals("sess-b", (lifecycle.state.value as SessionState.Active).session.sessionId) + assertEquals(1, sessionMgr.stopCallCount, "outgoing session stopped exactly once") + } + + /** Control: without the gate the same interleaving really does break. */ + @Test + fun `ungated duplicate teardown supersedes the next item`() = runTest { + val sessionMgr = FakeSessionManager() + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + val epochA = lifecycle.acquireOwnershipEpoch() + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-a"), + expectedOwnershipEpoch = epochA, + ) + lifecycle.stop(expectedSessionId = "sess-a") + + val epochB = lifecycle.acquireOwnershipEpoch() + lifecycle.stop(expectedSessionId = "sess-a") + + assertFalse( + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-b"), + expectedOwnershipEpoch = epochB, + ), + "this is the bug the gate exists to prevent", + ) + } + + /** + * The claim must never be consumed without leaving an owner. Here the + * fallback lands while the ordered stop is still in flight — so it correctly + * skips — and the ordered stop then fails. Teardown has to survive that. + */ + @Test + fun `fallback during a suspended ordered stop cannot abandon teardown`() = runTest { + val firstStopReached = CompletableDeferred() + val releaseFirstStop = CompletableDeferred() + val sessionMgr = object : FakeSessionManager() { + var attempts = 0 + override suspend fun stopSession(sessionId: String): ApiResult { + attempts++ + if (attempts == 1) { + firstStopReached.complete(Unit) + releaseFirstStop.await() + throw IllegalStateException("stop failed") + } + return super.stopSession(sessionId) + } + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + val gate = PlaybackTeardownGate(lifecycle) + + val epochA = lifecycle.acquireOwnershipEpoch() + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-a"), + expectedOwnershipEpoch = epochA, + ) + + // runCatching so the failure stays inside this coroutine. + val ordered = backgroundScope.async { + runCatching { gate.stopOrdered(expectedSessionId = "sess-a") } + } + firstStopReached.await() + + // The dying screen's onCleared fires mid-flight and finds it claimed. + gate.stopDetached(expectedSessionId = "sess-a") + + releaseFirstStop.complete(Unit) + assertTrue(ordered.await().isFailure, "the ordered stop really did fail") + // The handoff job runs on Dispatchers.IO, so the test scheduler cannot + // see it. Join it through the same API a real next start uses. + lifecycle.acquireOwnershipEpoch() + assertEquals(2, sessionMgr.attempts, "the tracked job must retry the abandoned teardown") + assertEquals(SessionState.Idle, lifecycle.state.value) + } + + /** + * The detached routes only schedule the stop, so nothing is positioned to + * catch it. The lifecycle scope has a SupervisorJob but no + * CoroutineExceptionHandler, so an escaping throw would be an uncaught + * coroutine exception — process death rather than a lingering session. + */ + @Test + fun `a failing async stop does not escape as an uncaught exception`() = runTest { + val sessionMgr = object : FakeSessionManager() { + override suspend fun stopSession(sessionId: String): ApiResult = + throw IllegalStateException("stop failed") + } + val lifecycle = newLifecycle(sessionMgr, scope = backgroundScope) + + val epoch = lifecycle.acquireOwnershipEpoch() + lifecycle.adoptActiveSessionIfCurrent( + params = defaultStartParams(), + session = makeSession("sess-a"), + expectedOwnershipEpoch = epoch, + ) + + lifecycle.stopAsync(expectedSessionId = "sess-a") + // Joins the tracked job. If the failure escaped, runTest reports it. + lifecycle.acquireOwnershipEpoch() + } + // ------------------------------------------------------------------------ // Test infrastructure // ------------------------------------------------------------------------ private fun TestScope.newLifecycle( sessionMgr: FakeSessionManager, - profileRepo: ProfileRepository = FakeProfileRepository(activeProfileId = "p1"), healthApi: FakeHealthApi = FakeHealthApi(), personalRepo: PersonalDataRepository = RecordingPersonalDataRepository(), scope: CoroutineScope = this.backgroundScope, playbackSessions: DiagnosticsPlaybackSessionRecorder = DiagnosticsPlaybackSessionRecorder.None, ): PlaybackSessionLifecycle = PlaybackSessionLifecycle( sessionManager = sessionMgr, - profileRepository = profileRepo, healthApi = healthApi, personalDataRepository = personalRepo, scope = scope, playbackSessions = playbackSessions, ) + /** + * Reports a sample as the session the lifecycle currently owns. + * + * reportPosition requires the caller to name its session — null is "I own + * none", not "skip the check" — so these tests have to say which session + * they are reporting for, exactly as the players do. + */ + private fun PlaybackSessionLifecycle.reportOwnedPosition( + positionSec: Double, + durationSec: Double, + isPaused: Boolean, + ) = reportPosition( + positionSec = positionSec, + durationSec = durationSec, + isPaused = isPaused, + expectedSessionId = (state.value as? SessionState.Active)?.session?.sessionId, + ) + private fun defaultStartParams(startPosition: Double? = null) = StartParams( contentId = "content-1", fileId = 42, capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), audioTrackIndex = null, qualityPreference = null, startPosition = startPosition, @@ -813,40 +930,17 @@ private open class FakeSessionManager : PlaybackSessionManager( tokenManager = NoOpTokenManager, ) { - /** If `startResults` is non-empty it takes priority; otherwise `startResult`. */ - var startResult: ApiResult = ApiResult.Error(500, "x", "x") - var startResults: ArrayDeque>? = null - var progressDefault: ApiResult = ApiResult.Success(Unit) var progressResults: ArrayDeque>? = null var stopResult: ApiResult = ApiResult.Success(Unit) - var startCallCount = 0 var progressCallCount = 0 var stopCallCount = 0 var lastStoppedSessionId: String? = null - var lastStartPosition: Double? = null var lastProgressSessionId: String? = null var lastProgressPosition: Double? = null - var lastDisableProgressPersistence: Boolean? = null - - override suspend fun startSession( - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - audioTrackIndex: Int?, - qualityPreference: String?, - startPosition: Double?, - disableProgressPersistence: Boolean, - ): ApiResult { - startCallCount++ - lastStartPosition = startPosition - lastDisableProgressPersistence = disableProgressPersistence - return startResults?.takeIf { it.isNotEmpty() }?.removeFirst() ?: startResult - } - override suspend fun reportProgress( sessionId: String, position: Double, @@ -880,15 +974,6 @@ private class FakeHealthApi : HealthApi(client = HttpClient()) { private fun healthOk(): HealthStatus = HealthStatus(status = "ok") -private class FakeProfileRepository( - private val activeProfileId: String?, -) : ProfileRepository( - profileApi = NoOpProfileApi, - tokenManager = NoOpTokenManager, -) { - override suspend fun getActiveProfileId(): String? = activeProfileId -} - private class RecordingPersonalDataRepository : PersonalDataRepository( personalDataApi = NoOpPersonalDataApi, ) { @@ -905,7 +990,6 @@ private class RecordingPersonalDataRepository : PersonalDataRepository( private val NoOpHttpClient: HttpClient = HttpClient() private val NoOpPlaybackApi: PlaybackApi = PlaybackApi(NoOpHttpClient) -private val NoOpProfileApi: ProfileApi = ProfileApi(NoOpHttpClient) private val NoOpPersonalDataApi: PersonalDataApi = PersonalDataApi(NoOpHttpClient) private val NoOpTokenManager: TokenManager = object : TokenManager { diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt index 125bf7778..ac7c2bc1e 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt @@ -27,18 +27,22 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext +import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_ORIGINAL_HTTP +import org.prairieserver.prairie.model.playback.DeliveryCapability +import org.prairieserver.prairie.model.playback.DeliverySubtitleCapabilities import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE +import org.prairieserver.prairie.model.playback.NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE import org.prairieserver.prairie.model.playback.PlaybackDecisionOutcome import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol import org.prairieserver.prairie.model.playback.PlaybackStreamV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleArtifactV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 @@ -56,13 +60,56 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue class PlaybackSessionManagerSeekReanchorTest { + @Test + fun startRequestCarriesSubtitleSupportOnlyInTheNeutralDeliveryContext() = runTest { + val capable = Harness(startResponse = response(plan())) { _, _ -> error("unused") } + capable.manager.startVideoSessionV3( + fileId = 42, + profileId = "profile-1", + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + deliveries = mapOf( + DELIVERY_CLASS_ORIGINAL_HTTP to DeliveryCapability( + enabled = true, + supportedOnDevice = true, + subtitles = DeliverySubtitleCapabilities(sidecarText = true), + ), + ), + ), + audioTrackIndex = null, + subtitleTrackIndex = null, + qualityPreference = "original", + startPosition = 0.0, + ) + val capableBody = capable.startBodies.single() + assertFalse( + "external_text_sidecar_set_v1" in + capableBody["client_features"]!!.jsonArray.map { it.jsonPrimitive.content }, + ) + assertFalse("features" in capableBody["client_playback_context"]!!.jsonObject) + assertTrue( + capableBody["client_playback_context"]!!.jsonObject["deliveries"]!!.jsonObject[ + DELIVERY_CLASS_ORIGINAL_HTTP + ]!!.jsonObject["subtitles"]!!.jsonObject["sidecar_text"]!!.jsonPrimitive.content.toBoolean(), + ) + } + @Test fun reanchorRequiresNegotiatedServerFeature() = runTest { val harness = Harness( - startResponse = response(plan(), features = listOf(PLAYBACK_PLAN_V3_FEATURE)), + startResponse = response( + plan(), + features = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ), + ), ) { _, _ -> error("A feature-gated reanchor must not reach the server") } harness.manager.start() @@ -115,15 +162,26 @@ class PlaybackSessionManagerSeekReanchorTest { val ready = assertIs(assertIs>(result).data) val request = harness.replanBodies.single() assertEquals("seek_reanchor", request.string("operation")) + assertNull(request["failure"], "timeline reanchors are not failure recovery") assertEquals(90.0, request["position_seconds"]!!.jsonPrimitive.double) assertEquals("plan-1", request.string("failed_plan_id")) assertEquals("original", request.string("quality_preference")) - assertEquals(7, request["output_route_generation"]!!.jsonPrimitive.int) + assertEquals( + "7", + request["client_playback_context"]!!.jsonObject["output"]!!.jsonObject.string("output_context_id"), + ) assertFalse(request["metered"]!!.jsonPrimitive.content.toBoolean()) assertEquals(50_000, request["bandwidth_estimate_kbps"]!!.jsonPrimitive.int) assertEquals("file:42:audio:1", request["selected_tracks"]!!.jsonObject["audio"]!!.jsonObject.string("id")) assertEquals(listOf("hevc"), request["client_capabilities"]!!.jsonObject["codecs_video"]!!.jsonArray.map { it.jsonPrimitive.content }) - assertEquals(2, request["attempted_plan_keys"]!!.jsonArray.size) + // Keys are server-owned: a local mutation records itself in + // `local_mutations` and leaves the key history alone, because only the + // server can mint the key for a route it has not planned yet. + assertEquals( + listOf("transport_reopen"), + request["local_mutations"]!!.jsonArray.map { it.jsonPrimitive.content }, + ) + assertEquals(1, request["attempted_plan_keys"]!!.jsonArray.size) assertEquals( request.string("plan_attempt_key"), request["attempted_plan_keys"]!!.jsonArray.last().jsonPrimitive.content, @@ -178,6 +236,16 @@ class PlaybackSessionManagerSeekReanchorTest { assertFalse(ready.session.playbackPlan!!.claims.audio.passthrough) } + @Test + fun transportReopenDoesNotResetTheSinglePcmRetry() = runTest { + val harness = Harness(response(plan())) { _, _ -> success(response(plan())) } + harness.manager.start() + + assertTrue(harness.manager.trySingleLocalPcmRetry("audio/eac3", 8)) + assertTrue(harness.manager.recordTransportReopen()) + assertFalse(harness.manager.trySingleLocalPcmRetry("audio/eac3", 8)) + } + @Test fun reanchorRejectsIdentityDriftAndKeepsTheActiveAttemptRetryable() = runTest { val initial = plan() @@ -240,10 +308,14 @@ class PlaybackSessionManagerSeekReanchorTest { @Test fun failedImmediateStartupReplanStopsAllocatedSessionAndClearsAttempt() = runTest { - val harness = Harness(response(plan().copy(engine = PlaybackEngineKind.MPV_DIRECT))) { _, _ -> + // An unknown runtime correction is a route this client cannot execute, + // so the manager replans immediately at startup — and that replan fails. + val harness = Harness( + response(plan().copy(runtimeCorrections = listOf("future_runtime_fix"))), + ) { _, _ -> MockResponse( HttpStatusCode.InternalServerError, - """{"error":"replan_failed","message":"Could not replace legacy route"}""", + """{"error":"replan_failed","message":"Could not replace unexecutable route"}""", ) } @@ -290,6 +362,7 @@ class PlaybackSessionManagerSeekReanchorTest { val initial = plan() val fallback = initial.copy( planId = "plan-2", + planAttemptKey = "v3:00000000000000a2", delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, stream = initial.stream.copy(url = "/stream/session-1/seek-recovery.m3u8"), effectiveRecipe = initial.effectiveRecipe.copy(audioCodec = "aac"), @@ -297,6 +370,7 @@ class PlaybackSessionManagerSeekReanchorTest { ) val thirdRoute = fallback.copy( planId = "plan-3", + planAttemptKey = "v3:00000000000000a3", stream = fallback.stream.copy(container = "fmp4"), ) val harness = Harness(response(initial)) { index, _ -> @@ -338,6 +412,7 @@ class PlaybackSessionManagerSeekReanchorTest { val initial = plan() val fallback = initial.copy( planId = "plan-2", + planAttemptKey = "v3:00000000000000a2", stream = initial.stream.copy(url = "/stream/session-1/seek-recovery.m3u8"), requestedMediaFileId = null, effectiveMediaFileId = null, @@ -360,6 +435,7 @@ class PlaybackSessionManagerSeekReanchorTest { val initial = plan() val switchedFile = initial.copy( planId = "plan-other-file", + planAttemptKey = "v3:00000000000000b1", requestedMediaFileId = 84, effectiveMediaFileId = 84, selectedTracks = SelectedPlaybackTracksV3( @@ -368,6 +444,7 @@ class PlaybackSessionManagerSeekReanchorTest { ) val fallback = initial.copy( planId = "plan-2", + planAttemptKey = "v3:00000000000000a2", delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, effectiveRecipe = initial.effectiveRecipe.copy(audioCodec = "aac"), ) @@ -391,27 +468,61 @@ class PlaybackSessionManagerSeekReanchorTest { } @Test - fun replanSynthesizesChangedTrackIdsFromTheEffectiveFile() = runTest { + fun replanEchoesInventorySubtitleIdAndSynthesizesChangedAudioId() = runTest { val initial = plan().copy( effectiveMediaFileId = 84, selectedTracks = SelectedPlaybackTracksV3( audio = PlaybackTrackIdentityV3("file:84:audio:1", 1), ), + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "server-subtitle-0", + combinedIndex = 0, + source = "external", + delivery = "sidecar", + url = "/stream/session-1/subtitles/0.vtt", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "server-subtitle-1", + combinedIndex = 1, + source = "embedded", + delivery = "sidecar", + url = "/stream/session-1/subtitles/1.vtt", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "server-subtitle-2", + combinedIndex = 2, + source = "embedded", + delivery = "burn_in_only", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "server-owned-subtitle-id", + combinedIndex = 3, + source = "embedded", + codec = "ass", + delivery = "sidecar", + url = "/stream/session-1/subtitles/3.ass", + ), + ), + ), ) val replanned = initial.copy( planId = "plan-2", + planAttemptKey = "v3:00000000000000a2", selectedTracks = SelectedPlaybackTracksV3( audio = PlaybackTrackIdentityV3("file:84:audio:2", 2), - subtitle = PlaybackTrackIdentityV3("file:84:subtitle:3", 3), + subtitle = PlaybackTrackIdentityV3("server-owned-subtitle-id", 3), ), subtitle = PlaybackSubtitleDecisionV3( mode = PlaybackSubtitleModeV3.RENDER, - trackId = "file:84:subtitle:3", + trackId = "server-owned-subtitle-id", artifact = PlaybackSubtitleArtifactV3( url = "/stream/session-1/subtitles/3.vtt", mimeType = "text/vtt", format = "webvtt", ), + inventory = initial.subtitle.inventory, ), ) val harness = Harness(response(initial)) { _, _ -> success(response(replanned)) } @@ -425,11 +536,14 @@ class PlaybackSessionManagerSeekReanchorTest { ) assertIs( - assertIs>(result).data, + assertIs>( + result, + "track-identity replan failed: $result", + ).data, ) val selectedTracks = harness.replanBodies.single()["selected_tracks"]!!.jsonObject assertEquals("file:84:audio:2", selectedTracks["audio"]!!.jsonObject.string("id")) - assertEquals("file:84:subtitle:3", selectedTracks["subtitle"]!!.jsonObject.string("id")) + assertEquals("server-owned-subtitle-id", selectedTracks["subtitle"]!!.jsonObject.string("id")) } private class Harness( @@ -437,6 +551,7 @@ class PlaybackSessionManagerSeekReanchorTest { networkEvidenceProvider: PlaybackNetworkEvidenceProvider = PlaybackNetworkEvidenceProvider.None, private val replanResponse: suspend (Int, JsonObject) -> MockResponse, ) { + val startBodies: MutableList = Collections.synchronizedList(mutableListOf()) val replanBodies: MutableList = Collections.synchronizedList(mutableListOf()) val stoppedSessionIds: MutableList = Collections.synchronizedList(mutableListOf()) private val replanIndex = AtomicInteger() @@ -444,10 +559,15 @@ class PlaybackSessionManagerSeekReanchorTest { MockEngine { request -> val path = request.url.encodedPath val response = when { - path == "/api/v1/playback/start" -> MockResponse( - HttpStatusCode.OK, - PrairieJson.encodeToString(startResponse), - ) + path == "/api/v1/playback/start" -> { + startBodies += PrairieJson.parseToJsonElement( + request.body.toByteArray().decodeToString(), + ).jsonObject + MockResponse( + HttpStatusCode.OK, + PrairieJson.encodeToString(startResponse), + ) + } path.endsWith("/replan") -> { val body = PrairieJson.parseToJsonElement( request.body.toByteArray().decodeToString(), @@ -492,7 +612,7 @@ class PlaybackSessionManagerSeekReanchorTest { clientPlaybackContext = ClientPlaybackContext( formFactor = "tv", appVersion = "test", - output = PlaybackOutputContext(outputRouteGeneration = 7), + output = PlaybackOutputContext(outputContextId = "7"), ), audioTrackIndex = 1, subtitleTrackIndex = null, @@ -504,8 +624,11 @@ class PlaybackSessionManagerSeekReanchorTest { private fun plan(): PlaybackPlanV3 = PlaybackPlanV3( planId = "plan-1", sessionId = "session-1", + // Server-minted and opaque. Fixtures use the server's `v3:%016x` shape + // and give every distinct route its own key, because the client's loop + // guard compares keys and can no longer derive one to tell routes apart. + planAttemptKey = "v3:00000000000000a1", delivery = PlaybackDelivery.SERVER_REMUX_HLS, - engine = PlaybackEngineKind.MEDIA3_HLS, stream = PlaybackStreamV3( url = "/stream/session-1/master.m3u8", protocol = PlaybackStreamProtocol.HLS, @@ -546,7 +669,11 @@ class PlaybackSessionManagerSeekReanchorTest { private fun response( plan: PlaybackPlanV3, sessionId: String = "session-1", - features: List = listOf(PLAYBACK_PLAN_V3_FEATURE, SEEK_REANCHOR_V3_FEATURE), + features: List = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + SEEK_REANCHOR_V3_FEATURE, + ), ): PlaybackDecisionResponseV3 = PlaybackDecisionResponseV3( protocolVersion = 3, serverFeatures = features, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt index 1bd1e4783..394b5fb6b 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt @@ -28,21 +28,25 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE +import org.prairieserver.prairie.model.playback.NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE import org.prairieserver.prairie.model.playback.PlaybackDecisionOutcome import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol import org.prairieserver.prairie.model.playback.PlaybackStreamV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleArtifactV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTerminalV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 @@ -70,7 +74,9 @@ class PlaybackSessionManagerStagedReplanTest { .substringAfter("suspend fun confirmVideoSessionPublication(") .substringBefore("suspend fun rollbackUnpublishedVideoSession(") - val orphanRegistration = confirmation.indexOf("orphanedSessionIds +=") + // Every insertion goes through the bounded helper now, so the ledger + // cannot grow without limit when stops keep failing. + val orphanRegistration = confirmation.indexOf("rememberOrphanedSessionLocked(") val waiterRelease = confirmation.indexOf("pending.settled.complete(Unit)") val registeredCleanup = confirmation.indexOf( "scheduleRegisteredCommittedSessionCleanup(", @@ -111,7 +117,7 @@ class PlaybackSessionManagerStagedReplanTest { // asynchronous cleanup still owns its first network attempt. harness.manager.stopSession("s2") releaseFirstCleanup.complete(Unit) - withTimeout(5_000) { + withTimeout(AWAIT_POLL_TIMEOUT_MS) { while (harness.manager.orphanedSessionIdsForTest().isNotEmpty()) { yield() } @@ -144,7 +150,7 @@ class PlaybackSessionManagerStagedReplanTest { } @Test - fun `staged replacement exposes manager derived output route generation`() = runTest { + fun stagedReplacementExposesTheOutputContextTheCandidateWasPlannedAgainst() = runTest { val harness = Harness( replanResponse = { _, _ -> response(sidecarPlan(sessionId = "s2")) }, ) @@ -159,12 +165,12 @@ class PlaybackSessionManagerStagedReplanTest { clientPlaybackContext = ClientPlaybackContext( formFactor = "tv", appVersion = "test", - output = PlaybackOutputContext(outputRouteGeneration = 11), + output = PlaybackOutputContext(outputContextId = "11"), ), ), ).data - assertEquals(11, staged.outputRouteGeneration) + assertEquals("11", staged.outputContextId) } @Test @@ -205,7 +211,7 @@ class PlaybackSessionManagerStagedReplanTest { response(sidecarPlan(sessionId = if (index == 0) "s2" else "s3")) }, ) - harness.start() + val renderedBase = harness.startReady() val replacement = harness.stageSidecar() assertIs>( @@ -219,7 +225,7 @@ class PlaybackSessionManagerStagedReplanTest { val reverseMutation = async { harness.manager.stageActiveVideoSessionReplan( - classification = "output_route_changed", + classification = "decoder_failure", positionSeconds = 43.0, audioTrackIndex = 0, subtitleTrackIndex = 4, @@ -237,6 +243,21 @@ class PlaybackSessionManagerStagedReplanTest { assertEquals(listOf("s1", "s1"), harness.replanBaseSessions) assertEquals("s1", harness.manager.activeSessionIdForTest()) assertEquals(mapOf("s2" to 1), harness.stoppedSessions.groupingBy { it }.eachCount()) + + val serverCursor = replacement.candidate + val secondRequest = harness.replanBodies[1] + assertEquals(serverCursor.plan.planId, secondRequest["failed_plan_id"]!!.jsonPrimitive.content) + assertEquals(serverCursor.planAttemptId, secondRequest["plan_attempt_id"]!!.jsonPrimitive.content) + assertEquals(serverCursor.planAttemptKey, secondRequest["plan_attempt_key"]!!.jsonPrimitive.content) + assertEquals( + listOf(serverCursor.planAttemptKey), + secondRequest["attempted_plan_keys"]!!.jsonArray.map { it.jsonPrimitive.content }, + ) + assertEquals(1, secondRequest["attempt_count"]!!.jsonPrimitive.int) + + val failureEvent = harness.awaitRouteEvent("plan_failed", renderedBase.plan.planId) + assertEquals(renderedBase.planAttemptId, failureEvent["plan_attempt_id"]!!.jsonPrimitive.content) + assertEquals(renderedBase.planAttemptKey, failureEvent["plan_attempt_key"]!!.jsonPrimitive.content) } @Test @@ -540,7 +561,7 @@ class PlaybackSessionManagerStagedReplanTest { val secondDiscard = launch { harness.manager.discardStagedVideoReplan(second) } try { withContext(Dispatchers.Default) { - withTimeout(5_000) { secondStopStarted.await() } + withTimeout(AWAIT_POLL_TIMEOUT_MS) { secondStopStarted.await() } } assertFalse(firstDiscard.isCompleted) assertTrue("s3" in harness.stopAttempts) @@ -608,6 +629,12 @@ class PlaybackSessionManagerStagedReplanTest { subtitle = PlaybackSubtitleDecisionV3( mode = PlaybackSubtitleModeV3.BURN_IN, trackId = subtitleTrackId(fileId = 42, index = 4), + inventory = subtitleInventory( + fileId = 42, + sessionId = "s2", + maxIndex = 4, + burnInIndex = 4, + ), ), ), ) @@ -634,6 +661,11 @@ class PlaybackSessionManagerStagedReplanTest { mode = PlaybackSubtitleModeV3.CONVERT, trackId = subtitleTrackId(fileId = 42, index = 4), artifact = null, + inventory = subtitleInventory( + fileId = 42, + sessionId = "s2", + maxIndex = 4, + ), ), ), ) @@ -670,6 +702,11 @@ class PlaybackSessionManagerStagedReplanTest { mode = PlaybackSubtitleModeV3.RENDER, trackId = subtitleTrackId(fileId = 42, index = 5), artifact = sidecarArtifact(sessionId = "s2", index = 5), + inventory = subtitleInventory( + fileId = 42, + sessionId = "s2", + maxIndex = 5, + ), ), ), ) @@ -689,6 +726,44 @@ class PlaybackSessionManagerStagedReplanTest { assertEquals(listOf("s2"), harness.stoppedSessions) } + @Test + fun `sidecar identity may be remapped when server adapts to another edition`() = runTest { + val remapped = sidecarPlan(sessionId = "s2").copy( + requestedMediaFileId = 42, + effectiveMediaFileId = 84, + selectedTracks = SelectedPlaybackTracksV3( + audio = audioTrack(fileId = 84), + subtitle = PlaybackTrackIdentityV3( + id = subtitleTrackId(fileId = 84, index = 1), + index = 1, + ), + ), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.CONVERT, + trackId = subtitleTrackId(fileId = 84, index = 1), + artifact = sidecarArtifact(sessionId = "s2", index = 1), + inventory = subtitleInventory( + fileId = 84, + sessionId = "s2", + maxIndex = 1, + ), + ), + ) + val harness = Harness(replanResponse = { _, _ -> response(remapped) }) + harness.start() + + val staged = harness.manager.stageActiveVideoSessionReplan( + classification = "decoder_failure", + positionSeconds = 42.0, + audioTrackIndex = 0, + subtitleTrackIndex = 4, + ) + + val candidate = assertIs>(staged).data.candidate + assertEquals(84, candidate.plan.effectiveMediaFileId) + assertEquals("file:84:subtitle:1", candidate.plan.selectedTracks.subtitle?.id) + } + @Test fun `immediate replan wrapper stages and commits replacement`() = runTest { val harness = Harness( @@ -816,13 +891,13 @@ class PlaybackSessionManagerStagedReplanTest { } @Test - fun `deferred legacy fresh start terminal replan restores prior active attempt`() = runTest { + fun deferredUnexecutableFreshStartTerminalReplanRestoresPriorActiveAttempt() = runTest { val harness = Harness( startResponses = listOf( response(basePlan(sessionId = "s1", fileId = 42)), response( basePlan(sessionId = "s3", fileId = 84).copy( - engine = PlaybackEngineKind.MPV_DIRECT, + runtimeCorrections = listOf("future_runtime_fix"), ), ), ), @@ -1063,6 +1138,10 @@ class PlaybackSessionManagerStagedReplanTest { harness.awaitStopped("s1") harness.manager.stopSession("s3") + // Stopping s3 also drains the stale candidate s2 it still owns, and that + // cleanup lands asynchronously. Only s1 was awaited, so on a contended + // runner the count assertion below raced it and saw {s1:1, s3:1}. + harness.awaitStopped("s2") assertEquals(null, harness.manager.activeSessionIdForTest()) assertEquals( @@ -1310,7 +1389,7 @@ class PlaybackSessionManagerStagedReplanTest { } @Test - fun `immediate legacy engine response preserves terminal outcome and cleanup`() = runTest { + fun immediateUnexecutableRouteResponsePreservesTerminalOutcomeAndCleanup() = runTest { val harness = Harness( replanResponse = { index, _ -> if (index == 0) { @@ -1318,7 +1397,7 @@ class PlaybackSessionManagerStagedReplanTest { } else { response( sidecarPlan(sessionId = "s3").copy( - engine = PlaybackEngineKind.MPV_DIRECT, + runtimeCorrections = listOf("future_runtime_fix"), ), ) } @@ -1337,7 +1416,7 @@ class PlaybackSessionManagerStagedReplanTest { val terminal = assertIs( assertIs>(result).data, ) - assertEquals("unsupported_legacy_engine", terminal.reason) + assertEquals(PlaybackSessionManager.UNEXECUTABLE_ROUTE_REASON, terminal.reason) assertEquals(null, harness.manager.activeSessionIdForTest()) assertEquals( mapOf("s1" to 1, "s3" to 1), @@ -1389,8 +1468,10 @@ class PlaybackSessionManagerStagedReplanTest { val replanBodies: MutableList = Collections.synchronizedList(mutableListOf()) val replanBaseSessions: MutableList = Collections.synchronizedList(mutableListOf()) + val routeEvents: MutableList = Collections.synchronizedList(mutableListOf()) private val stoppedEvents = Channel(Channel.UNLIMITED) private val stopAttemptEvents = Channel(Channel.UNLIMITED) + private val routeEventSignals = Channel(Channel.UNLIMITED) private val startIndex = AtomicInteger() private val replanIndex = AtomicInteger() private val client = HttpClient( @@ -1411,6 +1492,13 @@ class PlaybackSessionManagerStagedReplanTest { replanBodies += body replanResponse(replanIndex.getAndIncrement(), body) } + path == "/api/v1/playback/route-events" -> { + routeEvents += PrairieJson.parseToJsonElement( + request.body.toByteArray().decodeToString(), + ).jsonObject + routeEventSignals.send(Unit) + null + } request.method == HttpMethod.Delete && path.startsWith("/api/v1/playback/") -> { val sessionId = path.substringAfterLast('/') stopAttempts += sessionId @@ -1446,7 +1534,24 @@ class PlaybackSessionManagerStagedReplanTest { deferPublication: Boolean = false, ) { assertIs>( - manager.startVideoSessionV3( + startResult(fileId, deferPublication), + ) + } + + suspend fun startReady( + fileId: Int = 42, + deferPublication: Boolean = false, + ): VideoSessionStartV3.Ready = assertIs( + assertIs>( + startResult(fileId, deferPublication), + ).data, + ) + + private suspend fun startResult( + fileId: Int, + deferPublication: Boolean, + ): ApiResult = + manager.startVideoSessionV3( fileId = fileId, profileId = "profile-1", capabilities = ClientCodecCapabilities( @@ -1457,7 +1562,7 @@ class PlaybackSessionManagerStagedReplanTest { clientPlaybackContext = ClientPlaybackContext( formFactor = "tv", appVersion = "test", - output = PlaybackOutputContext(outputRouteGeneration = 7), + output = PlaybackOutputContext(outputContextId = "7"), ), audioTrackIndex = 0, subtitleTrackIndex = null, @@ -1465,9 +1570,7 @@ class PlaybackSessionManagerStagedReplanTest { startPosition = 0.0, subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, deferPublication = deferPublication, - ), - ) - } + ) suspend fun stageSidecar(): StagedVideoReplan = assertIs>( manager.stageActiveVideoSessionReplan( @@ -1490,6 +1593,18 @@ class PlaybackSessionManagerStagedReplanTest { stopAttemptEvents.receive() } } + + suspend fun awaitRouteEvent(event: String, planId: String): JsonObject = + withTimeout(AWAIT_POLL_TIMEOUT_MS) { + while (true) { + routeEvents.firstOrNull { body -> + body["event"]?.jsonPrimitive?.content == event && + body["plan_id"]?.jsonPrimitive?.content == planId + }?.let { return@withTimeout it } + routeEventSignals.receive() + } + error("unreachable") + } } private companion object { @@ -1498,9 +1613,9 @@ class PlaybackSessionManagerStagedReplanTest { fileId: Int = 42, ): PlaybackPlanV3 = PlaybackPlanV3( planId = "plan-$sessionId", + planAttemptKey = "v3:test:$sessionId", sessionId = sessionId, delivery = PlaybackDelivery.SERVER_REMUX_HLS, - engine = PlaybackEngineKind.MEDIA3_HLS, stream = PlaybackStreamV3( url = "/stream/$sessionId/master.m3u8", protocol = PlaybackStreamProtocol.HLS, @@ -1529,9 +1644,30 @@ class PlaybackSessionManagerStagedReplanTest { mode = PlaybackSubtitleModeV3.CONVERT, trackId = subtitleTrackId(fileId = 42, index = 4), artifact = sidecarArtifact(sessionId = sessionId, index = 4), + inventory = subtitleInventory( + fileId = 42, + sessionId = sessionId, + maxIndex = 4, + ), ), ) + fun subtitleInventory( + fileId: Int, + sessionId: String, + maxIndex: Int, + burnInIndex: Int? = null, + ): List = (0..maxIndex).map { index -> + val burnIn = index == burnInIndex + PlaybackSubtitleInventoryItemV3( + trackId = subtitleTrackId(fileId, index), + combinedIndex = index, + source = "embedded", + delivery = if (burnIn) "burn_in_only" else "sidecar", + url = if (burnIn) null else "/stream/$sessionId/subtitles/$index.vtt", + ) + } + fun sidecarArtifact(sessionId: String, index: Int): PlaybackSubtitleArtifactV3 = PlaybackSubtitleArtifactV3( url = "/stream/$sessionId/subtitles/$index.vtt", @@ -1548,7 +1684,10 @@ class PlaybackSessionManagerStagedReplanTest { fun response(plan: PlaybackPlanV3): PlaybackDecisionResponseV3 = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ), outcome = PlaybackDecisionOutcome.PLAYABLE, sessionId = plan.sessionId, playbackPlan = plan, @@ -1560,7 +1699,10 @@ class PlaybackSessionManagerStagedReplanTest { message: String, ): PlaybackDecisionResponseV3 = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ), outcome = PlaybackDecisionOutcome.ADAPTATION_UNAVAILABLE, sessionId = sessionId, terminal = PlaybackTerminalV3( @@ -1590,3 +1732,13 @@ private object StagedReplanNoOpTokenManager : TokenManager { override suspend fun signOutCurrentServer() {} override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = null } + +/** + * Wall-clock backstop for the awaits above. + * + * These wait on signals and spins whose progress depends on getting scheduled, + * while the deadline counts real seconds regardless — so on a loaded CI runner + * a merely-slow test failed as if it had raced. The deadline exists to turn a + * hang into a failure, not to police latency. + */ +private const val AWAIT_POLL_TIMEOUT_MS = 30_000L diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerTranscodeFallbackTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerTranscodeFallbackTest.kt deleted file mode 100644 index 35f91a31e..000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerTranscodeFallbackTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -package org.prairieserver.prairie.common.player - -import org.prairieserver.prairie.model.playback.PlayMethod -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.AuthScopeSnapshot -import org.prairieserver.prairie.network.PrairieJson -import org.prairieserver.prairie.network.TokenManager -import org.prairieserver.prairie.network.api.PlaybackApi -import org.prairieserver.prairie.repository.PlaybackRepository -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.toByteArray -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class PlaybackSessionManagerTranscodeFallbackTest { - @Test - fun remuxFallbackPreservesRemuxPlayMethodAndRequestsCopyCodecs() = runTest { - val captured = CapturedRequest() - val manager = manager( - captured = captured, - responseBody = """{"session_id":"remux-session","status":"ready","manifest_url":"/stream/remux/master","duration_seconds":120.0,"player_start_seconds":42.5}""", - ) - - val result = manager.startTranscodeFallback( - session = session(playMethod = PlayMethod.REMUX), - seekSeconds = 42.5, - resolution = "1080p", - mode = PlaybackSessionManager.TranscodeMode.REMUX, - ) - - assertTrue(result is ApiResult.Success) - assertEquals(PlayMethod.REMUX, result.data.playMethod) - assertEquals("/stream/remux/master", result.data.streamUrl) - assertEquals(42.5, result.data.position) - - val body = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("copy", body["target_codec_video"]!!.jsonPrimitive.content) - assertEquals("copy", body["target_codec_audio"]!!.jsonPrimitive.content) - assertEquals(0, body["target_bitrate_kbps"]!!.jsonPrimitive.int) - } - - @Test - fun fullFallbackStillReportsTranscodePlayMethod() = runTest { - val manager = manager( - captured = CapturedRequest(), - responseBody = """{"session_id":"transcode-session","status":"ready","manifest_url":"/stream/transcode/master","duration_seconds":120.0,"player_start_seconds":12.0}""", - ) - - val result = manager.startTranscodeFallback( - session = session(playMethod = PlayMethod.DIRECT), - seekSeconds = 12.0, - resolution = "1080p", - mode = PlaybackSessionManager.TranscodeMode.FULL, - ) - - assertTrue(result is ApiResult.Success) - assertEquals(PlayMethod.TRANSCODE, result.data.playMethod) - } - - @Test - fun fallbackRenewsPlaybackSessionWhenServerReportsSessionMissing() = runTest { - val captured = CapturedRequest() - val manager = manager( - captured = captured, - responses = ArrayDeque( - listOf( - MockHttpResponse( - status = HttpStatusCode.NotFound, - body = """{"error":"playback_session_not_found","message":"Playback session not found"}""", - ), - MockHttpResponse( - status = HttpStatusCode.OK, - body = """{"session_id":"fresh-session","status":"ready","manifest_url":"/stream/transcode/fresh","duration_seconds":120.0,"player_start_seconds":33.0}""", - ), - ), - ), - ) - - val result = manager.startTranscodeFallbackRecoveringMissingSession( - session = session(sessionId = "stale-session", playMethod = PlayMethod.DIRECT), - seekSeconds = 33.0, - resolution = "2160p", - mode = PlaybackSessionManager.TranscodeMode.FULL, - renewSession = { - ApiResult.Success( - session(sessionId = "fresh-session", playMethod = PlayMethod.DIRECT), - ) - }, - ) - - assertTrue(result is ApiResult.Success) - assertEquals("fresh-session", result.data.sessionId) - assertEquals("/stream/transcode/fresh", result.data.streamUrl) - assertEquals( - listOf("stale-session", "fresh-session"), - captured.bodies.map { body -> - PrairieJson.parseToJsonElement(body).jsonObject["session_id"]!!.jsonPrimitive.content - }, - ) - } - - @Test - fun fallbackStopsRenewedSessionWhenRetryFailsAfterRenewal() = runTest { - val captured = CapturedRequest() - val manager = manager( - captured = captured, - responses = ArrayDeque( - listOf( - MockHttpResponse( - status = HttpStatusCode.NotFound, - body = """{"error":"playback_session_not_found","message":"Playback session not found"}""", - ), - MockHttpResponse( - status = HttpStatusCode.InternalServerError, - body = """{"error":"transcode_failed","message":"Transcode retry failed"}""", - ), - MockHttpResponse(status = HttpStatusCode.OK, body = ""), - ), - ), - ) - - val result = manager.startTranscodeFallbackRecoveringMissingSession( - session = session(sessionId = "stale-session", playMethod = PlayMethod.DIRECT), - seekSeconds = 33.0, - resolution = "2160p", - mode = PlaybackSessionManager.TranscodeMode.FULL, - renewSession = { - ApiResult.Success( - session(sessionId = "fresh-session", playMethod = PlayMethod.DIRECT), - ) - }, - ) - - assertTrue(result is ApiResult.Error) - assertEquals( - listOf( - "POST /api/v1/playback/transcode/start", - "POST /api/v1/playback/transcode/start", - "DELETE /api/v1/playback/fresh-session", - ), - captured.calls, - ) - } - - private fun manager( - captured: CapturedRequest, - responseBody: String, - ): PlaybackSessionManager = - manager( - captured = captured, - responses = ArrayDeque(listOf(MockHttpResponse(HttpStatusCode.OK, responseBody))), - ) - - private fun manager( - captured: CapturedRequest, - responses: ArrayDeque, - ): PlaybackSessionManager { - val client = HttpClient( - MockEngine { request -> - val body = request.body.toByteArray().decodeToString() - captured.calls += "${request.method.value} ${request.url.encodedPath}" - captured.body = body - captured.bodies += body - val response = responses.removeFirst() - respond( - content = response.body, - status = response.status, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - install(ContentNegotiation) { json(PrairieJson) } - } - return PlaybackSessionManager( - playbackRepository = PlaybackRepository(PlaybackApi(client)), - tokenManager = NoOpTokenManager, - ) - } - - private fun session( - sessionId: String = "session-1", - playMethod: PlayMethod, - ): PlaybackSessionResponse = - PlaybackSessionResponse( - sessionId = sessionId, - userId = 1, - profileId = "profile-1", - mediaFileId = 42, - playMethod = playMethod, - streamUrl = "/stream/session-1", - durationSeconds = 120.0, - ) - - private class CapturedRequest { - var body: String = "" - val bodies = mutableListOf() - val calls = mutableListOf() - } - - private data class MockHttpResponse( - val status: HttpStatusCode, - val body: String, - ) -} - -private object NoOpTokenManager : TokenManager { - override val sessionExpired: SharedFlow = MutableSharedFlow() - override suspend fun getAccessToken(): String? = null - override suspend fun getRefreshToken(): String? = null - override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) {} - override suspend fun clearTokens() {} - override suspend fun invalidateSession() {} - override suspend fun getProfileId(): String? = null - override suspend fun setProfileId(profileId: String?) {} - override suspend fun getProfileToken(): String? = null - override suspend fun setProfileToken(token: String?) {} - override suspend fun getServerUrl(): String = "" - override suspend fun setServerUrl(url: String) {} - override suspend fun getCurrentServerId(): String? = null - override suspend fun switchActiveServer(serverId: String?) {} - override suspend fun signOutCurrentServer() {} - override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = null -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt index 0fb654b16..b4a61d4b5 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt @@ -1,23 +1,125 @@ package org.prairieserver.prairie.common.player import org.prairieserver.prairie.model.playback.PlaybackDelivery -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackSourceDescriptorV3 import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol import org.prairieserver.prairie.model.playback.PlaybackStreamV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleArtifactV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 import org.prairieserver.prairie.network.PrairieJson import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class PlaybackV3SessionTest { + @Test + fun offPlanProjectsTheCompleteAuthoritativeInventoryForPhoneAndTv() { + val response = plan( + mode = PlaybackSubtitleModeV3.OFF, + format = "", + url = "", + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "external", + combinedIndex = 0, + source = "external", + codec = "srt", + language = "eng", + label = "English", + delivery = "sidecar", + url = "/subtitles/0.vtt", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "bitmap", + combinedIndex = 1, + source = "embedded", + codec = "dvd_subtitle", + language = "fra", + label = "French", + delivery = "burn_in_only", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "provider", + combinedIndex = 2, + source = "downloaded", + codec = "ass", + language = "spa", + label = "Spanish", + forced = true, + delivery = "sidecar", + url = "/subtitles/2.ass", + ), + ), + ).toSessionResponse("session", "profile", 482) + + val rows = response.subtitleUrls.orEmpty() + assertEquals(listOf(0, 1, 2), rows.map(PlayerSubtitleInfo::index)) + assertEquals(listOf("/subtitles/0.vtt", "", "/subtitles/2.ass"), rows.map(PlayerSubtitleInfo::url)) + assertEquals("bitmap", rows[1].serverTrackId) + assertEquals("burn_in_only", rows[1].serverDelivery) + assertEquals("downloaded", rows[2].source) + assertTrue(rows[2].forced == true) + assertTrue(!rows[2].isDownloadedSubtitleArtifact()) + } + + @Test + fun unknownSubtitleDeliveriesStayOutOfTheNativePicker() { + val response = plan( + mode = PlaybackSubtitleModeV3.OFF, + format = "", + url = "", + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "future", + combinedIndex = 0, + source = "embedded", + codec = "future_codec", + delivery = "future_delivery", + ), + ), + ).toSessionResponse("session", "profile", 482) + + assertTrue(response.subtitleUrls.orEmpty().isEmpty()) + } + + @Test + fun burnInPlanKeepsInventorySelectableButDoesNotMountAlternatives() { + val response = plan( + mode = PlaybackSubtitleModeV3.BURN_IN, + format = "", + url = "", + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "text", + combinedIndex = 0, + source = "external", + codec = "srt", + delivery = "sidecar", + url = "/subtitles/0.srt", + ), + PlaybackSubtitleInventoryItemV3( + trackId = "bitmap", + combinedIndex = 1, + source = "embedded", + codec = "dvd_subtitle", + delivery = "burn_in_only", + ), + ), + ).toSessionResponse("session", "profile", 482) + + val rows = response.subtitleUrls.orEmpty() + assertEquals(listOf(0, 1), rows.map(PlayerSubtitleInfo::index)) + assertTrue(rows.all { it.url.isEmpty() }) + } + @Test fun originalEmbeddedBitmapRenderArtifactBecomesSelectionMetadataNotASidecar() { val response = plan( @@ -33,6 +135,31 @@ class PlaybackV3SessionTest { assertEquals("", subtitle.url) } + @Test + fun unresolvedArtifactDoesNotCollideWithAuthoritativeIndexZero() { + val inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "catalog-zero", + combinedIndex = 0, + source = "external", + codec = "srt", + delivery = "sidecar", + url = "/subtitles/0.vtt", + ), + ) + val incomplete = plan( + mode = PlaybackSubtitleModeV3.CONVERT, + format = "vtt", + url = "/artifact.vtt", + inventory = inventory, + ).copy(selectedTracks = SelectedPlaybackTracksV3(subtitle = null)) + + val rows = incomplete.toSessionResponse("session", "profile", 482).subtitleUrls.orEmpty() + + assertEquals(listOf("catalog-zero"), rows.mapNotNull(PlayerSubtitleInfo::serverTrackId)) + assertTrue(rows.none { it.source == "server_artifact" }) + } + @Test fun convertedTextArtifactRemainsAMountableServerSidecar() { val response = plan( @@ -46,6 +173,35 @@ class PlaybackV3SessionTest { assertEquals("/stream/session/subtitles/2.vtt", subtitle.url) } + @Test + fun stableSubtitleIdentityRestoresItsAuthoritativeOrdinal() { + val inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "stable-track", + combinedIndex = 7, + source = "external", + codec = "srt", + delivery = "sidecar", + url = "/subtitles/7.vtt", + ), + ) + val plan = plan( + mode = PlaybackSubtitleModeV3.CONVERT, + format = "webvtt", + url = "/subtitles/7.vtt", + inventory = inventory, + ).copy( + selectedTracks = SelectedPlaybackTracksV3( + subtitle = PlaybackTrackIdentityV3("stable-track", index = null), + ), + ) + + val response = plan.toSessionResponse("session", "profile", 482) + + assertEquals(7, response.playbackPlan?.selectedTracks?.subtitleIndex) + assertEquals(7, response.subtitleUrls.orEmpty().single().index) + } + @Test fun protocolV3TimelineSemanticsSurviveTheActivePlanConversion() { val timeline = PlaybackTimelineV3( @@ -114,7 +270,6 @@ class PlaybackV3SessionTest { { "plan_id": "plan", "delivery": "original_http", - "engine": "media3_direct", "stream": {"url": "/stream/session", "protocol": "http_progressive"}, "decision_reason": "test" } @@ -131,29 +286,50 @@ class PlaybackV3SessionTest { url: String, timeline: PlaybackTimelineV3 = PlaybackTimelineV3(), source: PlaybackSourceDescriptorV3 = PlaybackSourceDescriptorV3(), - ) = PlaybackPlanV3( - source = source, - planId = "plan", - delivery = PlaybackDelivery.ORIGINAL_HTTP, - engine = PlaybackEngineKind.MEDIA3_DIRECT, - stream = PlaybackStreamV3( - url = "/stream/session", - protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, - container = "mkv", - ), - timeline = timeline, - selectedTracks = SelectedPlaybackTracksV3( - subtitle = PlaybackTrackIdentityV3("subtitle", 2), - ), - subtitle = PlaybackSubtitleDecisionV3( - mode = mode, - trackId = "subtitle", - artifact = PlaybackSubtitleArtifactV3( + inventory: List = emptyList(), + ): PlaybackPlanV3 { + val inventorySelection = inventory.firstOrNull { + mode != PlaybackSubtitleModeV3.OFF && + (mode != PlaybackSubtitleModeV3.BURN_IN || it.delivery == "burn_in_only") + } + val selectedSubtitle = when { + mode == PlaybackSubtitleModeV3.OFF -> null + inventorySelection != null -> PlaybackTrackIdentityV3( + inventorySelection.trackId, + inventorySelection.combinedIndex, + ) + else -> PlaybackTrackIdentityV3("subtitle", 2) + } + val selectedArtifact = if ( + mode == PlaybackSubtitleModeV3.CONVERT || mode == PlaybackSubtitleModeV3.RENDER + ) { + PlaybackSubtitleArtifactV3( url = url, mimeType = "text/vtt", format = format, + ) + } else { + null + } + return PlaybackPlanV3( + source = source, + planId = "plan", + planAttemptKey = "v3:test:plan", + delivery = PlaybackDelivery.ORIGINAL_HTTP, + stream = PlaybackStreamV3( + url = "/stream/session", + protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, + container = "mkv", + ), + timeline = timeline, + selectedTracks = SelectedPlaybackTracksV3(subtitle = selectedSubtitle), + subtitle = PlaybackSubtitleDecisionV3( + mode = mode, + trackId = selectedSubtitle?.id, + artifact = selectedArtifact, + inventory = inventory, ), - ), - decisionReason = "test", - ) + decisionReason = "test", + ) + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt new file mode 100644 index 000000000..ac989f6af --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt @@ -0,0 +1,330 @@ +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrairieLoadControlTest { + @Test + fun ordinaryPlaybackKeepsTheDeviceBufferBudget() { + assertEquals( + 96 * 1024 * 1024, + playbackBufferBudgetBytes( + baseBudgetBytes = 96 * 1024 * 1024, + hasDolbyVision = false, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + ), + ) + } + + @Test + fun dolbyVisionLeavesHalfOfTheOrdinaryAllocatorBudgetAsHeapHeadroom() { + assertEquals( + 48 * 1024 * 1024, + playbackBufferBudgetBytes( + baseBudgetBytes = 96 * 1024 * 1024, + hasDolbyVision = true, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + ), + ) + } + + @Test + fun dolbyVisionAdjustmentNeverRaisesOrUndercutsAConstrainedBudget() { + val constrained = 8 * 1024 * 1024 + + assertEquals( + constrained, + playbackBufferBudgetBytes( + baseBudgetBytes = constrained, + hasDolbyVision = true, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + ), + ) + } + + @Test + fun dolbyVisionBufferTrackRecognizesMimeAndCodecSignals() { + assertTrue(isDolbyVisionBufferTrack("video/dolby-vision", null)) + listOf("dvhe.08.06", "dvh1.05.06", "dva1.09.01", "dvav.09.01").forEach { codec -> + assertTrue(isDolbyVisionBufferTrack("video/hevc", codec)) + } + assertFalse(isDolbyVisionBufferTrack("video/hevc", "hvc1.2.4.L153.B0")) + } + + @Test + fun `average bitrate takes precedence over peak bitrate`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = 4_000_000, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(4_000_000L, selected) + } + + @Test + fun `peak bitrate is used when average bitrate is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = -1, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(9_000_000L, selected) + } + + @Test + fun `known selected media bitrates are summed and network capacity is ignored`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, 192_000, 100_000_000L), + ), + ) + + assertEquals(4_192_000L, selected) + } + + @Test + fun `known audio cannot hide an unknown high bitrate video track`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(384_000, 384_000, 100_000_000L), + BufferSizingTrackBitrates(-1, -1, 100_000_000L), + ), + ) + + assertEquals(100_384_000L, selected) + } + + @Test + fun `partial media metadata stays unknown until a network estimate exists`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(384_000, 384_000, 100_000_000L), + BufferSizingTrackBitrates(-1, -1, -1L), + ), + ) + + assertNull(selected) + } + + @Test + fun `largest network estimate is the last resort when all media metadata is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(0, -1, 18_000_000L), + BufferSizingTrackBitrates(-1, 0, 25_000_000L), + ), + ) + + assertEquals(25_000_000L, selected) + } + + @Test + fun `unknown bitrate remains unknown when metadata and network estimates are invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(-1, 0, -1L), + ), + ) + + assertNull(selected) + } + + @Test + fun `empty track selection remains unknown`() { + assertNull(selectBufferSizingBitrateBps(emptyList())) + } + + @Test + fun `depth follows the budget honestly, even below the floor`() { + // 60 Mbps against a 48 MiB budget: accounting for the same 15% + // overhead margin calculateBitrateTargetBufferBytes applies when it + // turns this depth back into bytes, the budget only really affords + // ~5.8s. The requested 180s cannot be held, and neither can the 20s + // floor — the budget wins over the floor because a false, rounded-up + // report would be worse than an honest shortfall. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 60_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertTrue("expected reduction below the floor, got $depth", depth < 20_000) + assertEquals("should report the honest budget-derived value", 5_835, depth) + } + + @Test + fun `depth is left alone when the budget can fund it`() { + // 5 Mbps against 160 MiB: ~268s available, more than the 180s asked for. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 5_000_000L, + budgetBytes = 160 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(180_000, depth) + } + + @Test + fun `depth falls back to the request when the bitrate is unknown`() { + val depth = + affordableDepthMs( + desiredDepthMs = 120_000, + selectedBitrateBps = null, + budgetBytes = 96 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(120_000, depth) + } + + @Test + fun `composed sizing derives depth from the budget and sizes bytes just under the ceiling`() { + // A 40 Mbps stream on a 48 MiB budget cannot hold the 180s the policy + // asks for; the budget-derived depth (~8.75s, once the overhead + // margin is accounted for) is neither the request nor the 20s floor. + // The resulting byte target is sized from that depth and lands at or + // just under the budget — not the (distinct) fallback — which is + // exactly what proves the depth actually determines the bytes, + // rather than both overshooting and clamping to the same ceiling + // regardless of which depth was used. + val budgetBytes = 48 * 1024 * 1024 + val fallbackBytes = 30 * 1024 * 1024 // distinct from budgetBytes: catches a maximumBytes mix-up + val result = + computeBufferSizing( + selectedBitrateBps = 40_000_000L, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = budgetBytes, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallbackBytes, + ) + + assertEquals("depth should be the honest budget-derived value", 8_753, result.depth.ms) + assertTrue( + "byte target ${result.target.bytes} should not exceed the budget $budgetBytes", + result.target.bytes <= budgetBytes, + ) + assertTrue( + "byte target ${result.target.bytes} should land just under the budget, not clamp to it", + result.target.bytes > budgetBytes - (budgetBytes / 50), + ) + } + + @Test + fun `composed sizing reports the true budget-limited depth, not just a clamped byte target`() { + // Both a correctly-routed depth and an un-routed, un-reduced one can + // produce the same clamped byte target once the byte clamp is hit — + // that erasure is exactly how a wiring bug that never routes the + // affordable depth into the byte calculation went undetected. This + // asserts the depth itself, which is the only place such a bug is + // visible: 60 Mbps against a 48 MiB budget affords ~5.8s once the + // overhead margin is accounted for. + val result = + computeBufferSizing( + selectedBitrateBps = 60_000_000L, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 48 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 48 * 1024 * 1024, + ) + + assertEquals("depth should be the true budget-limited value", 5_835, result.depth.ms) + } + + @Test + fun `composed sizing routes the fallback bytes when the bitrate is unknown`() { + // With no bitrate to size from, the requested depth passes through + // untouched and the byte target must come from the caller-supplied + // fallback (what the superclass computed) rather than the budget. + val fallbackBytes = 40 * 1024 * 1024 + val result = + computeBufferSizing( + selectedBitrateBps = null, + desiredDepthMs = 120_000, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 160 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = fallbackBytes, + ) + + assertEquals("depth should pass through unchanged", 120_000, result.depth.ms) + assertEquals("byte target should route the fallback", fallbackBytes, result.target.bytes) + } + + @Test + fun `composed sizing never asks for a deeper buffer than the policy requested`() { + // A reduction must only ever shrink the depth, never grow it — + // growing it would widen the fixed idle window between min and max. + val desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS + val result = + computeBufferSizing( + selectedBitrateBps = 80_000_000L, + desiredDepthMs = desiredDepthMs, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = 48 * 1024 * 1024, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 48 * 1024 * 1024, + ) + + assertTrue( + "depth ${result.depth.ms} exceeded requested $desiredDepthMs", + result.depth.ms <= desiredDepthMs, + ) + } + + @Test + fun `composed sizing keeps the budget authoritative when it is below the nominal byte floor`() { + // MIN_TARGET_BUFFER_BYTES and the policy's memory floor are both + // 16 MiB today, so nothing on shipping hardware reaches this case. + // A future change to either constant could separate them, and the + // relation must then be restored by lowering the floor, not raising + // the ceiling: a device allowed 8 MiB must get 8 MiB, not the 16 MiB + // floor its heap cannot hold. Both a known and an unknown bitrate are + // exercised, since the unknown path routes a caller-supplied fallback + // that is itself larger than the budget here. + val budgetBytes = 8 * 1024 * 1024 + + for (bitrate in listOf(6_000_000L, null)) { + val result = + computeBufferSizing( + selectedBitrateBps = bitrate, + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + budgetBytes = budgetBytes, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + unknownBitrateFallbackBytes = 40 * 1024 * 1024, + ) + + assertTrue( + "byte target ${result.target.bytes} exceeded the budget $budgetBytes at bitrate $bitrate", + result.target.bytes <= budgetBytes, + ) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackServiceStartPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackServiceStartPolicyTest.kt new file mode 100644 index 000000000..67fcdda12 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairiePlaybackServiceStartPolicyTest.kt @@ -0,0 +1,120 @@ +package org.prairieserver.prairie.common.player + +import android.content.Intent +import android.os.Bundle +import androidx.media3.session.MediaSessionService +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Guards the decisions that keep [PrairiePlaybackService] from being cold-started + * and then killed by the platform's 10-second start-foreground watchdog. + * + * A media key on a TV remote is delivered to this service through a + * `PendingIntent.getForegroundService()` that Media3 mints for the session, so + * a stray key press with nothing playing used to start the service, produce no + * notification, and take the whole app down with a RemoteServiceException. + */ +@RunWith(RobolectricTestRunner::class) +class PrairiePlaybackServiceStartPolicyTest { + + @Test + fun mediaButtonFallbackCallerIsRecognisedFromConnectionHints() { + val hints = Bundle().apply { + putString( + MediaSessionService.CONNECTION_HINT_KEY_CONTROLLER_INFO_TYPE, + Intent.ACTION_MEDIA_BUTTON, + ) + } + + assertTrue(PrairiePlaybackService.isMediaButtonFallbackCaller(hints)) + } + + @Test + fun realControllersAreNotMistakenForTheMediaButtonCaller() { + assertFalse( + PrairiePlaybackService.isMediaButtonFallbackCaller(Bundle()), + "an ordinary MediaController connection carries no controller-info hint", + ) + assertFalse( + PrairiePlaybackService.isMediaButtonFallbackCaller( + Bundle().apply { + putString( + MediaSessionService.CONNECTION_HINT_KEY_CONTROLLER_INFO_TYPE, + "androidx.media3.session.MediaBrowserService", + ) + }, + ), + "only ACTION_MEDIA_BUTTON identifies the synthetic media-button caller", + ) + } + + @Test + fun coldMediaButtonStartWithNothingQueuedHasNothingToServe() { + assertTrue( + PrairiePlaybackService.hasNothingToServe( + queuedMediaItemCount = 0, + isPlaybackOngoing = false, + connectedControllerCount = 0, + ), + "an idle player, no foreground playback and no controller is the crash state", + ) + } + + @Test + fun queuedMediaKeepsTheServiceAlive() { + assertFalse( + PrairiePlaybackService.hasNothingToServe( + queuedMediaItemCount = 1, + isPlaybackOngoing = false, + connectedControllerCount = 0, + ), + "a media button that can resume queued content must still be honoured", + ) + } + + @Test + fun ongoingForegroundPlaybackKeepsTheServiceAlive() { + assertFalse( + PrairiePlaybackService.hasNothingToServe( + queuedMediaItemCount = 0, + isPlaybackOngoing = true, + connectedControllerCount = 0, + ), + "a running foreground playback service must never be torn down", + ) + } + + @Test + fun connectedControllerKeepsTheServiceAlive() { + assertFalse( + PrairiePlaybackService.hasNothingToServe( + queuedMediaItemCount = 0, + isPlaybackOngoing = false, + connectedControllerCount = 1, + ), + "a player screen that has just bound the service must not be stopped under it", + ) + } + + @Test + fun pictureInPictureActionsMustNotBeDeliveredAsForegroundServiceStarts() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/common/pip/PrairiePictureInPictureCoordinator.kt", + ).readText() + + assertTrue( + source.contains("PendingIntent.getService("), + "PiP transport actions must stay plain startService() sends", + ) + assertFalse( + source.contains("PendingIntent.getForegroundService("), + "getForegroundService() would arm the start-foreground watchdog on the PiP path, " + + "which PrairiePlaybackService.onStartCommand deliberately does not satisfy", + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSourceTest.kt new file mode 100644 index 000000000..8fdbe9499 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/ReplayableSubtitleDataSourceTest.kt @@ -0,0 +1,130 @@ +package org.prairieserver.prairie.common.player + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ReplayableSubtitleDataSourceTest { + @Test + fun `whole sidecar is fetched once and replayed across data sources`() { + val upstream = RecordingDataSourceFactory("WEBVTT\n\n00:01.000 --> 00:02.000\nHello\n".encodeToByteArray()) + val factory = ReplayableSubtitleDataSourceFactory(upstream) + val spec = DataSpec(Uri.parse("https://example.test/subtitles/7.vtt")) + + val first = factory.createDataSource() + assertEquals(upstream.payload.size.toLong(), first.open(spec)) + assertContentEquals(upstream.payload, first.readAll()) + first.close() + + val reopened = factory.createDataSource() + assertEquals(upstream.payload.size.toLong(), reopened.open(spec)) + assertContentEquals(upstream.payload, reopened.readAll()) + reopened.close() + + assertEquals(1, upstream.openCount) + } + + @Test + fun `cached sidecar honors a ranged extractor reopen`() { + val upstream = RecordingDataSourceFactory("0123456789".encodeToByteArray()) + val factory = ReplayableSubtitleDataSourceFactory(upstream) + val uri = Uri.parse("https://example.test/subtitles/2.vtt") + factory.createDataSource().run { + open(DataSpec(uri)) + readAll() + close() + } + + val ranged = factory.createDataSource() + val rangeSpec = DataSpec.Builder() + .setUri(uri) + .setPosition(4) + .setLength(3) + .build() + assertEquals(3L, ranged.open(rangeSpec)) + assertContentEquals("456".encodeToByteArray(), ranged.readAll()) + ranged.close() + + assertEquals(1, upstream.openCount) + } + + @Test + fun `oversized sidecar fails before it can be cached`() { + val upstream = RecordingDataSourceFactory("too large".encodeToByteArray()) + val factory = ReplayableSubtitleDataSourceFactory(upstream, maxBytes = 3) + + assertFailsWith { + factory.createDataSource().open( + DataSpec(Uri.parse("https://example.test/subtitles/7.vtt")), + ) + } + assertEquals(1, upstream.openCount) + assertEquals(1, upstream.closeCount) + } +} + +private class RecordingDataSourceFactory( + val payload: ByteArray, +) : DataSource.Factory { + var openCount = 0 + var closeCount = 0 + + override fun createDataSource(): DataSource = object : DataSource { + private var uri: Uri? = null + private var position = 0 + private var limit = 0 + + override fun addTransferListener(transferListener: TransferListener) = Unit + + override fun open(dataSpec: DataSpec): Long { + openCount++ + uri = dataSpec.uri + position = dataSpec.position.toInt() + limit = if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + payload.size + } else { + minOf(payload.size, position + dataSpec.length.toInt()) + } + return (limit - position).toLong() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (position >= limit) return C.RESULT_END_OF_INPUT + val count = minOf(length, limit - position) + payload.copyInto(buffer, offset, position, position + count) + position += count + return count + } + + override fun getUri(): Uri? = uri + + override fun getResponseHeaders(): Map> = + mapOf("ETag" to listOf("\"subtitle\"")) + + override fun close() { + closeCount++ + uri = null + } + } +} + +private fun DataSource.readAll(): ByteArray { + val chunks = ArrayList() + val buffer = ByteArray(7) + while (true) { + val read = read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) break + repeat(read) { chunks += buffer[it] } + } + return chunks.toByteArray() +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/StyledSubtitleBurnInTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/StyledSubtitleBurnInTest.kt deleted file mode 100644 index 9894605f8..000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/StyledSubtitleBurnInTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.prairieserver.prairie.common.player - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class StyledSubtitleBurnInTest { - - @Test - fun burnsStyledTrackDuringFullTranscode() { - assertTrue( - shouldBurnStyledSubtitle( - isRemux = false, - subtitleTrackIndex = 0, - subtitleCodec = "ass", - ), - ) - assertTrue( - shouldBurnStyledSubtitle( - isRemux = false, - subtitleTrackIndex = 1, - subtitleCodec = "SSA", - ), - ) - } - - @Test - fun neverBurnsWhenFidelityOrTogglingWouldRegress() { - // Plain text tracks stay client-rendered so toggling needs no restart. - assertFalse(shouldBurnStyledSubtitle(false, 0, "subrip")) - // Remux has no video encode to burn into. - assertFalse(shouldBurnStyledSubtitle(true, 0, "ass")) - // No subtitle selected. - assertFalse(shouldBurnStyledSubtitle(false, null, "ass")) - assertFalse(shouldBurnStyledSubtitle(false, 0, null)) - } -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleBitmapCueAppearanceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleBitmapCueAppearanceTest.kt new file mode 100644 index 000000000..28dc335a0 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleBitmapCueAppearanceTest.kt @@ -0,0 +1,444 @@ +package org.prairieserver.prairie.common.player + +import android.graphics.Bitmap +import androidx.annotation.OptIn +import androidx.media3.common.text.Cue +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.UnstableApi +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.prairieserver.prairie.model.settings.SubtitleAppearance +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import org.prairieserver.prairie.model.settings.SubtitlePositionPreset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Media3's `SubtitlePainter` positions and sizes a bitmap cue from the cue's own + * fields only — `setStyle`, `setFixedTextSize` and `setBottomPaddingFraction` are + * read by the text branch alone. These lock the geometry rewrite that makes the + * Position and Size presets reach PGS/DVB captions. + */ +@OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +class SubtitleBitmapCueAppearanceTest { + + /** A PGS-shaped cue: left/top fractions, START anchors, LINE_TYPE_FRACTION. */ + private fun pgsCue( + position: Float = 0.2f, + line: Float = 0.8f, + size: Float = 0.6f, + bitmapHeight: Float = 0.1f, + positionAnchor: Int = Cue.ANCHOR_TYPE_START, + lineAnchor: Int = Cue.ANCHOR_TYPE_START, + lineType: Int = Cue.LINE_TYPE_FRACTION, + ): Cue = Cue.Builder() + .setBitmap(Bitmap.createBitmap(64, 16, Bitmap.Config.ARGB_8888)) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .setLine(line, lineType) + .setLineAnchor(lineAnchor) + .setSize(size) + .setBitmapHeight(bitmapHeight) + .build() + + private fun appearance( + position: SubtitlePositionPreset = SubtitlePositionPreset.Bottom, + fontSize: SubtitleFontSizePreset = SubtitleFontSizePreset.Medium, + ) = SubtitleAppearance(position = position, fontSize = fontSize) + + @Test + fun bottomPresetPutsTheCuesBottomEdgeAtTheTextPathsPadding() { + val remapped = remapBitmapCue( + cue = pgsCue(line = 0.5f, bitmapHeight = 0.1f), + appearance = appearance(position = SubtitlePositionPreset.Bottom), + titleSafeFraction = 0f, + ) + + // Bottom padding 0.06 (no title-safe inset) => bottom edge at 0.94, top (START anchor) at 0.84. + assertEquals(0.84f, remapped.line, absoluteTolerance = 1e-4f) + assertEquals(Cue.LINE_TYPE_FRACTION, remapped.lineType) + } + + @Test + fun positionPresetsMoveTheCueMonotonicallyUpTheScreen() { + val cue = pgsCue(bitmapHeight = 0.1f) + val bottom = remapBitmapCue(cue, appearance(SubtitlePositionPreset.Bottom), 0f).line + val lowerThird = remapBitmapCue(cue, appearance(SubtitlePositionPreset.LowerThird), 0f).line + val top = remapBitmapCue(cue, appearance(SubtitlePositionPreset.Top), 0f).line + + assertEquals(0.84f, bottom, absoluteTolerance = 1e-4f) + assertEquals(0.72f, lowerThird, absoluteTolerance = 1e-4f) + // Top is anchored from the top, not derived from a bottom padding. + assertEquals(SUBTITLE_TOP_LINE_FRACTION, top, absoluteTolerance = 1e-4f) + assertTrue(top < lowerThird && lowerThird < bottom) + } + + @Test + fun titleSafeInsetIsCompensatedExactlyAsForText() { + val remapped = remapBitmapCue( + cue = pgsCue(bitmapHeight = 0.1f), + appearance = appearance(SubtitlePositionPreset.Bottom), + titleSafeFraction = 0.05f, + ) + + // Physical 6% inside a 5% title-safe inset: (0.06 - 0.05) / 0.90. + val padding = (0.06f - 0.05f) / 0.90f + assertEquals(1f - padding - 0.1f, remapped.line, absoluteTolerance = 1e-4f) + } + + /** + * The screen-anchored Bottom preset: on a 2.39:1 title the canvas reaches + * from the title-safe line down into the letterbox bar (902px of a 1080 + * player), so the fraction that puts the caption 6% above the SCREEN bottom + * is 64.8/902, not the picture-relative one. The bitmap path has to take the + * same fraction the text path is given or the two kinds of cue split apart. + */ + @Test + fun bottomBitmapCuesTakeTheCanvasFractionTheTextPathIsGiven() { + val canvasPadding = 64.8f / 902f + val remapped = remapBitmapCue( + cue = pgsCue(bitmapHeight = 0.1f), + appearance = appearance(SubtitlePositionPreset.Bottom), + titleSafeFraction = 0.05f, + bottomPaddingFraction = canvasPadding, + ) + + assertEquals(1f - canvasPadding - 0.1f, remapped.line, absoluteTolerance = 1e-4f) + // 40 (canvas top in the frame) + 138 (frame top) + 902 * (bottom edge) + // = 1015.2 on a 1080 screen: 6% up, exactly where the text lands. + val bottomEdgeOnScreen = 138f + 40f + 902f * (remapped.line + remapped.bitmapHeight) + assertEquals(1015.2f, bottomEdgeOnScreen, absoluteTolerance = 0.5f) + } + + @Test + fun lowerThirdBitmapCuesStayPictureAnchoredOnTheSameLetterboxedFrame() { + // Lower Third's canvas is the picture (1728x723 at 96,40 in a frame + // whose own top is 138), so the fraction it is handed is the + // picture-relative one and nothing about the bar reaches it. + val fraction = subtitleBottomPaddingFractionForCanvas( + position = SubtitlePositionPreset.LowerThird, + titleSafeFraction = 0.05f, + canvasHeight = 723, + canvasBottomInPlayerSpace = 138 + 40 + 723, + playerHeight = 1080, + ) + val remapped = remapBitmapCue( + cue = pgsCue(bitmapHeight = 0.1f), + appearance = appearance(SubtitlePositionPreset.LowerThird), + titleSafeFraction = 0.05f, + bottomPaddingFraction = fraction, + ) + + assertEquals((0.18f - 0.05f) / 0.90f, fraction, absoluteTolerance = 1e-4f) + assertEquals(1f - fraction - 0.1f, remapped.line, absoluteTolerance = 1e-4f) + } + + @Test + fun topBitmapCuesIgnoreTheBottomCanvasFractionEntirely() { + val remapped = remapBitmapCue( + cue = pgsCue(bitmapHeight = 0.1f), + appearance = appearance(SubtitlePositionPreset.Top), + titleSafeFraction = 0.05f, + bottomPaddingFraction = 64.8f / 902f, + ) + + assertEquals(SUBTITLE_TOP_LINE_FRACTION, remapped.line, absoluteTolerance = 1e-4f) + } + + @Test + fun sizePresetsScaleWidthAndHeightAroundTheCuesHorizontalCentre() { + val cue = pgsCue(position = 0.2f, size = 0.6f, bitmapHeight = 0.1f) + + val large = remapBitmapCue(cue, appearance(fontSize = SubtitleFontSizePreset.Large), 0f) + assertEquals(0.6f * 1.15f, large.size, absoluteTolerance = 1e-4f) + assertEquals(0.1f * 1.15f, large.bitmapHeight, absoluteTolerance = 1e-4f) + // Centre was 0.5; the wider cue keeps it. + assertEquals(0.5f, large.position + large.size / 2f, absoluteTolerance = 1e-4f) + + val small = remapBitmapCue(cue, appearance(fontSize = SubtitleFontSizePreset.Small), 0f) + assertEquals(0.6f * 0.85f, small.size, absoluteTolerance = 1e-4f) + assertEquals(0.5f, small.position + small.size / 2f, absoluteTolerance = 1e-4f) + } + + @Test + fun sizeLadderIsMonotonicAndMediumIsTheAuthoredSize() { + val scales = SubtitleFontSizePreset.entries.map(::bitmapCueScaleFor) + assertEquals(1f, bitmapCueScaleFor(SubtitleFontSizePreset.Medium)) + assertEquals(scales.sorted(), scales) + } + + @Test + fun mediumSizeLeavesTheAuthoredWidthAlone() { + val remapped = remapBitmapCue( + cue = pgsCue(size = 0.6f, bitmapHeight = 0.1f), + appearance = appearance(fontSize = SubtitleFontSizePreset.Medium), + titleSafeFraction = 0f, + ) + + assertEquals(0.6f, remapped.size, absoluteTolerance = 1e-4f) + assertEquals(0.1f, remapped.bitmapHeight, absoluteTolerance = 1e-4f) + assertEquals(0.2f, remapped.position, absoluteTolerance = 1e-4f) + } + + @Test + fun scalingUpNeverPushesTheCueOffScreen() { + val remapped = remapBitmapCue( + cue = pgsCue(position = 0.05f, size = 0.9f, bitmapHeight = 0.3f), + appearance = appearance(fontSize = SubtitleFontSizePreset.XXLarge), + titleSafeFraction = 0f, + ) + + assertTrue(remapped.size <= 1f, "width ${remapped.size}") + assertTrue(remapped.bitmapHeight <= 1f, "height ${remapped.bitmapHeight}") + assertTrue(remapped.position >= 0f) + assertTrue(remapped.position + remapped.size <= 1.0001f) + assertTrue(remapped.line >= 0f) + assertTrue(remapped.line + remapped.bitmapHeight <= 1.0001f) + } + + @Test + fun aTallCueKeepsTheTopMarginInsteadOfLeavingTheSurface() { + val remapped = remapBitmapCue( + cue = pgsCue(bitmapHeight = 0.95f), + appearance = appearance(SubtitlePositionPreset.Top), + titleSafeFraction = 0f, + ) + + assertEquals(SUBTITLE_TOP_LINE_FRACTION, remapped.line, absoluteTolerance = 1e-4f) + assertTrue(remapped.line + remapped.bitmapHeight <= 1.0001f) + } + + @Test + fun endAndMiddleAnchorsArePreservedAndReExpressed() { + val end = remapBitmapCue( + cue = pgsCue( + position = 0.8f, + size = 0.6f, + bitmapHeight = 0.1f, + positionAnchor = Cue.ANCHOR_TYPE_END, + lineAnchor = Cue.ANCHOR_TYPE_END, + ), + appearance = appearance(fontSize = SubtitleFontSizePreset.Large), + titleSafeFraction = 0f, + ) + assertEquals(Cue.ANCHOR_TYPE_END, end.positionAnchor) + assertEquals(Cue.ANCHOR_TYPE_END, end.lineAnchor) + // Authored span 0.2..0.8 (centre 0.5); END anchor reports the right edge. + assertEquals(0.5f + (0.6f * 1.15f) / 2f, end.position, absoluteTolerance = 1e-4f) + // END line anchor reports the bottom edge, which is 1 - 0.06. + assertEquals(0.94f, end.line, absoluteTolerance = 1e-4f) + + val middle = remapBitmapCue( + cue = pgsCue( + position = 0.5f, + size = 0.6f, + bitmapHeight = 0.1f, + positionAnchor = Cue.ANCHOR_TYPE_MIDDLE, + lineAnchor = Cue.ANCHOR_TYPE_MIDDLE, + ), + appearance = appearance(), + titleSafeFraction = 0f, + ) + assertEquals(0.5f, middle.position, absoluteTolerance = 1e-4f) + assertEquals(0.94f - 0.05f, middle.line, absoluteTolerance = 1e-4f) + } + + @Test + fun anUnsetLineTypeAndAnchorAreTreatedAsAStartFraction() { + val cue = Cue.Builder() + .setBitmap(Bitmap.createBitmap(64, 16, Bitmap.Config.ARGB_8888)) + .setPosition(0.2f) + .setSize(0.6f) + .setBitmapHeight(0.1f) + .build() + assertEquals(Cue.TYPE_UNSET, cue.lineType) + + val remapped = remapBitmapCue(cue, appearance(), titleSafeFraction = 0f) + + assertEquals(Cue.LINE_TYPE_FRACTION, remapped.lineType) + assertEquals(0.84f, remapped.line, absoluteTolerance = 1e-4f) + assertEquals(0.2f, remapped.position, absoluteTolerance = 1e-4f) + } + + @Test + fun aCueWithoutUsableGeometryIsLeftExactlyAsAuthored() { + val noHeight = Cue.Builder() + .setBitmap(Bitmap.createBitmap(64, 16, Bitmap.Config.ARGB_8888)) + .setPosition(0.2f) + .setLine(0.8f, Cue.LINE_TYPE_FRACTION) + .setSize(0.6f) + .build() + assertSame(noHeight, remapBitmapCue(noHeight, appearance(), 0f)) + + val noSize = Cue.Builder() + .setBitmap(Bitmap.createBitmap(64, 16, Bitmap.Config.ARGB_8888)) + .setPosition(0.2f) + .setLine(0.8f, Cue.LINE_TYPE_FRACTION) + .setBitmapHeight(0.1f) + .build() + assertSame(noSize, remapBitmapCue(noSize, appearance(), 0f)) + + val noPosition = Cue.Builder() + .setBitmap(Bitmap.createBitmap(64, 16, Bitmap.Config.ARGB_8888)) + .setLine(0.8f, Cue.LINE_TYPE_FRACTION) + .setSize(0.6f) + .setBitmapHeight(0.1f) + .build() + assertSame(noPosition, remapBitmapCue(noPosition, appearance(), 0f)) + } + + @Test + fun textCuesAreNeverTouched() { + val text = Cue.Builder() + .setText("Hello") + .setPosition(0.5f) + .setLine(0.9f, Cue.LINE_TYPE_FRACTION) + .setSize(1f) + .build() + + assertSame( + text, + remapBitmapCue( + text, + appearance(SubtitlePositionPreset.Top, SubtitleFontSizePreset.XXLarge), + 0f, + ), + ) + } + + @Test + fun anAlreadyCorrectCueIsReturnedByIdentitySoThePainterKeepsItsCache() { + val once = remapBitmapCue(pgsCue(), appearance(), 0f) + assertSame(once, remapBitmapCue(once, appearance(), 0f)) + } + + @Test + fun theGroupWrapperRemapsBitmapCuesAndPreservesThePresentationTime() { + val group = CueGroup( + listOf(pgsCue(), Cue.Builder().setText("Hello").build()), + /* presentationTimeUs= */ 1_234L, + ) + + val remapped = remapBitmapCues(group, appearance(SubtitlePositionPreset.Top), 0f) + + assertEquals(1_234L, remapped.presentationTimeUs) + assertNotEquals(group.cues[0].line, remapped.cues[0].line) + assertSame(group.cues[1], remapped.cues[1]) + } + + @Test + fun aGroupWithNothingToChangeIsReturnedByIdentity() { + val group = CueGroup(listOf(Cue.Builder().setText("Hello").build()), 0L) + assertSame(group, remapBitmapCues(group, appearance(), 0f)) + } + + /** + * The text counterpart: `bottomPaddingFraction` — how the Position preset is + * applied to text — is read only when the cue carries no line of its own, so + * the parser's default placement has to be cleared or the preset is a no-op. + */ + @Test + fun theParserDefaultPlacementIsClearedSoThePositionPresetApplies() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(-1f, Cue.LINE_TYPE_NUMBER) + .build() + + val remapped = remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Bottom) + + assertEquals(Cue.DIMEN_UNSET, remapped.line) + assertEquals(Cue.TYPE_UNSET, remapped.lineType) + assertEquals("Hello", remapped.text.toString()) + } + + @Test + fun theTopPresetGivesTextCuesATopAnchoredLine() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(-1f, Cue.LINE_TYPE_NUMBER) + .build() + + val remapped = remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Top) + + assertEquals(SUBTITLE_TOP_LINE_FRACTION, remapped.line, absoluteTolerance = 1e-4f) + assertEquals(Cue.LINE_TYPE_FRACTION, remapped.lineType) + assertEquals(Cue.ANCHOR_TYPE_START, remapped.lineAnchor) + } + + @Test + fun theTopPresetStillLeavesAnAuthoredPlacementAlone() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(0.4f, Cue.LINE_TYPE_FRACTION) + .build() + + assertSame(cue, remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Top)) + } + + @Test + fun anAuthoredFractionPlacementIsLeftAlone() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(0.1f, Cue.LINE_TYPE_FRACTION) + .build() + + assertSame(cue, remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Bottom)) + } + + @Test + fun anAuthoredLineNumberIsLeftAlone() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(2f, Cue.LINE_TYPE_NUMBER) + .build() + + assertSame(cue, remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Bottom)) + } + + @Test + fun aDefaultLineWithItsOwnAnchorIsLeftAlone() { + val cue = Cue.Builder() + .setText("Hello") + .setLine(-1f, Cue.LINE_TYPE_NUMBER) + .setLineAnchor(Cue.ANCHOR_TYPE_END) + .build() + + assertSame(cue, remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Bottom)) + } + + @Test + fun bitmapCuesKeepTheirPlacement() { + val cue = pgsCue(line = -1f, lineType = Cue.LINE_TYPE_NUMBER) + + assertSame(cue, remapDefaultTextCuePlacement(cue, SubtitlePositionPreset.Bottom)) + } + + @Test + fun theTextPlacementGroupWrapperPreservesTimeAndUntouchedCues() { + val authored = Cue.Builder().setText("A").setLine(0.2f, Cue.LINE_TYPE_FRACTION).build() + val group = CueGroup( + listOf(Cue.Builder().setText("B").setLine(-1f, Cue.LINE_TYPE_NUMBER).build(), authored), + /* presentationTimeUs= */ 99L, + ) + + val remapped = remapDefaultTextCuePlacements(group, SubtitlePositionPreset.Bottom) + + assertEquals(99L, remapped.presentationTimeUs) + assertEquals(Cue.DIMEN_UNSET, remapped.cues[0].line) + assertSame(authored, remapped.cues[1]) + } + + @Test + fun aTextPlacementGroupWithNothingToChangeIsReturnedByIdentity() { + val group = CueGroup( + listOf(Cue.Builder().setText("A").setLine(0.2f, Cue.LINE_TYPE_FRACTION).build()), + 0L, + ) + + assertSame(group, remapDefaultTextCuePlacements(group, SubtitlePositionPreset.Bottom)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt index 78623ba4b..89175ecb2 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt @@ -1,19 +1,30 @@ package org.prairieserver.prairie.common.player +import android.app.Activity +import android.os.Looper +import android.view.View +import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.PlayerView import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset -import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(UnstableApi::class) @RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1920dp-h1080dp-mdpi") class SubtitleManagerAppearanceTest { @Test @@ -28,37 +39,151 @@ class SubtitleManagerAppearanceTest { } @Test - fun subtitleTextFractionsMatchTheWebScale() { + fun bottomSubtitlesUseTheReferenceSafeMargin() { val method = SubtitleManager::class.java.getDeclaredMethod( - "fractionalSizeFor", - SubtitleFontSizePreset::class.java, + "bottomPaddingFor", + SubtitlePositionPreset::class.java, ) method.isAccessible = true - assertEquals(20f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Small) as Float) - assertEquals(26f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Medium) as Float) - assertEquals(32f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.Large) as Float) - assertEquals(40f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XLarge) as Float) - assertEquals(48f / 720f, method.invoke(SubtitleManager(), SubtitleFontSizePreset.XXLarge) as Float) + // No title-safe inset (phone): the physical 6% applies raw. + assertEquals( + 0.06f, + method.invoke( + SubtitleManager(), + SubtitlePositionPreset.Bottom, + ) as Float, + ) } @Test - fun bottomSubtitlesUseTheReferenceSafeMargin() { + fun titleSafeCompensationKeepsTheBottomAnchoredPresetsInPlace() { + val manager = SubtitleManager().apply { + titleSafeFraction = 0.05f + } val method = SubtitleManager::class.java.getDeclaredMethod( "bottomPaddingFor", - org.prairieserver.prairie.model.settings.SubtitlePositionPreset::class.java, + SubtitlePositionPreset::class.java, ) method.isAccessible = true + // The padding fraction is evaluated inside a surface scaled to 90% of + // the original video height. Preserve the original physical presets: + // f + p(1 - 2f) = base, so p = (base - f) / (1 - 2f). + // Top is top-anchored (SUBTITLE_TOP_LINE_FRACTION) and reads no padding. + // Bottom is a physical 6%: one percent of it lies inside the inset. assertEquals( - 0.09f, - method.invoke( - SubtitleManager(), - org.prairieserver.prairie.model.settings.SubtitlePositionPreset.Bottom, - ) as Float, + expected = (0.06f - 0.05f) / 0.90f, + actual = method.invoke(manager, SubtitlePositionPreset.Bottom) as Float, + absoluteTolerance = 0.0001f, + ) + assertEquals( + expected = (0.18f - 0.05f) / 0.90f, + actual = method.invoke(manager, SubtitlePositionPreset.LowerThird) as Float, + absoluteTolerance = 0.0001f, + ) + } + + /** + * The Shield measurement this change answers: a 2.39:1 title in a 1920x1080 + * PlayerView leaves a 1920x803 content frame at y=138 and a 1728x723 canvas + * at 96,40 inside it. Bottom must reach the screen, not stop at the picture. + */ + @Test + fun bottomCanvasDropsIntoTheLetterboxBar() { + val picture = SubtitleVideoRect(left = 96, top = 40, width = 1728, height = 723) + + val canvas = subtitleCanvasRectFor( + position = SubtitlePositionPreset.Bottom, + pictureRect = picture, + playerBottomInParentSpace = 1080 - 138, + ) + + assertEquals(SubtitleVideoRect(left = 96, top = 40, width = 1728, height = 902), canvas) + // Frame origin 138 + canvas top 40 + height 902 = 1080, the screen edge. + assertEquals(1080, 138 + canvas.top + canvas.height) + } + + @Test + fun lowerThirdAndTopCanvasesStayOnThePicture() { + val picture = SubtitleVideoRect(left = 96, top = 40, width = 1728, height = 723) + + assertEquals( + picture, + subtitleCanvasRectFor( + position = SubtitlePositionPreset.LowerThird, + pictureRect = picture, + playerBottomInParentSpace = 1080 - 138, + ), + ) + assertEquals( + picture, + subtitleCanvasRectFor( + position = SubtitlePositionPreset.Top, + pictureRect = picture, + playerBottomInParentSpace = 1080 - 138, + ), ) } + @Test + fun bottomCanvasNeverShrinksOrReachesPastThePlayerView() { + // Zoom: the picture already covers the screen, so there is no bar to + // drop into and nothing to extend. + val fullScreen = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + + assertEquals( + fullScreen, + subtitleCanvasRectFor( + position = SubtitlePositionPreset.Bottom, + pictureRect = fullScreen, + playerBottomInParentSpace = 1080, + ), + ) + } + + /** + * The whole point, stated as the number the owner reads off a screenshot: + * Bottom sits 6% of the SCREEN above the screen's bottom whatever the + * picture is doing. 16:9 is unchanged from the picture-anchored behaviour + * because there the picture IS the screen. + */ + @Test + fun bottomLandsSixPercentAboveTheScreenOnEveryAspect() { + val expected = 0.06f * 1080f + + assertEquals( + expected, + bottomCaptionGapAboveScreenBottom(frameTop = 0, frameHeight = 1080), + absoluteTolerance = 1f, + ) + assertEquals( + expected, + bottomCaptionGapAboveScreenBottom(frameTop = 138, frameHeight = 803), + absoluteTolerance = 1f, + ) + // Encoded bars inside a 16:9 frame: the detected letterbox insets the + // picture, and Bottom drops back into that bar the same way. + assertEquals( + expected, + bottomCaptionGapAboveScreenBottom( + frameTop = 0, + frameHeight = 1080, + letterbox = LetterboxInsets(0.1278f, 0.1287f), + ), + absoluteTolerance = 1f, + ) + } + + @Test + fun lowerThirdKeepsItsPictureRelativeDistanceOnALetterboxedFrame() { + // 18% of the 803-px picture above the picture's bottom edge, which is + // 138 + 803 = 941 on screen: unchanged by the Bottom preset's work. + val gap = lowerThirdCaptionGapAbovePictureBottom(frameTop = 138, frameHeight = 803) + + assertEquals(0.18f * 803f, gap, absoluteTolerance = 1f) + } + @Test fun boxBackgroundStyleAppliesConfiguredBackgroundAlpha() { val style = captionStyleFor( @@ -168,6 +293,147 @@ class SubtitleManagerAppearanceTest { assertEquals(SubtitleVideoRect(left = 0, top = 0, width = 1080, height = 2400), fill) } + @Test + fun zoomAnchorsToTheContentFrameEvenMidResize() { + // The frame is momentarily still the fitted one, because the resize + // mode changed and layout has not run again yet. Anchoring to it keeps + // the captions on the video that is actually rendered at this instant; + // the view-space viewport would be applied at frame-relative margins + // and shift them by the frame's offset. + val staleFit = SubtitleVideoRect(left = 0, top = 236, width = 2404, height = 1352) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2404, height = 1080) + + assertEquals( + staleFit, + selectSubtitleCanvasRect( + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun captionsStayCentredOnAnOffsetContentFrame() { + // The regression: a 1920x1080 title expanded on a 3120x1440 display + // leaves the PlayerView 2814 wide, and mid-resize the frame is the + // 2560-wide fitted rect inset 127px inside it. Anchoring to the frame + // centres the captions on the video; the 2814-wide view-space viewport + // used to be applied at frame margin 0 and pushed them 127px right. + val offsetFrame = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = 2814, + viewHeight = 1440, + frameLeft = 127, + frameTop = 0, + frameWidth = 2560, + frameHeight = 1440, + ), + ) + val viewportSpaceRect = SubtitleVideoRect(left = 0, top = 0, width = 2814, height = 1440) + val canvas = selectSubtitleCanvasRect( + contentFrameRect = offsetFrame, + displayedVideoRect = viewportSpaceRect, + ) + + assertEquals(SubtitleVideoRect(left = 0, top = 0, width = 2560, height = 1440), canvas) + // Frame origin 127 + canvas centre 1280 = 1407, the centre of the 2814 + // view. The old answer centred at 1407 + 127 = 1534. + assertEquals(1407, 127 + canvas.left + canvas.width / 2) + } + + @Test + fun captionsNeverExtendBelowTheVisibleBottom() { + // Expansion overhangs the view by design: a 2814x1583 content frame in + // a 2814x1440 view hangs ~71px off each edge. The canvas must be the + // intersection, or a bottom-anchored caption is drawn off-screen. + val visible = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = 2814, + viewHeight = 1440, + frameLeft = 0, + frameTop = -71, + frameWidth = 2814, + frameHeight = 1583, + ), + ) + val canvas = selectSubtitleCanvasRect( + contentFrameRect = visible, + displayedVideoRect = SubtitleVideoRect(0, 0, 2814, 1440), + ) + + assertEquals(SubtitleVideoRect(left = 0, top = 71, width = 2814, height = 1440), canvas) + // Frame origin -71 + canvas top 71 = 0, and its bottom lands on 1440. + assertEquals(0, -71 + canvas.top) + assertEquals(1440, -71 + canvas.top + canvas.height) + } + + @Test + fun zoomUsesVisibleViewportInNegativeContentFrameParentCoordinates() { + val visibleCanvas = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = 1920, + viewHeight = 1080, + frameLeft = -120, + frameTop = -64, + frameWidth = 2160, + frameHeight = 1208, + ), + ) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + + assertEquals( + SubtitleVideoRect(left = 120, top = 64, width = 1920, height = 1080), + selectSubtitleCanvasRect( + contentFrameRect = visibleCanvas, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun stretchAnchorsToTheContentFrameEvenMidResize() { + val staleFit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + assertEquals( + staleFit, + selectSubtitleCanvasRect( + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) + } + + @Test + fun fitContinuesToUsePostLayoutContentFrame() { + val fittedFrame = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + val computedFallback = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + + assertEquals( + fittedFrame, + selectSubtitleCanvasRect( + contentFrameRect = fittedFrame, + displayedVideoRect = computedFallback, + ), + ) + } + + @Test + fun repeatedModeSelectionDoesNotRetainPreviousCanvas() { + val fit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val full = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + val fill = selectSubtitleCanvasRect(fit, full) + val stretch = selectSubtitleCanvasRect(fit, full) + val restoredFit = selectSubtitleCanvasRect(fit, fit) + + // Every mode anchors to the same place now: the content frame is the + // subtitle layer's parent whatever the video is being scaled to. + assertEquals(fit, fill) + assertEquals(fit, stretch) + assertEquals(fit, restoredFit) + } + @Test fun invalidVideoSizeUsesFullViewRect() { val rect = displayedSubtitleVideoRect( @@ -224,6 +490,357 @@ class SubtitleManagerAppearanceTest { ) } + @Test + fun mountedCanvasReconcilesFitToZoomAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasReconcilesFitToFillAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + frame = FrameBounds(0, 0, 1920, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasDoesNotRetainOffsetsAcrossRepeatedZoomAndFillSwitches() { + val canvas = MountedSubtitleCanvas() + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + frame = FrameBounds(0, 0, 1920, 1016), + ) + assertEquals(SubtitleVideoRect(0, 0, 1920, 1016), canvas.subtitleRect()) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + assertEquals(SubtitleVideoRect(120, 64, 1920, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasReconcilesZoomBackToFitAfterContentFrameLayout() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + frame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + } + + @Test + fun mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transitionAfterEarlyPreDraw( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + finalFrame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + } + + @Test + fun rapidEarlyTransitionsApplyOnlyLatestMode() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FILL) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FIT) + canvas.dispatchEarlyPreDrawThenMount(FrameBounds(240, 0, 1680, 1016)) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + assertEquals(2, canvas.reconciliationCount) + } + + @Test + fun detachCancelsPostedSnapshotVerification() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.dispatchPreDraw() + canvas.detach() + canvas.mountFrameAndDrain(FrameBounds(-120, -64, 2040, 1080)) + + assertEquals(1, canvas.reconciliationCount) + } + + @Test + fun detachedPendingReconciliationLeavesSubtitleLayoutSentinelUnchanged() { + val canvas = MountedSubtitleCanvas() + + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + val sentinel = SubtitleVideoRect(33, 44, 777, 555) + canvas.setSubtitleRect(sentinel) + canvas.detachAndDrain(FrameBounds(-120, -64, 2040, 1080)) + + assertEquals(sentinel, canvas.subtitleRect()) + } + + @Test + fun repeatedExplicitSyncsRunOnePostLayoutReconciliation() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val playerView = PlayerView(activity) + val manager = SubtitleManager() + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + manager.syncSubtitleVideoBounds(playerView) + playerView.viewTreeObserver.dispatchOnPreDraw() + + var reconciliations = 0 + manager.postLayoutReconciliationObserver = { reconciliations++ } + repeat(3) { + manager.syncSubtitleVideoBounds(playerView) + } + + playerView.viewTreeObserver.dispatchOnPreDraw() + playerView.viewTreeObserver.dispatchOnPreDraw() + + assertEquals(1, reconciliations) + } + + /** + * The letterbox regression: the params carry the narrowed frame, the view + * is still laid out at the outgoing 16:9 geometry because the parent + * measured it before the params were written and never comes back, and a + * params-only diff would go quiet forever. The sync has to notice the + * BOUNDS and place the canvas itself. + * + * Stated on a picture-anchored preset, so the geometry under test is the + * frame's alone — Bottom deliberately spans past the frame and is covered + * by [bottomPresetSpansIntoTheBarAndAPositionChangeReplacesTheCanvas]. + */ + @Test + fun canvasLeftLaidOutAtTheOldAspectIsPlacedAtTheLetterboxedFrame() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val playerView = PlayerView(activity) + val contentFrame = requireNotNull( + playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ), + ) + val subtitleView = requireNotNull(playerView.subtitleView) + val manager = SubtitleManager() + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + manager.applyAppearance( + playerView, + SubtitleAppearance.DEFAULT.copy(position = SubtitlePositionPreset.LowerThird), + ) + + manager.syncSubtitleVideoBounds(playerView) + playerView.viewTreeObserver.dispatchOnPreDraw() + Shadows.shadowOf(Looper.getMainLooper()).idle() + // 2.39:1 content inside the view: the frame narrows to 803 tall. The + // aspect ratio is set too so a real traversal re-measures to the same + // geometry instead of springing back to the full parent height. + contentFrame.setAspectRatio(1920f / 803f) + contentFrame.layout(0, 106, 1920, 909) + + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + assertEquals(1920, params.width) + assertEquals(803, params.height) + + // The bounds the canvas keeps when the frame measured it a beat early. + subtitleView.layout(0, 0, 1920, 1016) + + manager.syncSubtitleVideoBounds(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + + assertEquals(803, subtitleView.height) + assertEquals(1920, subtitleView.width) + assertEquals(0, subtitleView.top) + assertEquals(0, subtitleView.left) + } + + /** + * Runs the production placement — content-frame rect, letterbox and + * title-safe insets, the preset's canvas, the preset's bottom padding — and + * reports how far the caption's bottom edge ends up above the PLAYER VIEW's + * bottom, in pixels of a 1920x1080 television screen. + */ + private fun bottomCaptionGapAboveScreenBottom( + frameTop: Int, + frameHeight: Int, + letterbox: LetterboxInsets = LetterboxInsets.NONE, + titleSafeFraction: Float = 0.05f, + playerHeight: Int = 1080, + playerWidth: Int = 1920, + ): Float { + val picture = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = playerWidth, + viewHeight = playerHeight, + frameLeft = 0, + frameTop = frameTop, + frameWidth = playerWidth, + frameHeight = frameHeight, + ), + ).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) + val canvas = subtitleCanvasRectFor( + position = SubtitlePositionPreset.Bottom, + pictureRect = picture, + playerBottomInParentSpace = playerHeight - frameTop, + ) + val padding = subtitleBottomPaddingFractionForCanvas( + position = SubtitlePositionPreset.Bottom, + titleSafeFraction = titleSafeFraction, + canvasHeight = canvas.height, + canvasBottomInPlayerSpace = frameTop + canvas.top + canvas.height, + playerHeight = playerHeight, + ) + val captionBottom = frameTop + canvas.top + canvas.height * (1f - padding) + return playerHeight - captionBottom + } + + /** The same walk for Lower Third, measured against the PICTURE's bottom. */ + private fun lowerThirdCaptionGapAbovePictureBottom( + frameTop: Int, + frameHeight: Int, + titleSafeFraction: Float = 0.05f, + playerHeight: Int = 1080, + playerWidth: Int = 1920, + ): Float { + val picture = requireNotNull( + displayedSubtitleContentFrameRect( + viewWidth = playerWidth, + viewHeight = playerHeight, + frameLeft = 0, + frameTop = frameTop, + frameWidth = playerWidth, + frameHeight = frameHeight, + ), + ).insetByTitleSafe(titleSafeFraction) + val canvas = subtitleCanvasRectFor( + position = SubtitlePositionPreset.LowerThird, + pictureRect = picture, + playerBottomInParentSpace = playerHeight - frameTop, + ) + val padding = subtitleBottomPaddingFractionForCanvas( + position = SubtitlePositionPreset.LowerThird, + titleSafeFraction = titleSafeFraction, + canvasHeight = canvas.height, + canvasBottomInPlayerSpace = frameTop + canvas.top + canvas.height, + playerHeight = playerHeight, + ) + val captionBottom = canvas.top + canvas.height * (1f - padding) + return frameHeight - captionBottom + } + + /** + * End to end on a letterboxed frame: Bottom spans into the bar, the content + * frame stops clipping so the canvas can be drawn there, and a Position + * change re-places the canvas on the spot — no parent layout pass, which is + * the one thing a Compose-hosted PlayerView cannot be relied on to run. + */ + @Test + fun bottomPresetSpansIntoTheBarAndAPositionChangeReplacesTheCanvas() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val playerView = PlayerView(activity) + val contentFrame = requireNotNull( + playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ), + ) + val subtitleView = requireNotNull(playerView.subtitleView) + val manager = SubtitleManager().apply { titleSafeFraction = 0.05f } + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + // 2.39:1 inside the 1920x1016 view: an 803-tall frame at y=106. + contentFrame.setAspectRatio(1920f / 803f) + contentFrame.layout(0, 106, 1920, 909) + + manager.applyAppearance( + playerView, + SubtitleAppearance.DEFAULT.copy(position = SubtitlePositionPreset.Bottom), + ) + + val bottom = subtitleView.layoutParams as FrameLayout.LayoutParams + assertEquals(1728, bottom.width) + assertEquals(96, bottom.leftMargin) + assertEquals(40, bottom.topMargin) + // The title-safe canvas is 723 tall; 870 carries it 147px past the + // picture, to 106 + 40 + 870 = 1016 — the player view's own bottom. + assertEquals(870, bottom.height) + assertEquals(1016, 106 + bottom.topMargin + bottom.height) + assertFalse(contentFrame.clipChildren) + + manager.applyAppearance( + playerView, + SubtitleAppearance.DEFAULT.copy(position = SubtitlePositionPreset.LowerThird), + ) + + val lowerThird = subtitleView.layoutParams as FrameLayout.LayoutParams + assertEquals(723, lowerThird.height) + assertEquals(40, lowerThird.topMargin) + assertTrue(contentFrame.clipChildren) + } + + /** 16:9: the picture already is the screen, so nothing about it moves. */ + @Test + fun bottomPresetLeavesTheFullScreenPictureCanvasAlone() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val playerView = PlayerView(activity) + val contentFrame = requireNotNull( + playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ), + ) + val subtitleView = requireNotNull(playerView.subtitleView) + val manager = SubtitleManager().apply { titleSafeFraction = 0.05f } + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + contentFrame.setAspectRatio(1920f / 1016f) + contentFrame.layout(0, 0, 1920, 1016) + + manager.applyAppearance( + playerView, + SubtitleAppearance.DEFAULT.copy(position = SubtitlePositionPreset.Bottom), + ) + + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + // Title-safe puts the canvas at 51,96 with 1728x914; the extension only + // reclaims the bottom inset, which the padding then gives straight back. + assertEquals(96, params.leftMargin) + assertEquals(51, params.topMargin) + assertEquals(1016 - 51, params.height) + // Still inside the frame, so the frame keeps clipping its children. + assertTrue(contentFrame.clipChildren) + } + private fun captionStyleFor(appearance: SubtitleAppearance): CaptionStyleCompat { val method = SubtitleManager::class.java.getDeclaredMethod( "buildCaptionStyle", @@ -233,3 +850,145 @@ class SubtitleManagerAppearanceTest { return method.invoke(SubtitleManager(), appearance) as CaptionStyleCompat } } + +private fun SubtitleManager.subtitleRectSyncForTest(playerView: PlayerView): Any { + val field = SubtitleManager::class.java.getDeclaredField("videoRectSyncs") + field.isAccessible = true + val syncs = field.get(this) as Map<*, *> + return requireNotNull(syncs[playerView]) +} + +private data class FrameBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) + +private class MountedSubtitleCanvas { + private val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private val playerView = PlayerView(activity) + private val contentFrame = requireNotNull( + playerView.findViewById( + androidx.media3.ui.R.id.exo_content_frame, + ), + ) + private val subtitleView = requireNotNull(playerView.subtitleView) + private val manager = SubtitleManager() + var reconciliationCount = 0 + private set + + init { + activity.setContentView(playerView) + Shadows.shadowOf(Looper.getMainLooper()).idle() + check(playerView.width == 1920 && playerView.height == 1016) + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + manager.syncSubtitleVideoBounds(playerView) + drainScheduledWork() + + // Isolate the explicit post-layout reconciliation from the permanent + // layout listeners: production has both, but this harness proves the + // bounded fallback still works when an early callback runs before the + // content-frame traversal that supplies the final geometry. + val syncListener = + manager.subtitleRectSyncForTest(playerView) as View.OnLayoutChangeListener + playerView.removeOnLayoutChangeListener(syncListener) + contentFrame.removeOnLayoutChangeListener(syncListener) + mountFrame(FrameBounds(240, 0, 1680, 1016)) + manager.syncSubtitleVideoBounds(playerView) + manager.postLayoutReconciliationObserver = { reconciliationCount++ } + } + + /** + * Mounts an observed Media3 content frame. The aspect ratio is set as well + * as the bounds so a real Robolectric traversal re-measures to the SAME + * geometry — without it the frame springs back to the full parent width and + * the scenario under test evaporates. + */ + private fun mountFrame(frame: FrameBounds) { + val width = frame.right - frame.left + val height = frame.bottom - frame.top + contentFrame.setAspectRatio(width.toFloat() / height.toFloat()) + contentFrame.layout(frame.left, frame.top, frame.right, frame.bottom) + } + + fun schedule(resizeMode: Int) { + playerView.resizeMode = resizeMode + manager.syncSubtitleVideoBounds(playerView) + } + + fun transition(resizeMode: Int, frame: FrameBounds) { + schedule(resizeMode) + mountFrame(frame) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun transitionAfterEarlyPreDraw(resizeMode: Int, finalFrame: FrameBounds) { + schedule(resizeMode) + playerView.viewTreeObserver.dispatchOnPreDraw() + mountFrame(finalFrame) + Shadows.shadowOf(Looper.getMainLooper()).idle() + // Robolectric's parent traversal has no renderer-backed aspect ratio, + // so re-mount the observed Media3 frame before the corrective pre-draw. + mountFrame(finalFrame) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun dispatchEarlyPreDrawThenMount(finalFrame: FrameBounds) { + mountFrame(FrameBounds(-120, -64, 2040, 1080)) + playerView.viewTreeObserver.dispatchOnPreDraw() + mountFrame(finalFrame) + Shadows.shadowOf(Looper.getMainLooper()).idle() + // Keep the synthetic final frame mounted after Robolectric drains the + // posted verifier and its unrelated full-width parent traversal. + mountFrame(finalFrame) + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun dispatchPreDraw() { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + + fun detach() { + activity.setContentView(FrameLayout(activity)) + } + + fun mountFrameAndDrain(frame: FrameBounds) { + mountFrame(frame) + Shadows.shadowOf(Looper.getMainLooper()).idle() + if (playerView.viewTreeObserver.isAlive) { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + } + + fun detachAndDrain(frame: FrameBounds) { + detach() + mountFrameAndDrain(frame) + } + + fun subtitleRect(): SubtitleVideoRect { + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + return SubtitleVideoRect( + left = params.leftMargin, + top = params.topMargin, + width = params.width, + height = params.height, + ) + } + + fun setSubtitleRect(rect: SubtitleVideoRect) { + val params = subtitleView.layoutParams as FrameLayout.LayoutParams + params.leftMargin = rect.left + params.topMargin = rect.top + params.width = rect.width + params.height = rect.height + subtitleView.layoutParams = params + } + + private fun drainScheduledWork() { + if (playerView.viewTreeObserver.isAlive) { + playerView.viewTreeObserver.dispatchOnPreDraw() + } + Shadows.shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt index 03ef48910..882fb58c4 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerTrackSelectionTest.kt @@ -10,6 +10,7 @@ import androidx.media3.common.util.UnstableApi import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -72,7 +73,7 @@ class SubtitleManagerTrackSelectionTest { label = "English", language = "en", sampleMimeType = MimeTypes.TEXT_VTT, - id = "prairie-subtitle:7", + id = "silo-subtitle:7", ), ) val tracks = Tracks( @@ -110,11 +111,11 @@ class SubtitleManagerTrackSelectionTest { PlayerSubtitleInfo(3, "en", "webvtt", "Server subtitle", "server_artifact", true, "/3.vtt"), PlayerSubtitleInfo(4, "en", "webvtt", "Server subtitle", "server_artifact", false, "/4.vtt"), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ) assertEquals( - listOf("prairie-subtitle:3", "prairie-subtitle:4"), + listOf("silo-subtitle:3", "silo-subtitle:4"), configurations.map { it.id }, ) } @@ -157,15 +158,15 @@ class SubtitleManagerTrackSelectionTest { downloadId = 314, ), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ) assertEquals( listOf( - "prairie-subtitle:3", - "prairie-downloaded-subtitle:312", - "prairie-downloaded-subtitle:313", - "prairie-downloaded-subtitle:314", + "silo-subtitle:3", + "silo-downloaded-subtitle:312", + "silo-downloaded-subtitle:313", + "silo-downloaded-subtitle:314", ), configurations.map { it.id }, ) @@ -187,12 +188,12 @@ class SubtitleManagerTrackSelectionTest { downloadId = 312, ), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ).single().id - assertEquals("prairie-downloaded-subtitle:312", mountedId(index = 1)) - assertEquals("prairie-downloaded-subtitle:312", mountedId(index = 2)) - assertEquals("prairie-downloaded-subtitle:312", mountedId(index = 8)) + assertEquals("silo-downloaded-subtitle:312", mountedId(index = 1)) + assertEquals("silo-downloaded-subtitle:312", mountedId(index = 2)) + assertEquals("silo-downloaded-subtitle:312", mountedId(index = 8)) } @Test @@ -209,7 +210,7 @@ class SubtitleManagerTrackSelectionTest { url = "/4.vtt", ), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ).single() assertNull(configuration.id) @@ -255,10 +256,10 @@ class SubtitleManagerTrackSelectionTest { @Test fun mobileSelectionUsesDownloadedStableIdAcrossDuplicateLabels() { val server = TrackGroup( - subtitle("English", "en", id = "prairie-subtitle:3"), + subtitle("English", "en", id = "silo-subtitle:3"), ) val downloaded = TrackGroup( - subtitle("English", "en", id = "prairie-downloaded-subtitle:312"), + subtitle("English", "en", id = "silo-downloaded-subtitle:312"), ) val tracks = Tracks( listOf( @@ -289,10 +290,10 @@ class SubtitleManagerTrackSelectionTest { @Test fun mobileMetadataSelectionUsesStableIdAcrossDuplicateRuntimeLabels() { val forced = TrackGroup( - subtitle("Server subtitle", "en", id = "prairie-subtitle:3", forced = true), + subtitle("Server subtitle", "en", id = "silo-subtitle:3", forced = true), ) val full = TrackGroup( - subtitle("Server subtitle", "en", id = "prairie-subtitle:4", forced = false), + subtitle("Server subtitle", "en", id = "silo-subtitle:4", forced = false), ) val tracks = Tracks( listOf( @@ -313,16 +314,16 @@ class SubtitleManagerTrackSelectionTest { @Test fun relativeServerSubtitleUrlsResolveThroughApiStreamMount() { assertEquals( - "https://prairie.example/api/v1/stream/session-1/subtitles/0.srt", - resolveSubtitleUrl("https://prairie.example", "/stream/session-1/subtitles/0.srt"), + "https://silo.example/api/v1/stream/session-1/subtitles/0.srt", + resolveSubtitleUrl("https://silo.example", "/stream/session-1/subtitles/0.srt"), ) } @Test fun apiRelativeStreamUrlsAreNotDoublePrefixed() { assertEquals( - "https://prairie.example/api/v1/stream/session-1/subtitles/0.srt", - resolveSubtitleUrl("https://prairie.example", "/api/v1/stream/session-1/subtitles/0.srt"), + "https://silo.example/api/v1/stream/session-1/subtitles/0.srt", + resolveSubtitleUrl("https://silo.example", "/api/v1/stream/session-1/subtitles/0.srt"), ) } @@ -485,7 +486,7 @@ class SubtitleManagerTrackSelectionTest { url = "/stream/session-1/subtitles/0.vtt", ) ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ).single() assertEquals(MimeTypes.TEXT_VTT, configuration.mimeType) @@ -505,7 +506,7 @@ class SubtitleManagerTrackSelectionTest { url = "/stream/session-1/subtitles/0.srt", ) ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ).single() assertEquals(MimeTypes.APPLICATION_SUBRIP, configuration.mimeType) @@ -534,7 +535,7 @@ class SubtitleManagerTrackSelectionTest { url = "/stream/session-1/subtitles/1.sup", ), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ) assertEquals(2, configurations.size) @@ -555,7 +556,7 @@ class SubtitleManagerTrackSelectionTest { url = "", ), ), - serverUrl = "https://prairie.example", + serverUrl = "https://silo.example", ) assertTrue(configurations.isEmpty()) @@ -578,7 +579,7 @@ class SubtitleManagerTrackSelectionTest { MimeTypes.APPLICATION_PGS, MimeTypes.APPLICATION_DVBSUBS, ).forEach { codec -> - assertTrue(isBitmapSubtitleCodecOrMime(codec), "expected bitmap: $codec") + assertTrue(isBitmapSubtitleCodecFamily(codec), "expected bitmap: $codec") } listOf( "subrip", @@ -591,7 +592,7 @@ class SubtitleManagerTrackSelectionTest { null, " ", ).forEach { codec -> - assertFalse(isBitmapSubtitleCodecOrMime(codec), "expected text: $codec") + assertFalse(isBitmapSubtitleCodecFamily(codec), "expected text: $codec") } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolverTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolverTest.kt index 2fc016cfb..8e2143945 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolverTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolverTest.kt @@ -630,6 +630,62 @@ class SubtitleMountResolverTest { assertEquals("prairie-subtitle:42", subtitleArtifactTrackId(42)) } + // Shield repro (Supergirl): a disc with three English SubRip streams — + // "Forced", an untitled one the catalog labels with the placeholder + // "SUBRIP", and "SDH". The v3 row for the untitled one is typed sidecar + // (url present) but the direct-play stream carries the track, so it must + // resolve onto the untitled Media3 track — not to nothing. + @Test + fun untitledPlaceholderLabelledRowResolvesToTheUntitledSiblingNotSdhOrForced() { + val tracks = listOf( + track(index = 0, trackId = "2", label = "Forced", language = "en", codec = "application/x-subrip", forced = true, hearingImpaired = false), + // The TV synthesises "EN" for a track Media3 exposes without a label. + track(index = 1, trackId = "3", label = "EN", language = "en", codec = "application/x-subrip", forced = false, hearingImpaired = false), + track(index = 2, trackId = "4", label = "SDH", language = "en", codec = "application/x-subrip", forced = false, hearingImpaired = true), + ) + val row = PlayerSubtitleInfo( + index = 8, + language = "en", + codec = "subrip", + label = "SUBRIP", + source = "embedded", + catalogSource = "embedded", + serverTrackId = "file:22069955:subtitle:8", + serverDelivery = "sidecar", + url = "/stream/s/subtitles/8.srt", + ) + + assertEquals(1, resolveMountedSubtitle(row, tracks)?.track?.index) + } + + @Test + fun placeholderLabelIsNotUsedAsATitle() { + val tracks = listOf( + track(index = 0, trackId = "3", label = null, language = "en", codec = "application/x-subrip"), + ) + assertEquals( + 0, + resolveMountedSubtitle( + SubtitleIdentity.LocalMedia3(media(label = "PGS", language = "en", codecFamily = "subrip")), + tracks, + )?.track?.index, + ) + } + + @Test + fun untitledRowStaysAmbiguousBetweenTwoUntitledSiblings() { + val tracks = listOf( + track(index = 0, trackId = "3", label = null, language = "en", codec = "application/x-subrip"), + track(index = 1, trackId = "4", label = null, language = "en", codec = "application/x-subrip"), + ) + assertNull( + resolveMountedSubtitle( + SubtitleIdentity.LocalMedia3(media(label = "SUBRIP", language = "en", codecFamily = "subrip")), + tracks, + ), + ) + } + private fun track( index: Int, trackId: String?, diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackReselectionGuardTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackReselectionGuardTest.kt new file mode 100644 index 000000000..3ebbc69f3 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackReselectionGuardTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.common.player + +import androidx.media3.common.Player +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Regression cover for a playback-killing crash seen on a Google TV Streamer: + * + * ``` + * ExoPlaybackException: Unexpected runtime error + * Caused by: NullPointerException: MediaPeriodHolder.info + * at ExoPlayerImplInternal.seekToCurrentPosition + * at ExoPlayerImplInternal.reselectTracksInternalAndSeek + * ``` + * + * An HDMI audio-route change ("Audio output capabilities updated: codecs=[] + * maxChannels=2") re-fired the track-selection effect while the player was + * being torn down. ExoPlayer applies a reselection by seeking the current media + * period, and there was none left to seek, so playback ended in ERROR(7). + * + * The rule: never hand a reselection to a player that has nothing to apply it + * to. Presets are re-applied when the next player is built, so withholding them + * here loses nothing. + */ +class TrackReselectionGuardTest { + + @Test + fun `withholds reselection from an idle player`() { + assertTrue(shouldSkipTrackReselection(Player.STATE_IDLE, timelineEmpty = false)) + } + + @Test + fun `withholds reselection when the timeline is empty`() { + assertTrue(shouldSkipTrackReselection(Player.STATE_READY, timelineEmpty = true)) + } + + @Test + fun `withholds reselection from an idle player with an empty timeline`() { + assertTrue(shouldSkipTrackReselection(Player.STATE_IDLE, timelineEmpty = true)) + } + + @Test + fun `applies reselection while buffering, ready or ended`() { + // Ended still holds a media period, so a reselection resolves normally; + // only IDLE and an empty timeline are unsafe. + for (state in listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED)) { + assertFalse( + shouldSkipTrackReselection(state, timelineEmpty = false), + "state $state with a populated timeline should accept a reselection", + ) + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresetsTextOwnershipTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresetsTextOwnershipTest.kt new file mode 100644 index 000000000..c172693fb --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/TrackSelectionPresetsTextOwnershipTest.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.common.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The TV preset must not make `DefaultTrackSelector` a second subtitle + * authority. A preferred-text hint there enabled a text track on its own while + * the subtitle transaction adapter's committed identity stayed put — playback + * obeyed the selector, the HUD reported the adapter, and the two disagreed + * (subtitles on screen, "Off" in the HUD). + * + * Asserted over source because `buildTvParameters` needs a `Context` to produce + * `DefaultTrackSelector.Parameters`, which a plain JVM unit test cannot provide + * — the same reason the MIME-preference helpers are tested directly. + */ +class TrackSelectionPresetsTextOwnershipTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("android-shared/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun functionBody(source: String, signature: String): String { + val start = source.indexOf(signature) + require(start >= 0) { "$signature is missing" } + val end = source.indexOf("\n /**", start) + require(end > start) { "Could not delimit $signature" } + return source.substring(start, end) + } + + @Test + fun tvPresetLeavesTextTrackSelectionToTheApp() { + val source = source("org/prairieserver/prairie/common/player/TrackSelectionPresets.kt") + val tv = functionBody(source, "fun buildTvParameters(") + + assertFalse(tv.contains("setPreferredTextLanguage")) + assertFalse(tv.contains("preferredTextLanguage")) + // Text enablement is left entirely untouched, so re-applying presets on + // a capability change cannot disturb an already-mounted subtitle. + assertFalse(tv.contains("TRACK_TYPE_TEXT")) + // Audio language IS still a selector preference on TV. + assertTrue(tv.contains("setPreferredAudioLanguage")) + } + + @Test + fun phonePresetStillHonoursThePreferredTextLanguage() { + val source = source("org/prairieserver/prairie/common/player/TrackSelectionPresets.kt") + val phone = functionBody(source, "fun buildPhoneParameters(") + + assertTrue(phone.contains("setPreferredTextLanguage")) + } + + @Test + fun theFactoryDoesNotForwardAPreferredTextLanguageOnTv() { + val source = source("org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt") + val tvCall = source.substringAfter("TrackSelectionPresets.buildTvParameters(") + .substringBefore(")") + + assertFalse(tvCall.contains("preferredTextLanguage")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/VideoPlayerSubtitleMountTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/VideoPlayerSubtitleMountTest.kt new file mode 100644 index 000000000..346e7c5c7 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/VideoPlayerSubtitleMountTest.kt @@ -0,0 +1,204 @@ +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan +import org.prairieserver.prairie.model.playback.PlaybackRouteFamily +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SelectedPlaybackTracks +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import kotlin.test.Test +import kotlin.test.assertEquals + +class VideoPlayerSubtitleMountTest { + @Test + fun v3ServerSidecarMountIgnoresEveryUncommittedDownload() { + val rows = listOf( + serverRow(index = 0), + serverRow(index = 7), + serverRow(index = 17), + PlayerSubtitleInfo( + index = 18, + source = "downloaded", + downloadId = 312, + url = "content://downloads/subtitle-312.vtt", + ), + ) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 7), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 7), + ) + + assertEquals(listOf(7), mounted.map(PlayerSubtitleInfo::index)) + } + + @Test + fun v3DownloadedMountAttachesOnlyTheCommittedDownload() { + val rows = listOf( + serverRow(index = 0), + serverRow(index = 7), + PlayerSubtitleInfo( + index = 8, + source = "downloaded", + downloadId = 44, + url = "content://downloads/subtitle-44.vtt", + ), + PlayerSubtitleInfo( + index = 9, + source = "downloaded", + downloadId = 45, + url = "content://downloads/subtitle-45.vtt", + ), + ) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = null), + subtitleIdentity = SubtitleIdentity.Downloaded( + downloadId = 44, + media = SubtitleMediaIdentity(), + ), + ) + + assertEquals(listOf(8), mounted.map(PlayerSubtitleInfo::index)) + } + + @Test + fun v3OffPlanDoesNotAttachAnySubtitleArtifact() { + val rows = listOf(serverRow(index = 0), serverRow(index = 7)) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = null), + subtitleIdentity = SubtitleIdentity.Off, + ) + + assertEquals(emptyList(), mounted) + } + + @Test + fun legacyAndOfflineMountsKeepTheirSuppliedSubtitleContract() { + val rows = listOf(serverRow(index = 0), serverRow(index = 7)) + + assertEquals( + rows, + subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = null, + subtitleIdentity = SubtitleIdentity.Off, + ), + ) + } + + // Protocol v3 types a row describing a track MUXED into the file as + // `delivery = sidecar` too. On the untouched original that track is already + // in the stream, so a caller that can select it in place must not have the + // server-extracted duplicate attached (it stalls the mount and paints the + // cue backlog past the resume point). + @Test + fun v3MuxedEmbeddedRowMountsNothingOnDirectPlayWhenMuxedTracksPreferred() { + val rows = listOf(serverRow(index = 3), embeddedPgsRow(index = 8)) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 8, delivery = PlaybackDelivery.ORIGINAL_HTTP), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 8), + preferMuxedTracks = true, + ) + + assertEquals(emptyList(), mounted) + } + + @Test + fun v3MuxedEmbeddedRowStillMountsTheSidecarWhenCallerCannotSelectInPlace() { + val rows = listOf(serverRow(index = 3), embeddedPgsRow(index = 8)) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 8, delivery = PlaybackDelivery.ORIGINAL_HTTP), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 8), + ) + + assertEquals(listOf(8), mounted.map(PlayerSubtitleInfo::index)) + } + + @Test + fun v3MuxedEmbeddedRowStillMountsTheSidecarOnRemuxAndTranscodeDeliveries() { + val rows = listOf(serverRow(index = 3), embeddedPgsRow(index = 8)) + for (delivery in listOf( + PlaybackDelivery.SERVER_REMUX_HLS, + PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, + PlaybackDelivery.SERVER_TRANSCODE_HLS, + )) { + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 8, delivery = delivery), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 8), + preferMuxedTracks = true, + ) + assertEquals(listOf(8), mounted.map(PlayerSubtitleInfo::index), "delivery=$delivery") + } + } + + @Test + fun v3ExternalRowStillMountsOnDirectPlayWhenMuxedTracksPreferred() { + val rows = listOf(serverRow(index = 3), embeddedPgsRow(index = 8)) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 3, delivery = PlaybackDelivery.ORIGINAL_HTTP), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 3), + preferMuxedTracks = true, + ) + + assertEquals(listOf(3), mounted.map(PlayerSubtitleInfo::index)) + } + + @Test + fun v3MuxedBitmapRowTheClientCannotDecodeStillMountsTheSidecar() { + val rows = listOf(embeddedPgsRow(index = 8).copy(codec = "dvb_subtitle")) + + val mounted = subtitlesForVideoMediaMount( + subtitles = rows, + playbackPlan = plan(selectedSubtitleIndex = 8, delivery = PlaybackDelivery.ORIGINAL_HTTP), + subtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 8), + preferMuxedTracks = true, + ) + + assertEquals(listOf(8), mounted.map(PlayerSubtitleInfo::index)) + } + + private fun serverRow(index: Int): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = index, + source = "external", + catalogSource = "external", + serverTrackId = "file:482:subtitle:$index", + serverDelivery = "sidecar", + url = "/stream/session/subtitles/$index.vtt", + ) + + /** A v3 row for a PGS track muxed into the file: typed sidecar all the same. */ + private fun embeddedPgsRow(index: Int): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = index, + language = "en", + codec = "hdmv_pgs_subtitle", + label = "English", + source = "embedded", + catalogSource = "embedded", + serverTrackId = "file:482:subtitle:$index", + serverDelivery = "sidecar", + url = "/stream/session/subtitles/$index.sup", + ) + + private fun plan( + selectedSubtitleIndex: Int?, + delivery: PlaybackDelivery = PlaybackDelivery.SERVER_REMUX_HLS, + ): PlaybackExecutionPlan = PlaybackExecutionPlan( + planId = "plan", + delivery = delivery, + routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE, + selectedTracks = SelectedPlaybackTracks(subtitleIndex = selectedSubtitleIndex), + ) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessorTest.kt index 2f2e3887e..0039184af 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/audio/DelayAudioProcessorTest.kt @@ -59,14 +59,58 @@ class DelayAudioProcessorTest { p.configure(makeStereo16Pcm44k()) p.flush(StreamMetadata.DEFAULT) val input = inputBytes(4_000) + + // The head is paid off first, on its own. The input is deliberately + // NOT consumed on this pass, so it is offered again. p.queueInput(input) - val out = p.output - // Expect 1764 silence bytes + 4000 input bytes - assertEquals(5_764, out.remaining()) - // First 1764 bytes should be zero - repeat(1_764) { - assertEquals(0.toByte(), out.get()) + val silence = p.output + assertEquals(1_764, silence.remaining()) + repeat(1_764) { assertEquals(0.toByte(), silence.get()) } + assertEquals(4_000, input.remaining()) + + // Then the audio, unbroken. + p.queueInput(input) + assertEquals(4_000, p.output.remaining()) + } + + /** + * The real streaming shape, which the single-large-buffer test above cannot + * express: a delay spanning MANY decoder buffers. + * + * The processor used to emit silence and audio on every pass until the head + * was consumed, so a delay longer than one buffer came out as + * silence, audio, silence, audio... — audible as chopped, half-rate sound + * for the length of the offset. Silence must come out in one unbroken run. + */ + @Test + fun `a delay longer than one buffer does not interleave audio`() { + val p = DelayAudioProcessor() + p.setDelayMs(100) // 17_640 bytes — far more than one buffer + p.configure(makeStereo16Pcm44k()) + p.flush(StreamMetadata.DEFAULT) + + var silenceEmitted = 0 + var passes = 0 + while (silenceEmitted < 17_640 && passes < 200) { + passes++ + val chunk = inputBytes(512) + p.queueInput(chunk) + val out = p.output + val len = out.remaining() + if (len == 0) continue + // Every byte before the head is paid off must be silence. A single + // non-zero byte here is the interleaving bug. + repeat(len) { assertEquals(0.toByte(), out.get()) } + silenceEmitted += len + // Audio is held back while the head is outstanding. + assertEquals(512, chunk.remaining()) } + assertEquals(17_640, silenceEmitted) + + // Head paid: audio now flows. + val audio = inputBytes(512) + p.queueInput(audio) + assertEquals(512, p.output.remaining()) } @Test diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackendLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackendLifecycleTest.kt new file mode 100644 index 000000000..3d94d13ae --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackendLifecycleTest.kt @@ -0,0 +1,22 @@ +package org.prairieserver.prairie.common.player.backend + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class Media3VideoPlaybackBackendLifecycleTest { + private val sourceFile = java.io.File( + "src/androidMain/kotlin/org/prairieserver/prairie/common/player/backend/Media3VideoPlaybackBackend.kt", + ) + + @Test + fun externalSubtitleBeforeMountIsARecoverableNotReadyResult() { + val methodBody = sourceFile.readText() + .substringAfter("override fun selectSubtitle(") + .substringBefore("override fun selectMountedSubtitle(") + + assertTrue(methodBody.contains("track?.subtitle != null && mountedSpec == null")) + assertTrue(methodBody.contains("return false")) + assertFalse(methodBody.contains("error(")) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt new file mode 100644 index 000000000..86462e072 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt @@ -0,0 +1,96 @@ +package org.prairieserver.prairie.common.player.cast + +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackPlanV3 +import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol +import org.prairieserver.prairie.model.playback.PlaybackStreamV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 +import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 +import org.prairieserver.prairie.model.playback.playbackClientFeaturesV3 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CastPlaybackPreparerTest { + @Test + fun castContextDoesNotAdvertiseThePreNeutralSidecarFeature() { + assertFalse( + "external_text_sidecar_set_v1" in + playbackClientFeaturesV3( + chromecastPlaybackContext( + appVersion = "test", + buildIdentity = PrairieClientBuildIdentity(buildNumber = "5", channel = "release"), + ), + ), + ) + } + + @Test + fun castUsesPlayerLocalStartInsteadOfSourceTimelinePosition() { + val plan = plan( + timeline = PlaybackTimelineV3( + sourceStartSeconds = 90.0, + playerStartSeconds = 0.0, + ), + ) + + assertEquals(0.0, castPlayerStartPosition(plan, requested = 90.0)) + } + + @Test + fun castUsesAuthoritativeInventoryAndPreservesAnAuthoritativeEmptyList() { + assertEquals(emptyList(), castSubtitleInventory(plan())) + val authoritative = PlaybackSubtitleInventoryItemV3( + trackId = "file:7:subtitle:0", + combinedIndex = 0, + source = "embedded", + codec = "ass", + delivery = "sidecar", + url = "/subtitles/0.ass", + ) + assertEquals( + listOf(authoritative), + castSubtitleInventory( + plan( + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf(authoritative), + ), + ), + ), + ) + } + + @Test + fun successfulReceiverLoadResetsTheRecoveryBudget() { + val budget = CastLoadRecoveryBudget(maxAttempts = 3) + + repeat(5) { + assertTrue(budget.tryConsume()) + budget.resetAfterSuccess() + } + } + + @Test + fun consecutiveReceiverLoadFailuresExhaustTheRecoveryBudget() { + val budget = CastLoadRecoveryBudget(maxAttempts = 3) + + repeat(3) { assertTrue(budget.tryConsume()) } + assertFalse(budget.tryConsume()) + } + + private fun plan( + timeline: PlaybackTimelineV3 = PlaybackTimelineV3(), + subtitle: PlaybackSubtitleDecisionV3 = PlaybackSubtitleDecisionV3(), + ) = PlaybackPlanV3( + planId = "plan", + planAttemptKey = "opaque", + delivery = PlaybackDelivery.SERVER_REMUX_HLS, + stream = PlaybackStreamV3("/stream.m3u8", PlaybackStreamProtocol.HLS), + timeline = timeline, + subtitle = subtitle, + decisionReason = "test", + ) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicyTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicyTest.kt index 6121100bf..ee755a5f2 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicyTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/seek/PlaybackTimelineSeekPolicyTest.kt @@ -1,12 +1,55 @@ package org.prairieserver.prairie.common.player.seek import org.prairieserver.prairie.model.playback.PlaybackTimeline +import org.prairieserver.prairie.model.playback.PlaybackTimelineV3 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull class PlaybackTimelineSeekPolicyTest { + @Test + fun `replan remount restores current source position on reused transport timeline`() { + val timeline = PlaybackTimelineV3( + sourceStartSeconds = 4_946.708, + playerStartSeconds = 0.001, + timelineOffsetSeconds = 4_946.708, + ) + + val mount = timeline.replanMountPositionForSource(5_103.58) + + assertEquals(156.872, mount.playerPositionSeconds, absoluteTolerance = 0.000_001) + assertEquals(5_103.58, mount.sourcePositionSeconds) + } + + @Test + fun `replan remount maps a newly anchored transport to its local start`() { + val timeline = PlaybackTimeline( + sourceStartSeconds = 321.25, + playerStartSeconds = 0.0, + timelineOffsetSeconds = 321.25, + ) + + val mount = timeline.replanMountPositionForSource(321.25) + + assertEquals(0.0, mount.playerPositionSeconds) + assertEquals(321.25, mount.sourcePositionSeconds) + } + + @Test + fun `replan remount falls back to plan start for invalid source position`() { + val timeline = PlaybackTimeline( + sourceStartSeconds = 90.0, + playerStartSeconds = 0.5, + timelineOffsetSeconds = 89.5, + ) + + val mount = timeline.replanMountPositionForSource(Double.NaN) + + assertEquals(0.5, mount.playerPositionSeconds) + assertEquals(90.0, mount.sourcePositionSeconds) + } + @Test fun offsetMapsBetweenPlayerAndSourceCoordinates() { val timeline = PlaybackTimeline(timelineOffsetSeconds = 120.0) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt index 742c1d7cf..d7144f014 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/PgsSupExtractorTest.kt @@ -42,6 +42,32 @@ class PgsSupExtractorTest { ) } + @Test + fun advertisesASeekableStartForMergedNonZeroResume() { + val extractor = PgsSupExtractor(RecordingParserFactory(), { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + + extractor.init(output) + + assertTrue(output.seekMap.isSeekable) + val seekPoints = output.seekMap.getSeekPoints(15_000_000L) + assertEquals(0L, seekPoints.first.timeUs) + assertEquals(0L, seekPoints.first.position) + } + + @Test + fun indexesParsedDisplaySetsByTimestampAndBytePosition() { + val extractor = PgsSupExtractor(RecordingParserFactory(), { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + + drain(extractor, FakeExtractorInput.Builder().setData(supStream()).build()) + + val seekPoints = output.seekMap.getSeekPoints(3_500_000L) + assertEquals(3_000_000L, seekPoints.first.timeUs) + assertTrue(seekPoints.first.position > 0L) + } + @Test fun eachDisplaySetBecomesOneSampleAtItsOwnPts() { val factory = RecordingParserFactory() @@ -79,6 +105,99 @@ class PgsSupExtractorTest { assertEquals(2_500_000L, track.getSampleTimeUs(1)) } + /** + * A SUP is read from the top on every seek, so every caption before the + * target streams through first. PGS is REPLACE with no duration: publish + * those and each one is "the newest cue at or before the position" for as + * long as the next takes to download — the film's caption history replays + * on screen while the video buffers at the resume point (seen on an onn + * box and reproduced on the TV emulator: a fresh caption every ~0.8s at a + * pinned position). Only the set in force at the seek point survives, and + * it lands AT the seek point. + */ + @Test + fun captionsBeforeTheSeekPointAreNotPublishedExceptTheOneInForce() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + extractor.seek(0L, 4_000_000L) + + drain(extractor, FakeExtractorInput.Builder().setData(threeSetStream()).build()) + + // 1s never reached the parser; 3s (in force at 4s) and 5s did. + assertEquals(2, factory.parsed.size) + val track = output.trackOutputs[0]!! + assertEquals(2, track.sampleCount) + assertEquals(4_000_000L, track.getSampleTimeUs(0)) + assertEquals(5_000_000L, track.getSampleTimeUs(1)) + } + + /** + * The re-anchored case that bit in the field: the server starts the + * stream at the resume point, so the player timeline is 0 there and the + * offset shifts the SUP's absolute times back by that much. Everything + * before the resume point goes negative — it must not clamp to zero and + * publish, it must be dropped, bar the one caption in force. + */ + @Test + fun aReanchoredTimelineDropsTheNegativeHistoryInsteadOfClampingIt() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { -3_500_000L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + extractor.seek(0L, 0L) + + drain(extractor, FakeExtractorInput.Builder().setData(threeSetStream()).build()) + + val track = output.trackOutputs[0]!! + assertEquals(2, track.sampleCount) + // 3s (in force at the resume point) lands at 0; 5s lands at 1.5s. + assertEquals(0L, track.getSampleTimeUs(0)) + assertEquals(1_500_000L, track.getSampleTimeUs(1)) + } + + /** + * With the sidecar taken out of the loading gate the video runs ahead of + * this download, so a set can arrive after the playhead has passed it. + * That set would flash for one render tick; the live floor drops it too. + */ + @Test + fun aSetThePlayheadHasAlreadyPassedIsHistoryToo() { + val factory = RecordingParserFactory() + var playheadUs = 0L + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat(), { playheadUs }) + val output = FakeExtractorOutput() + extractor.init(output) + extractor.seek(0L, 0L) + playheadUs = 4_000_000L // playing at 4s while the SUP is still arriving + + drain(extractor, FakeExtractorInput.Builder().setData(threeSetStream()).build()) + + val track = output.trackOutputs[0]!! + assertEquals(2, track.sampleCount) + // 1s dropped; 3s is the caption in force at 4s and keeps its own time + // (the seek point is 0); 5s is ahead of the playhead and published as is. + assertEquals(3_000_000L, track.getSampleTimeUs(0)) + assertEquals(5_000_000L, track.getSampleTimeUs(1)) + } + + /** A resume past the last caption still gets the set in force there. */ + @Test + fun aSeekPastEveryCaptionPublishesTheLastOneAtTheSeekPoint() { + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + val output = FakeExtractorOutput() + extractor.init(output) + extractor.seek(0L, 9_000_000L) + + drain(extractor, FakeExtractorInput.Builder().setData(threeSetStream()).build()) + + val track = output.trackOutputs[0]!! + assertEquals(1, track.sampleCount) + assertEquals(9_000_000L, track.getSampleTimeUs(0)) + } + @Test fun theEmittedTrackKeepsTheSidecarIdentity() { val extractor = PgsSupExtractor(RecordingParserFactory(), { 0L }, pgsFormat()) @@ -176,6 +295,64 @@ class PgsSupExtractorTest { } } + /** + * A caption declaring an enormous bitmap must never reach the parser. + * + * Media3 trusts these two 16-bit fields: it allocates IntArray(w * h) and + * an ARGB bitmap from them. 40000x40000 asks for 1.6 billion pixels — over + * 6 GB — from eleven bytes of input. Catching the failure afterwards is too + * late on a TV box, where the process simply disappears. + */ + @Test + fun anObjectDeclaringAnUnreasonableBitmapIsRejected() { + val out = ByteArrayOutputStream() + out.writeSegment(pts90kHz = 90_000, type = SEGMENT_TYPE_PCS, payload = byteArrayOf(0xAA.toByte())) + out.writeSegment( + pts90kHz = 90_000, + type = PgsSupExtractor.SEGMENT_TYPE_OBJECT, + payload = objectSegment(width = 40_000, height = 40_000), + ) + out.writeSegment(pts90kHz = 90_000, type = PgsSupExtractor.SEGMENT_TYPE_END, payload = ByteArray(0)) + + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + extractor.init(FakeExtractorOutput()) + drain(extractor, FakeExtractorInput.Builder().setData(out.toByteArray()).build()) + + assertEquals(0, factory.parsed.size) + } + + /** A full-frame 1080p caption is legitimate and must still play. */ + @Test + fun aFullFrameObjectIsAccepted() { + val out = ByteArrayOutputStream() + out.writeSegment(pts90kHz = 90_000, type = SEGMENT_TYPE_PCS, payload = byteArrayOf(0xAA.toByte())) + out.writeSegment( + pts90kHz = 90_000, + type = PgsSupExtractor.SEGMENT_TYPE_OBJECT, + payload = objectSegment(width = 1920, height = 1080), + ) + out.writeSegment(pts90kHz = 90_000, type = PgsSupExtractor.SEGMENT_TYPE_END, payload = ByteArray(0)) + + val factory = RecordingParserFactory() + val extractor = PgsSupExtractor(factory, { 0L }, pgsFormat()) + extractor.init(FakeExtractorOutput()) + drain(extractor, FakeExtractorInput.Builder().setData(out.toByteArray()).build()) + + assertEquals(1, factory.parsed.size) + } + + /** First-sequence ODS payload: id, version, descriptor, length, w, h. */ + private fun objectSegment(width: Int, height: Int): ByteArray = byteArrayOf( + 0x00, 0x01, // object id + 0x00, // version + 0x80.toByte(), // first sequence + 0x00, 0x00, 0x10, // object data length (>= 4) + (width shr 8 and 0xFF).toByte(), (width and 0xFF).toByte(), + (height shr 8 and 0xFF).toByte(), (height and 0xFF).toByte(), + 0x00, 0x00, // token RLE bytes + ) + /** Two display sets: PTS 1s and 3s, each one PCS segment then END. */ private fun supStream(): ByteArray { val out = ByteArrayOutputStream() @@ -186,6 +363,15 @@ class PgsSupExtractorTest { return out.toByteArray() } + /** Three display sets: PTS 1s, 3s and 5s. */ + private fun threeSetStream(): ByteArray { + val out = ByteArrayOutputStream() + out.write(supStream()) + out.writeSegment(pts90kHz = 450_000, type = SEGMENT_TYPE_PCS, payload = byteArrayOf(0xDD.toByte())) + out.writeSegment(pts90kHz = 450_000, type = PgsSupExtractor.SEGMENT_TYPE_END, payload = ByteArray(0)) + return out.toByteArray() + } + private fun missingEndStream(segmentCount: Int, payloadSize: Int): ByteArray { val out = ByteArrayOutputStream() val payload = ByteArray(payloadSize) { 0x5A } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt new file mode 100644 index 000000000..90d467b91 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SidecarSubtitleMediaSourceTest.kt @@ -0,0 +1,183 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.exoplayer.LoadingInfo +import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.source.MediaPeriod +import androidx.media3.exoplayer.source.SampleStream +import androidx.media3.exoplayer.source.TrackGroupArray +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.exoplayer.trackselection.FixedTrackSelection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The contract that keeps a sidecar from gating the merged period: it reports + * nothing to load and nothing buffered, it keeps its own delegate loading + * without the composite's help, and it publishes the live position as the + * history floor. + */ +@RunWith(RobolectricTestRunner::class) +class SidecarSubtitleMediaSourceTest { + + @Test + fun reportsItselfAsNotAParticipantInLoadingDecisions() { + val delegate = FakePeriod() + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + + assertEquals(C.TIME_END_OF_SOURCE, period.bufferedPositionUs) + assertEquals(C.TIME_END_OF_SOURCE, period.nextLoadPositionUs) + } + + /** + * ProgressiveMediaPeriod parks its loader every N bytes and after a seek + * cancels a load in flight, and only resumes when someone calls + * continueLoading. The composite never will for a child that reports + * END_OF_SOURCE, so the wrapper has to. + */ + @Test + fun continuesItsOwnDelegateWhenTheDelegateParks() { + val delegate = FakePeriod() + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor) + val upstream = RecordingCallback() + period.prepare(upstream, 0L) + floor.set(7_000_000L) + + delegate.loading = false + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertEquals(1, delegate.continueLoadingCalls.size) + assertEquals(7_000_000L, delegate.continueLoadingCalls.single().playbackPositionUs) + // Still forwarded, identified as this wrapper, so the merge stays informed. + assertSame(period, upstream.continueLoadingRequestedFrom.single()) + } + + @Test + fun doesNotDoubleStartADelegateThatIsStillLoading() { + val delegate = FakePeriod() + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + period.prepare(RecordingCallback(), 0L) + + delegate.loading = true + delegate.callback!!.onContinueLoadingRequested(delegate) + + assertTrue(delegate.continueLoadingCalls.isEmpty()) + } + + /** + * ProgressiveMediaPeriod refuses continueLoading until a track is enabled, + * and a seek leaves an idle delegate reset-but-parked. Both wait for a + * continueLoading nobody upstream will send — seen on an onn box as a + * SUP that stopped at its first check interval and never drew a caption. + */ + @Test + fun kicksTheDelegateAfterATrackIsEnabledAndAfterASeek() { + val delegate = FakePeriod() + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + period.prepare(RecordingCallback(), 0L) + + period.selectTracks(arrayOfNulls(1), BooleanArray(1), arrayOfNulls(1), BooleanArray(1), 0L) + assertTrue(delegate.continueLoadingCalls.isEmpty()) // nothing enabled: nothing to kick + + period.selectTracks( + arrayOf(FixedTrackSelection(TrackGroup(Format.Builder().build()), 0)), BooleanArray(1), arrayOfNulls(1), BooleanArray(1), 0L, + ) + assertEquals(1, delegate.continueLoadingCalls.size) + + period.seekToUs(3_000_000L) + assertEquals(2, delegate.continueLoadingCalls.size) + assertEquals(3_000_000L, delegate.continueLoadingCalls.last().playbackPositionUs) + } + + @Test + fun publishesTheLivePositionAndSeeksAsTheFloor() { + val delegate = FakePeriod() + val floor = SidecarPlaybackFloor() + val period = NonGatingSidecarPeriod(delegate, floor) + + period.prepare(RecordingCallback(), 2_000_000L) + assertEquals(2_000_000L, floor.get()) + + period.reevaluateBuffer(9_500_000L) + assertEquals(9_500_000L, floor.get()) + + period.seekToUs(1_000_000L) + assertEquals(1_000_000L, floor.get()) + assertEquals(1_000_000L, delegate.lastSeekUs) + } + + @Test + fun forwardsPreparedAsItself() { + val delegate = FakePeriod() + val period = NonGatingSidecarPeriod(delegate, SidecarPlaybackFloor()) + val upstream = RecordingCallback() + + period.prepare(upstream, 0L) + delegate.callback!!.onPrepared(delegate) + + assertSame(period, upstream.preparedFrom.single()) + assertFalse(upstream.preparedFrom.contains(delegate)) + } + + private class RecordingCallback : MediaPeriod.Callback { + val preparedFrom = mutableListOf() + val continueLoadingRequestedFrom = mutableListOf() + + override fun onPrepared(mediaPeriod: MediaPeriod) { + preparedFrom += mediaPeriod + } + + override fun onContinueLoadingRequested(source: MediaPeriod) { + continueLoadingRequestedFrom += source + } + } + + private class FakePeriod : MediaPeriod { + var callback: MediaPeriod.Callback? = null + var loading = false + var lastSeekUs = C.TIME_UNSET + val continueLoadingCalls = mutableListOf() + + override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) { + this.callback = callback + } + + override fun maybeThrowPrepareError() = Unit + override fun getTrackGroups(): TrackGroupArray = TrackGroupArray.EMPTY + override fun selectTracks( + selections: Array, + mayRetainStreamFlags: BooleanArray, + streams: Array, + streamResetFlags: BooleanArray, + positionUs: Long, + ): Long = positionUs + + override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) = Unit + override fun readDiscontinuity(): Long = C.TIME_UNSET + override fun seekToUs(positionUs: Long): Long { + lastSeekUs = positionUs + return positionUs + } + + override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters): Long = + positionUs + + override fun getBufferedPositionUs(): Long = 0L + override fun getNextLoadPositionUs(): Long = 0L + override fun continueLoading(loadingInfo: LoadingInfo): Boolean { + continueLoadingCalls += loadingInfo + return true + } + + override fun isLoading(): Boolean = loading + override fun reevaluateBuffer(positionUs: Long) = Unit + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractorTest.kt new file mode 100644 index 000000000..0f2d9894e --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/StreamingWebvttExtractorTest.kt @@ -0,0 +1,121 @@ +package org.prairieserver.prairie.common.player.subtitle + +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.text.webvtt.WebvttParser +import androidx.media3.test.utils.FakeExtractorInput +import androidx.media3.test.utils.FakeExtractorOutput +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StreamingWebvttExtractorTest { + @Test + fun `first complete cue is published before the response reaches eof`() { + val trailingResponse = "NOTE\n${"x".repeat(20_000)}" + val payload = ( + "WEBVTT\n\n" + + "00:01.000 --> 00:02.000\nHello\n\n" + + trailingResponse + ).encodeToByteArray() + val input = FakeExtractorInput.Builder().setData(payload).build() + val output = FakeExtractorOutput() + val extractor = extractor() + extractor.init(output) + + assertEquals(Extractor.RESULT_CONTINUE, extractor.read(input, PositionHolder())) + + assertTrue(input.position < payload.size) + assertEquals(1, output.trackOutputs[0]!!.sampleCount) + assertEquals(1_000_000L, output.trackOutputs[0]!!.getSampleTimeUs(0)) + } + + @Test + fun `crlf cue blocks are emitted independently and keep track identity`() { + val payload = ( + "WEBVTT\r\n\r\n" + + "00:01.000 --> 00:02.000\r\nOne\r\n\r\n" + + "00:03.000 --> 00:04.000\r\nTwo\r\n\r\n" + ).encodeToByteArray() + val input = FakeExtractorInput.Builder().setData(payload).build() + val output = FakeExtractorOutput() + val extractor = extractor() + extractor.init(output) + + drain(extractor, input) + + val track = output.trackOutputs[0]!! + assertEquals(2, track.sampleCount) + assertEquals(1_000_000L, track.getSampleTimeUs(0)) + assertEquals(3_000_000L, track.getSampleTimeUs(1)) + assertEquals("silo-subtitle:7", track.lastFormat!!.id) + assertEquals("en", track.lastFormat!!.language) + assertEquals("English", track.lastFormat!!.label) + assertEquals(MimeTypes.TEXT_VTT, track.lastFormat!!.codecs) + } + + @Test + fun `configured parser offset is applied to each incremental cue`() { + val payload = ( + "WEBVTT\n\n" + + "00:01.000 --> 00:02.000\nShifted\n\n" + ).encodeToByteArray() + val input = FakeExtractorInput.Builder().setData(payload).build() + val output = FakeExtractorOutput() + val parser = OffsetSubtitleParserFactory( + offsetUsProvider = { 1_500_000L }, + ).create(sourceFormat()) + val extractor = StreamingWebvttExtractor( + parser, + sourceFormat(), + maxBytes = 32L * 1024 * 1024, + ) + extractor.init(output) + + drain(extractor, input) + + assertEquals(2_500_000L, output.trackOutputs[0]!!.getSampleTimeUs(0)) + } + + @Test + fun `input above the subtitle byte budget fails closed`() { + val payload = ("WEBVTT\n\n" + "x".repeat(256)).encodeToByteArray() + val input = FakeExtractorInput.Builder().setData(payload).build() + val output = FakeExtractorOutput() + val extractor = StreamingWebvttExtractor( + WebvttParser(), + sourceFormat(), + maxBytes = 32, + ) + extractor.init(output) + + assertEquals(Extractor.RESULT_END_OF_INPUT, extractor.read(input, PositionHolder())) + assertEquals(0, output.trackOutputs[0]!!.sampleCount) + } + + private fun extractor() = StreamingWebvttExtractor( + WebvttParser(), + sourceFormat(), + maxBytes = 32L * 1024 * 1024, + ) + + private fun sourceFormat() = Format.Builder() + .setId("silo-subtitle:7") + .setSampleMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .setLabel("English") + .build() + + private fun drain(extractor: Extractor, input: FakeExtractorInput) { + val position = PositionHolder() + var guard = 0 + while (extractor.read(input, position) != Extractor.RESULT_END_OF_INPUT) { + if (++guard > 100) error("extractor did not terminate") + } + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizerTest.kt index 338fce8b4..3df59d6cc 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizerTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/subtitle/SubripPayloadNormalizerTest.kt @@ -3,6 +3,8 @@ package org.prairieserver.prairie.common.player.subtitle import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertNotNull class SubripPayloadNormalizerTest { @Test @@ -111,4 +113,25 @@ class SubripPayloadNormalizerTest { ) assertFalse(source.contains("SubRip payload len=")) } + + /** + * A Windows-1252 subtitle that needs rewriting must keep its accents. + * + * Lenient UTF-8 decoding turned every high byte into U+FFFD, and because a + * rewrite re-encodes what it decoded, the damage was permanent — "José" + * became "Jos\uFFFD" in the cues the viewer actually read. + */ + @Test + fun `a windows-1252 payload keeps its accents through a rewrite`() { + // Timecode-first, so normalisation must rewrite it and therefore decode. + val text = "00:00:01,000 --> 00:00:02,000\nJosé\n" + val bytes = text.toByteArray(java.nio.charset.Charset.forName("windows-1252")) + + val out = normalizeSubripPayloadIfNeeded(bytes, 0, bytes.size) + + assertNotNull(out) + val decoded = out!!.decodeToString() + assertTrue(decoded.contains("José"), "accents were lost: $decoded") + assertFalse(decoded.contains('\uFFFD'), "replacement characters present: $decoded") + } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcileTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcileTest.kt new file mode 100644 index 000000000..31c931540 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/AudioReconcileTest.kt @@ -0,0 +1,175 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The decision half of desired-audio reconciliation. + * + * Every review round of this area produced a case that neither reasoning nor + * the pure-matcher tests caught — a discarded intent, a false confirmation, a + * cross-file ordinal — so the cases below are those failures written down. + */ +class AudioReconcileTest { + + private val english = AudioTrack( + codec = "dts", channels = 6, language = "en", title = "English DTS 5.1", isDefault = true, + ) + private val dutch = AudioTrack( + codec = "aac", channels = 2, language = "nl", title = "Dutch AAC Stereo", + ) + private val catalog = listOf(english, dutch) + + /** Mounted order is the REVERSE of catalog order, as on the device. */ + private val mounted = listOf( + MountedAudioTrack(0, "nl", "audio/mp4a-latm", 2, "Dutch AAC Stereo"), + MountedAudioTrack(1, "en", "audio/vnd.dts", 6, "English DTS 5.1"), + ) + + private fun desire(ordinal: Int, fileId: Int? = 1, confirmed: Boolean = false) = + DesiredAudio( + generation = 1L, + catalogOrdinal = ordinal, + explicit = true, + fileId = fileId, + confirmed = confirmed, + ) + + private fun reconcile( + desired: DesiredAudio?, + mountedTracks: List = mounted, + selectedOrdinal: Int? = 1, + activeFileId: Int? = 1, + planAudioOrdinal: Int? = null, + ) = reconcileDesiredAudioAction( + desired = desired, + activeFileId = activeFileId, + catalog = catalog, + mounted = mountedTracks, + selectedOrdinal = selectedOrdinal, + planAudioOrdinal = planAudioOrdinal, + ) + + @Test + fun wantedTrackPresentButUnselectedIsApplied() { + // Dutch is catalog 1 and mounted 0; a positional answer would say 1. + assertEquals(AudioReconcileAction.Apply(0), reconcile(desire(1))) + } + + @Test + fun wantedTrackAlreadySelectedConfirms() { + assertEquals(AudioReconcileAction.Confirm, reconcile(desire(1), selectedOrdinal = 0)) + assertEquals(AudioReconcileAction.Confirm, reconcile(desire(0), selectedOrdinal = 1)) + } + + /** + * The bug that made a launch pick silently fail: an empty or partial first + * callback must not be treated as evidence, and must not consume the intent. + */ + @Test + fun emptySnapshotDecidesNothing() { + assertEquals(AudioReconcileAction.None, reconcile(desire(1), mountedTracks = emptyList())) + } + + @Test + fun noIntentDecidesNothing() { + assertEquals(AudioReconcileAction.None, reconcile(null)) + } + + /** Ordinals are per-file; an intent from the outgoing version is abandoned. */ + @Test + fun intentFromAnotherFileIsDropped() { + assertEquals( + AudioReconcileAction.DropForeignFile, + reconcile(desire(1, fileId = 7), activeFileId = 9), + ) + } + + @Test + fun intentWithoutAFileIsNotTreatedAsForeign() { + assertEquals(AudioReconcileAction.Apply(0), reconcile(desire(1, fileId = null))) + } + + /** + * A transcode delivers a recoded representation that cannot identity-match + * its source. The server saying it delivered the row is what satisfies it — + * otherwise the intent retries an impossible match forever. + */ + @Test + fun absentTrackIsSatisfiedOnlyWhenThePlanNamesIt() { + val transcoded = listOf(MountedAudioTrack(0, null, "audio/mp4a-latm", 2, null)) + + assertEquals( + AudioReconcileAction.Confirm, + reconcile(desire(0), mountedTracks = transcoded, selectedOrdinal = 0, planAudioOrdinal = 0), + ) + assertEquals( + AudioReconcileAction.None, + reconcile(desire(0), mountedTracks = transcoded, selectedOrdinal = 0, planAudioOrdinal = 1), + ) + assertEquals( + AudioReconcileAction.None, + reconcile(desire(0), mountedTracks = transcoded, selectedOrdinal = 0, planAudioOrdinal = null), + ) + } + + /** + * Main mix and commentary share language and codec. Resolving against a + * one-element list let them confirm each other, because the matcher stops + * as soon as one candidate remains. + */ + @Test + fun commentaryDoesNotConfirmTheMainMix() { + val withCommentary = listOf( + AudioTrack(codec = "aac", channels = 2, language = "en", title = "Main", isDefault = true), + AudioTrack(codec = "aac", channels = 2, language = "en", title = "Director Commentary"), + ) + val mountedBoth = listOf( + MountedAudioTrack(0, "en", "audio/mp4a-latm", 2, "Director Commentary"), + MountedAudioTrack(1, "en", "audio/mp4a-latm", 2, "Main"), + ) + + // Wanting Main while Commentary is selected must not confirm. + val action = reconcileDesiredAudioAction( + desired = DesiredAudio(1L, catalogOrdinal = 0, explicit = true, fileId = 1), + activeFileId = 1, + catalog = withCommentary, + mounted = mountedBoth, + selectedOrdinal = 0, + planAudioOrdinal = null, + ) + assertEquals(AudioReconcileAction.Apply(1), action) + } + + /** + * A remount can reorder the groups. The same intent must resolve to the new + * ordinal, and the ordinal that used to be right must not confirm. + */ + @Test + fun aReorderMovesTheTargetAndInvalidatesTheOldOrdinal() { + val reordered = listOf( + MountedAudioTrack(0, "en", "audio/vnd.dts", 6, "English DTS 5.1"), + MountedAudioTrack(1, "nl", "audio/mp4a-latm", 2, "Dutch AAC Stereo"), + ) + // Dutch was mounted 0 before the remount, is mounted 1 after. + assertEquals( + AudioReconcileAction.Apply(1), + reconcile(desire(1), mountedTracks = reordered, selectedOrdinal = 0), + ) + } + + /** A confirmed choice is re-applied after a remount, not assumed to hold. */ + @Test + fun aConfirmedChoiceIsReappliedWhenThePlayerIsNoLongerOnIt() { + assertEquals( + AudioReconcileAction.Apply(0), + reconcile(desire(1, confirmed = true), selectedOrdinal = 1), + ) + } + + @Test + fun anOrdinalOutsideTheCatalogDecidesNothing() { + assertEquals(AudioReconcileAction.None, reconcile(desire(9))) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt new file mode 100644 index 000000000..3d478edc8 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt @@ -0,0 +1,379 @@ +package org.prairieserver.prairie.common.player.video + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.VideoTrack +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo + +class EpisodeSelectionHandoffTest { + @Test + fun sourceUsesResolutionBeforeCodecAndContainer() { + val source = version( + fileId = 101, + resolution = "2160p", + codec = "hevc", + hdr = true, + container = "mkv", + ) + val targets = listOf( + version(201, "1080p", "hevc", hdr = true, container = "mkv"), + version(202, "2160p", "h264", hdr = false, container = "mp4"), + ) + + assertEquals( + 202, + resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets), + ) + } + + @Test + fun sourceUsesCodecDynamicRangeAndContainerAsTieBreakers() { + val source = version( + fileId = 101, + resolution = "2160p", + codec = "hevc", + hdr = true, + container = "mkv", + hdrFormat = "Dolby Vision", + ) + val targets = listOf( + version(201, "2160p", "hevc", hdr = true, container = "mp4", hdrFormat = "Dolby Vision"), + version(202, "2160p", "hevc", hdr = true, container = "mkv", hdrFormat = "Dolby Vision"), + version(203, "2160p", "h264", hdr = true, container = "mkv", hdrFormat = "HDR10"), + ) + + assertEquals( + 202, + resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets), + ) + } + + @Test + fun sourceUsesCodecBeforeDynamicRangeAndContainerWhenCriteriaConflict() { + val source = version( + fileId = 101, + resolution = "2160p", + codec = "hevc", + hdr = true, + container = "mkv", + hdrFormat = "Dolby Vision", + ) + val targets = listOf( + version(201, "2160p", "h264", hdr = true, container = "mkv", hdrFormat = "Dolby Vision"), + version(202, "2160p", "hevc", hdr = false, container = "mp4"), + ) + + assertEquals( + 202, + resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets), + ) + } + + @Test + fun ambiguousBestSourceFallsBackToAutomaticSelection() { + val source = version(101, "2160p", "hevc", hdr = true, container = "mkv") + val targets = listOf( + version(201, "2160p", "hevc", hdr = true, container = "mkv"), + version(202, "2160p", "hevc", hdr = true, container = "mkv"), + ) + + assertNull(resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets)) + } + + @Test + fun unavailableResolutionFallsBackToAutomaticSelection() { + val source = version(101, "2160p", "hevc", hdr = true, container = "mkv") + val targets = listOf( + version(201, "1080p", "hevc", hdr = true, container = "mkv"), + version(202, "720p", "hevc", hdr = true, container = "mkv"), + ) + + assertNull(resolveEpisodeSourceIntent(captureEpisodeSourceIntent(source), targets)) + } + + @Test + fun sourceIntentNeverSerializesTheOriginalFileId() { + val handoff = EpisodeSelectionHandoff( + source = captureEpisodeSourceIntent( + version(4242, "2160p", "hevc", hdr = true, container = "mkv").copy( + fileName = "source-file-name.mkv", + filePath = "/private/source-file-path.mkv", + ), + ), + subtitle = captureEpisodeSubtitleIntent( + selectedTrackIndex = 31, + subtitles = listOf( + PlayerSubtitleInfo( + index = 31, + language = "en", + codec = "srt", + label = "English", + source = "downloaded", + url = "/private/source-subtitle.srt", + downloadId = 3131, + mediaTrackId = "source-media-track-id", + ), + ), + ), + ) + + val encoded = encodeEpisodeSelectionHandoff(handoff) + + assertFalse(encoded.contains("4242")) + assertFalse(encoded.contains("31")) + assertFalse(encoded.contains("3131")) + assertFalse(encoded.contains("source-file-name")) + assertFalse(encoded.contains("source-file-path")) + assertFalse(encoded.contains("source-subtitle")) + assertFalse(encoded.contains("source-media-track-id")) + assertTrue(encoded.contains("2160p")) + } + + @Test + fun explicitSubtitleMatchesSemanticsAtADifferentTargetIndex() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle( + index = 7, + language = "eng", + codec = "application/x-subrip", + label = "English SDH", + source = "external", + forced = false, + ), + ), + ) + val resolved = resolveEpisodeSubtitleIntent( + intent = intent, + targetSubtitles = listOf( + subtitle(2, "en", "srt", "English SDH", "external", forced = false), + subtitle(9, "en", "srt", "English SDH", "embedded", forced = false), + ), + ) + + assertEquals(2, resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun explicitSubtitleKeepsLanguageAndAccessibilityWhenFormatAndSourceChange() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle(7, "en", "srt", "English SDH", "external", forced = false), + ), + ) + + val resolved = resolveEpisodeSubtitleIntent( + intent, + targetSubtitles = listOf( + subtitle(12, "eng", "webvtt", "English SDH", "embedded", forced = false), + ), + ) + + assertEquals(12, resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun subtitleLanguageOutranksFormatAndSourceKind() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle(7, "en", "srt", "English", "external", forced = false), + ), + ) + + val resolved = resolveEpisodeSubtitleIntent( + intent, + targetSubtitles = listOf( + subtitle(2, "fr", "srt", "French", "external", forced = false), + subtitle(9, "eng", "webvtt", "English", "embedded", forced = false), + ), + ) + + assertEquals(9, resolved.trackIndex) + } + + @Test + fun subtitleAccessibilityOutranksFormatAndSourceKind() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle(7, "en", "srt", "English", "external", forced = false), + ), + ) + + val resolved = resolveEpisodeSubtitleIntent( + intent, + targetSubtitles = listOf( + subtitle(2, "en", "srt", "English SDH", "external", forced = false), + subtitle(9, "eng", "webvtt", "English", "embedded", forced = false), + ), + ) + + assertEquals(9, resolved.trackIndex) + } + + @Test + fun ambiguousBestSubtitleUsesProfileAuto() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle(7, "en", "srt", "English", "external", forced = false), + ), + ) + + val resolved = resolveEpisodeSubtitleIntent( + intent, + targetSubtitles = listOf( + subtitle(2, "eng", "srt", "English", "external", forced = false), + subtitle(9, "en", "srt", "English", "external", forced = false), + ), + ) + + assertNull(resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun underSpecifiedTrackIntentUsesProfileAuto() { + val resolved = resolveEpisodeSubtitleIntent( + EpisodeSubtitleIntent(mode = EpisodeSubtitleMode.TRACK), + targetSubtitles = listOf( + subtitle(2, "en", "srt", "English", "external", forced = false), + ), + ) + + assertNull(resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun explicitNonSdhSubtitleDoesNotResolveToAnSdhTarget() { + val intent = captureEpisodeSubtitleIntent( + selectedTrackIndex = 7, + subtitles = listOf( + subtitle(7, "en", "srt", "English", "external", forced = false), + ), + ) + + val resolved = resolveEpisodeSubtitleIntent( + intent, + targetSubtitles = listOf( + subtitle(2, "en", "srt", "English", "external", forced = false), + subtitle(9, "en", "srt", "English SDH", "external", forced = false), + ), + ) + + assertEquals(2, resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun explicitOffRemainsOff() { + val resolved = resolveEpisodeSubtitleIntent( + EpisodeSubtitleIntent.off(), + targetSubtitles = emptyList(), + ) + + assertEquals(-1, resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun automaticSubtitleRemainsUnspecified() { + val resolved = resolveEpisodeSubtitleIntent( + EpisodeSubtitleIntent.auto(), + targetSubtitles = emptyList(), + ) + + assertNull(resolved.trackIndex) + assertFalse(resolved.intentSpecified) + } + + @Test + fun unavailableExplicitSubtitleUsesProfileAutoWithoutTargetDurableRestore() { + val resolved = resolveEpisodeSubtitleIntent( + EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "nl", + codecFamily = "subrip", + external = true, + ), + targetSubtitles = listOf( + subtitle(4, "en", "srt", "English", "external", forced = false), + ), + ) + + assertNull(resolved.trackIndex) + assertTrue(resolved.intentSpecified) + } + + @Test + fun malformedPayloadDecodesToNull() { + assertNull(decodeEpisodeSelectionHandoff("{not-json")) + } + + @Test + fun encodedPayloadRoundTripsSemanticIntentAndRestoresAutoDefault() { + val handoff = EpisodeSelectionHandoff( + source = captureEpisodeSourceIntent( + version(101, "2160p", "hevc", hdr = true, container = "mkv"), + ), + subtitle = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "en", + codecFamily = "subrip", + external = true, + ), + ) + + assertEquals(handoff, decodeEpisodeSelectionHandoff(encodeEpisodeSelectionHandoff(handoff))) + assertEquals(EpisodeSubtitleIntent.auto(), decodeEpisodeSelectionHandoff("{}")?.subtitle) + } + + private fun version( + fileId: Int, + resolution: String, + codec: String, + hdr: Boolean, + container: String, + hdrFormat: String? = null, + ) = FileVersion( + fileId = fileId, + resolution = resolution, + codecVideo = codec, + hdr = hdr, + container = container, + videoTracks = listOf( + VideoTrack( + codec = codec, + hdr = hdr, + hdrFormat = hdrFormat, + ), + ), + ) + + private fun subtitle( + index: Int, + language: String, + codec: String, + label: String, + source: String, + forced: Boolean, + ) = PlayerSubtitleInfo( + index = index, + language = language, + codec = codec, + label = label, + source = source, + forced = forced, + url = "/subtitles/$index", + ) +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatchingTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatchingTest.kt new file mode 100644 index 000000000..4d9a73926 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioMatchingTest.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Strings here are the real ones observed on an NVIDIA Shield playing a + * two-audio-track fixture: the catalog says `dts` / `aac` while Media3 reports + * `audio/vnd.dts` / `audio/mp4a-latm`, and the catalog says `en` / `nl`. + */ +class MountedAudioMatchingTest { + + private val englishDts = AudioTrack( + codec = "dts", channels = 6, language = "en", title = "English DTS 5.1", isDefault = true, + ) + private val dutchAac = AudioTrack( + codec = "aac", channels = 2, language = "nl", title = "Dutch AAC Stereo", + ) + + private val mountedBoth = listOf( + MountedAudioTrack( + ordinal = 0, language = "nl", codecOrMime = "audio/mp4a-latm", + channelCount = 2, label = "Dutch AAC Stereo", + ), + MountedAudioTrack( + ordinal = 1, language = "en", codecOrMime = "audio/vnd.dts", + channelCount = 6, label = "English DTS 5.1", + ), + ) + + @Test + fun matchesAcrossCatalogAndMedia3Spellings() { + assertEquals(1, matchMountedAudioTrack(englishDts, mountedBoth)?.ordinal) + assertEquals(0, matchMountedAudioTrack(dutchAac, mountedBoth)?.ordinal) + } + + /** + * Media3 ordinals are not catalog ordinals. Here the mounted order is the + * reverse of the catalog's, so a positional answer would be wrong both ways. + */ + @Test + fun resolvesByIdentityNotPosition() { + assertEquals(1, matchMountedAudioTrack(englishDts, mountedBoth)?.ordinal) + } + + /** + * The transcode case, and the reason this must not be greedy: a DTS 5.1 + * source delivered as undetermined-language stereo AAC is NOT that track. + * Returning it would play the wrong audio and skip the replan that was the + * actual fix. + */ + @Test + fun transcodedRepresentationDoesNotMatchItsSource() { + val delivered = listOf( + MountedAudioTrack( + ordinal = 0, language = null, codecOrMime = "audio/mp4a-latm", + channelCount = 2, label = null, + ), + ) + assertNull(matchMountedAudioTrack(englishDts, delivered)) + } + + @Test + fun ambiguousCandidatesReturnNullRatherThanGuessing() { + val twoIdenticalEnglish = listOf( + MountedAudioTrack(0, "en", "audio/mp4a-latm", 2, null), + MountedAudioTrack(1, "en", "audio/mp4a-latm", 2, null), + ) + val catalog = AudioTrack(codec = "aac", channels = 2, language = "en") + assertNull(matchMountedAudioTrack(catalog, twoIdenticalEnglish)) + } + + /** Title is the only thing separating a commentary from the main mix. */ + @Test + fun titleBreaksAnOtherwiseIdenticalTie() { + val mounted = listOf( + MountedAudioTrack(0, "en", "audio/mp4a-latm", 2, "Main"), + MountedAudioTrack(1, "en", "audio/mp4a-latm", 2, "Director Commentary"), + ) + val commentary = AudioTrack( + codec = "aac", channels = 2, language = "en", title = "Director Commentary", + ) + assertEquals(1, matchMountedAudioTrack(commentary, mounted)?.ordinal) + } + + @Test + fun emptyMountedListNeverMatches() { + assertNull(matchMountedAudioTrack(englishDts, emptyList())) + } + + @Test + fun codecFamilyCanonicalisesBothSides() { + assertEquals("aac", canonicalAudioCodecFamily("aac")) + assertEquals("aac", canonicalAudioCodecFamily("audio/mp4a-latm")) + assertEquals("aac", canonicalAudioCodecFamily("mp4a.40.2")) + assertEquals("dts", canonicalAudioCodecFamily("dts")) + assertEquals("dts", canonicalAudioCodecFamily("audio/vnd.dts")) + assertEquals("eac3", canonicalAudioCodecFamily("audio/eac3")) + assertEquals("eac3", canonicalAudioCodecFamily("ec-3")) + assertEquals("ac3", canonicalAudioCodecFamily("audio/ac3")) + assertEquals("truehd", canonicalAudioCodecFamily("audio/true-hd")) + assertNull(canonicalAudioCodecFamily(null)) + assertNull(canonicalAudioCodecFamily(" ")) + } + + /** A catalog language with no mounted counterpart must not match blindly. */ + @Test + fun languageMismatchIsNotAMatch() { + val onlyDutch = listOf( + MountedAudioTrack(0, "nl", "audio/mp4a-latm", 2, "Dutch AAC Stereo"), + ) + assertNull(matchMountedAudioTrack(englishDts, onlyDutch)) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioReorderTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioReorderTest.kt new file mode 100644 index 000000000..8e40f34e1 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/MountedAudioReorderTest.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.common.player.video + +import org.prairieserver.prairie.model.catalog.AudioTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A remount can hand back the same tracks in a different order. Confirming a + * pending audio switch by comparing ordinals would then either commit the wrong + * language or strand the request forever, so confirmation has to re-resolve + * identity against the current snapshot — which is what these cases model. + */ +class MountedAudioReorderTest { + + private val dutch = AudioTrack( + codec = "aac", channels = 2, language = "nl", title = "Dutch AAC Stereo", + ) + + private val beforeRemount = listOf( + MountedAudioTrack(0, "nl", "audio/mp4a-latm", 2, "Dutch AAC Stereo"), + MountedAudioTrack(1, "en", "audio/vnd.dts", 6, "English DTS 5.1"), + ) + + /** Same tracks, opposite order: the ordinal that meant Dutch now means English. */ + private val afterRemount = listOf( + MountedAudioTrack(0, "en", "audio/vnd.dts", 6, "English DTS 5.1"), + MountedAudioTrack(1, "nl", "audio/mp4a-latm", 2, "Dutch AAC Stereo"), + ) + + @Test + fun theSameChoiceResolvesToADifferentOrdinalAfterAReorder() { + assertEquals(0, matchMountedAudioTrack(dutch, beforeRemount)?.ordinal) + assertEquals(1, matchMountedAudioTrack(dutch, afterRemount)?.ordinal) + } + + /** + * Confirmation asks "is the selected track the one I wanted", by identity. + * Ordinal 0 satisfies that before the reorder and must not after it. + */ + @Test + fun identityConfirmationSurvivesAReorderThatOrdinalComparisonWouldNotMatch() { + val selectedOrdinalZeroBefore = beforeRemount.first { it.ordinal == 0 } + val selectedOrdinalZeroAfter = afterRemount.first { it.ordinal == 0 } + + assertEquals(0, matchMountedAudioTrack(dutch, listOf(selectedOrdinalZeroBefore))?.ordinal) + assertNull( + matchMountedAudioTrack(dutch, listOf(selectedOrdinalZeroAfter)), + "ordinal 0 is English after the reorder and must not confirm a Dutch request", + ) + } + + /** An empty or partial snapshot resolves nothing, so the intent stays live. */ + @Test + fun partialSnapshotsResolveNothingRatherThanMisResolving() { + assertNull(matchMountedAudioTrack(dutch, emptyList())) + assertNull( + matchMountedAudioTrack( + dutch, + listOf(MountedAudioTrack(0, "en", "audio/vnd.dts", 6, "English DTS 5.1")), + ), + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetectorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetectorTest.kt index 2a24de199..9cf95ce4d 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetectorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PlaybackStartupStallDetectorTest.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.common.player.video import org.prairieserver.prairie.common.player.Playability +import org.prairieserver.prairie.model.playback.CLIENT_DV7_TO_HDR10 import org.prairieserver.prairie.model.playback.PlayMethod import kotlin.test.Test import kotlin.test.assertEquals @@ -359,6 +360,138 @@ class PlaybackStartupStallDetectorTest { assertNotNull(detector.sample("session", 1_101, true, false, true, 1_000, 7_000)) } + @Test + fun dv7ClientTransformStallUsesDedicatedDeadlineAndClassification() { + val detector = PlaybackStartupStallDetector( + startupGraceMs = 20_000, + clientTransformGraceMs = 10_000, + ) + detector.onMounted( + sessionKey = "dv7", + playMethod = PlayMethod.DIRECT, + startPositionMs = 0, + nowMs = 0, + clientTransformations = listOf(CLIENT_DV7_TO_HDR10), + ) + detector.onFirstFrameRendered() + + assertNull( + detector.sample( + sessionKey = "dv7", + nowMs = 100, + playWhenReady = true, + isPlaying = false, + isBuffering = true, + currentPositionMs = 0, + bufferedPositionMs = 0, + decoderInputBufferCount = 1, + decoderRenderedOutputBufferCount = 1, + ), + ) + assertNull(detector.sample("dv7", 10_100, true, false, true, 0, 0, 1, 1)) + assertEquals( + PlaybackStartupStallDetector.DV7_TRANSFORM_STALL_CLASSIFICATION, + detector.sample("dv7", 10_101, true, false, true, 0, 0, 1, 1)?.classification, + ) + } + + @Test + fun dv7ClientTransformProgressReanchorsDedicatedDeadline() { + val detector = PlaybackStartupStallDetector( + startupGraceMs = 30_000, + clientTransformGraceMs = 10_000, + ) + detector.onMounted( + sessionKey = "dv7-progress", + playMethod = PlayMethod.DIRECT, + startPositionMs = 0, + nowMs = 0, + clientTransformations = listOf(CLIENT_DV7_TO_HDR10), + ) + detector.onFirstFrameRendered() + + assertNull(detector.sample("dv7-progress", 100, true, false, true, 0, 0, 1, 1)) + assertNull(detector.sample("dv7-progress", 5_000, true, true, false, 1_600, 5_000, 5, 5)) + assertNull(detector.sample("dv7-progress", 15_000, true, false, true, 1_600, 1_600, 5, 5)) + assertEquals( + PlaybackStartupStallDetector.DV7_TRANSFORM_STALL_CLASSIFICATION, + detector.sample("dv7-progress", 15_001, true, false, true, 1_600, 1_600, 5, 5) + ?.classification, + ) + } + + @Test + fun dv7AudioPositionProgressDoesNotReanchorTransformDeadline() { + val detector = PlaybackStartupStallDetector( + startupGraceMs = 30_000, + clientTransformGraceMs = 10_000, + ) + detector.onMounted( + sessionKey = "dv7-audio-only", + playMethod = PlayMethod.DIRECT, + startPositionMs = 0, + nowMs = 0, + clientTransformations = listOf(CLIENT_DV7_TO_HDR10), + ) + detector.onFirstFrameRendered() + + assertNull(detector.sample("dv7-audio-only", 100, true, true, false, 0, 5_000, 1, 1)) + assertNull(detector.sample("dv7-audio-only", 5_000, true, true, false, 1_600, 6_000, 1, 1)) + assertNull(detector.sample("dv7-audio-only", 10_100, true, true, false, 3_200, 7_000, 1, 1)) + assertEquals( + PlaybackStartupStallDetector.DV7_TRANSFORM_STALL_CLASSIFICATION, + detector.sample("dv7-audio-only", 10_101, true, true, false, 4_800, 8_000, 1, 1) + ?.classification, + ) + } + + @Test + fun dv7BackwardSeekReanchorsTransformDeadlineWithoutDecoderProgress() { + val detector = PlaybackStartupStallDetector( + startupGraceMs = 30_000, + clientTransformGraceMs = 10_000, + ) + detector.onMounted( + sessionKey = "dv7-backward-seek", + playMethod = PlayMethod.DIRECT, + startPositionMs = 0, + nowMs = 0, + clientTransformations = listOf(CLIENT_DV7_TO_HDR10), + ) + detector.onFirstFrameRendered() + + assertNull(detector.sample("dv7-backward-seek", 100, true, true, false, 5_000, 8_000, 1, 1)) + assertNull(detector.sample("dv7-backward-seek", 9_000, true, true, false, 10_000, 13_000, 1, 1)) + assertNull(detector.sample("dv7-backward-seek", 9_001, true, true, false, 2_000, 5_000, 1, 1)) + assertNull(detector.sample("dv7-backward-seek", 19_001, true, true, false, 2_000, 5_000, 1, 1)) + assertEquals( + PlaybackStartupStallDetector.DV7_TRANSFORM_STALL_CLASSIFICATION, + detector.sample("dv7-backward-seek", 19_002, true, true, false, 2_000, 5_000, 1, 1) + ?.classification, + ) + } + + @Test + fun dv7RouteWithoutDecoderEvidenceKeepsTransportDeadline() { + val detector = PlaybackStartupStallDetector( + startupGraceMs = 20_000, + clientTransformGraceMs = 10_000, + ) + detector.onMounted( + sessionKey = "dv7-no-input", + playMethod = PlayMethod.DIRECT, + startPositionMs = 0, + nowMs = 0, + clientTransformations = listOf(CLIENT_DV7_TO_HDR10), + ) + + assertNull(detector.sample("dv7-no-input", 10_001, true, false, true, 0, 0)) + assertEquals( + "transport_stall", + detector.sample("dv7-no-input", 20_001, true, false, true, 0, 0)?.classification, + ) + } + @Test fun newMountResetsSignalState() { val detector = PlaybackStartupStallDetector(startupGraceMs = 10_000) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetectorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetectorTest.kt index 7ee8321aa..42535c0bb 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetectorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/PostResumeVideoStallDetectorTest.kt @@ -49,7 +49,6 @@ class PostResumeVideoStallDetectorTest { detector.onIsPlayingChanged("session", true, 100, 0, 0) assertNull(detector.sample("session", 2_000, true, true, true, 1_500, 60_000, 0)) - detector.onFirstFrameRendered() detector.onIsPlayingChanged("session", false, 2_100, 58_000, 20) detector.onIsPlayingChanged("session", true, 2_200, 58_000, 20) assertNull(detector.sample("session", 4_000, true, true, true, 59_000, 60_000, 20)) diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinatorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinatorTest.kt index 37dceb781..13a806f21 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinatorTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/VideoTrackSelectionCoordinatorTest.kt @@ -23,12 +23,16 @@ class VideoTrackSelectionCoordinatorTest { fun selectingExternalSubtitleRemountsAndPreservesPositionThroughSharedRefresh() { val source = sourceFile.readText() + assertTrue( + source.contains("val mountedMediaSpec = mediaSpec ?: return false"), + "external subtitle selection must report not-ready instead of remounting before media exists", + ) assertTrue( source.contains("refreshMountedVideoMedia("), "external subtitle selection must remount via the shared refresh helper", ) assertTrue( - source.contains("mediaSpec.copy(subtitles = listOf(subtitle))"), + source.contains("mountedMediaSpec.copy(subtitles = listOf(subtitle))"), "external subtitle selection must remount with the selected subtitle configuration", ) } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt index e68e78341..845f90201 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt @@ -3,10 +3,16 @@ package org.prairieserver.prairie.common.settings import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import org.prairieserver.prairie.model.settings.EffectiveSetting -import org.prairieserver.prairie.model.settings.EffectiveSettingsResponse +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.network.ApiResult @@ -18,6 +24,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import org.junit.Before import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -69,12 +79,94 @@ class AndroidPlayerSettingsStoreTest { ) } + /** + * A store whose backing DataStore already holds [seed]. + * + * The seeding goes through the SAME DataStore instance the store will use — + * DataStore refuses two live instances over one file, so writing the + * fixture through a second handle throws rather than setting up the state + * under test. + */ + private suspend fun newStoreSeededWith( + seed: suspend (androidx.datastore.preferences.core.MutablePreferences) -> Unit, + ): AndroidPlayerSettingsStore { + val shared = PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "ds_seeded.preferences_pb") }, + ) + shared.edit { prefs -> seed(prefs) } + return AndroidPlayerSettingsStore( + context = mockContextStub(), + legacyCache = fakeLegacyCache, + getActiveProfileId = { activeProfileId }, + getServerUrl = { serverUrl }, + serverSettingsFlusher = fakeFlusher, + scope = TestScope(), + profileChangeSignal = flowOf(Unit), + getDeviceId = { null }, + dataStoreFactory = { shared }, + ) + } + @Test - fun `setAutoSkipIntro updates flow value`() = runTest { + fun `setIntroSkipMode updates the mode flow and the boolean projected from it`() = runTest { val store = newStore() + assertEquals(IntroSkipMode.ASK, store.introSkipModeFlow.first()) assertEquals(false, store.autoSkipIntroFlow.first()) - store.setAutoSkipIntro(true) + + store.setIntroSkipMode(IntroSkipMode.ALWAYS) + assertEquals(IntroSkipMode.ALWAYS, store.introSkipModeFlow.first()) assertEquals(true, store.autoSkipIntroFlow.first()) + + // The mode the boolean could never express degrades to its `false`. + store.setIntroSkipMode(IntroSkipMode.NEVER) + assertEquals(IntroSkipMode.NEVER, store.introSkipModeFlow.first()) + assertEquals(false, store.autoSkipIntroFlow.first()) + } + + @Test + fun `the deprecated boolean setter writes the enum that superseded it`() = runTest { + val store = newStore() + store.setAutoSkipIntro(true) + assertEquals(IntroSkipMode.ALWAYS, store.introSkipModeFlow.first()) + assertTrue( + fakeFlusher.calls.any { + it.key == PlaybackSettingsKeys.IntroSkipMode && it.value == "always" + }, + ) + assertFalse( + fakeFlusher.calls.any { it.key == PlaybackSettingsKeys.AutoSkipIntro }, + "writing the boolean too would let the server's lossy mirror rewrite a `never`", + ) + } + + @Test + fun `a server that does not know the enum falls back to the boolean it does`() = runTest { + // The revision < 7 case: the effective-values response answers + // auto_skip_intro and says nothing about intro_skip_mode, so the local + // enum slot stays empty and the boolean decides. + val api = FakeSettingsApi( + effective = mapOf( + defaulted(PlaybackSettingsKeys.AutoSkipIntro, JsonPrimitive(true)), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.refreshFromServer() + assertEquals(IntroSkipMode.ALWAYS, store.introSkipModeFlow.first()) + } + + @Test + fun `a revision 7 server hydrates the enum, which outranks the mirrored boolean`() = runTest { + val api = FakeSettingsApi( + effective = mapOf( + // What the server's write mirror produces for `never`: the + // boolean cannot say it, so it degrades to false. + defaulted(PlaybackSettingsKeys.AutoSkipIntro, JsonPrimitive(false)), + defaulted(PlaybackSettingsKeys.IntroSkipMode, JsonPrimitive("never")), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.refreshFromServer() + assertEquals(IntroSkipMode.NEVER, store.introSkipModeFlow.first()) } @Test @@ -151,12 +243,12 @@ class AndroidPlayerSettingsStoreTest { @Test fun `flush enqueue is called on each setter`() = runTest { val store = newStore() - store.setAutoSkipIntro(true) + store.setIntroSkipMode(IntroSkipMode.ALWAYS) store.setPreferredQuality("720p") store.setPlaybackSpeed(1.5) val calls = fakeFlusher.calls - assertTrue(calls.any { it.key == PlaybackSettingsKeys.AutoSkipIntro && it.value == "true" }) + assertTrue(calls.any { it.key == PlaybackSettingsKeys.IntroSkipMode && it.value == "always" }) assertTrue(calls.any { it.key == PlaybackSettingsKeys.PreferredQuality && it.value == "720p" }) assertTrue(calls.any { it.key == PlaybackSettingsKeys.PlaybackSpeed && it.value == "1.5" }) assertTrue(calls.all { it.profileId == activeProfileId }) @@ -233,18 +325,33 @@ class AndroidPlayerSettingsStoreTest { assertEquals(-10000, store.subtitleSyncMsFlow.first()) } + @Test + fun `subtitleSyncMs writes the canonical remote device setting`() = runTest { + val store = newStore() + + store.setSubtitleSyncMs(-1900) + + assertEquals(-1900, store.subtitleSyncMsFlow.first()) + val call = fakeFlusher.calls.last() + assertEquals(activeProfileId, call.profileId) + assertEquals(PlaybackSettingsKeys.SubtitleSyncMs, call.key) + assertEquals("-1900", call.value) + assertEquals(serverUrl, call.serverUrl) + assertFalse(call.isDelete) + } + // ---- Server-sync surface ------------------------------------------ @Test - fun `refreshFromServer populates flows from effective settings response`() = runTest { + fun `refreshFromServer populates flows from batched effective values`() = runTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.AutoSkipIntro to "true", - PlaybackSettingsKeys.AutoPlayNext to "false", - PlaybackSettingsKeys.PreferredQuality to "1080p", - PlaybackSettingsKeys.AudioSyncMs to "120", - PlaybackSettingsKeys.PlaybackSpeed to "1.5", + stored(PlaybackSettingsKeys.AutoSkipIntro, JsonPrimitive(true)), + stored(PlaybackSettingsKeys.AutoPlayNext, JsonPrimitive(false)), + stored(PlaybackSettingsKeys.PreferredQuality, JsonPrimitive("1080p")), + stored(PlaybackSettingsKeys.AudioSyncMs, JsonPrimitive(120)), + stored(PlaybackSettingsKeys.PlaybackSpeed, JsonPrimitive(1.5)), ), ), ) @@ -257,6 +364,70 @@ class AndroidPlayerSettingsStoreTest { assertEquals(1.5, store.playbackSpeedFlow.first(), 0.0) } + @Test + fun `refreshFromServer requests only server-stored keys`() = runTest { + val api = FakeSettingsApi() + val store = newStore(repository = SettingsRepository(api)) + store.refreshFromServer() + val remote = SettingKeys.REMOTE.toSet() + assertTrue(api.requestedKeys.isNotEmpty()) + assertTrue( + api.requestedKeys.all { it in remote }, + "asked for non-contract keys: ${api.requestedKeys.filterNot { it in remote }}", + ) + } + + @Test + fun `refreshFromServer applies contract defaults over stale local overrides`() = runTest { + // The canonical endpoint answers every known key; one nothing is + // stored for comes back as the contract default with source + // "default". A stale local override (say the server-side value was + // reset from another device) must hydrate back to that default + // rather than surviving locally. + val api = FakeSettingsApi( + effective = mapOf( + defaulted(PlaybackSettingsKeys.IntroSkipMode, JsonPrimitive("ask")), + defaulted(PlaybackSettingsKeys.NextUpPromptSeconds, JsonPrimitive(30)), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setIntroSkipMode(IntroSkipMode.ALWAYS) + store.setNextUpPromptSeconds(90) + + store.refreshFromServer() + + assertEquals(IntroSkipMode.ASK, store.introSkipModeFlow.first()) + assertEquals(30, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `refreshFromServer keeps local value for keys this server does not know`() = runTest { + // A key absent from the response entirely means the server's + // contract revision predates it — not that it was reset. + val api = FakeSettingsApi(effective = emptyMap()) + val store = newStore(repository = SettingsRepository(api)) + store.setIntroSkipMode(IntroSkipMode.NEVER) + + store.refreshFromServer() + + assertEquals(IntroSkipMode.NEVER, store.introSkipModeFlow.first()) + } + + @Test + fun `refreshFromServer maps JSON null to the local no-preference spelling`() = runTest { + val api = FakeSettingsApi( + effective = mapOf( + defaulted(PlaybackSettingsKeys.AudioLanguage, JsonNull), + ), + ) + val store = newStore(repository = SettingsRepository(api)) + store.setAudioLanguage("de") + + store.refreshFromServer() + + assertEquals("", store.audioLanguageFlow.first()) + } + @Test fun `refreshFromServer no-ops when repository is null`() = runTest { val store = newStore(repository = null) @@ -269,9 +440,12 @@ class AndroidPlayerSettingsStoreTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to SubtitleAppearance.DEFAULT.toJsonString(), + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + scope = SettingScope.PROFILE_DEVICE.wire, + ), ), - hasDeviceOverride = setOf(PlaybackSettingsKeys.SubtitleAppearance), ), ) val store = newStore(repository = repo) @@ -281,36 +455,52 @@ class AndroidPlayerSettingsStoreTest { } @Test - fun `refreshFromServer clears override flag when subtitle entry absent`() = runTest { - // First refresh: server reports a device override; flag goes true. + fun `refreshFromServer clears override flag when appearance no longer resolves from this device`() = runTest { + // First refresh: the appearance resolves from profile_device; flag + // goes true. val api = FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to SubtitleAppearance.DEFAULT.toJsonString(), + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + scope = SettingScope.PROFILE_DEVICE.wire, + ), ), - hasDeviceOverride = setOf(PlaybackSettingsKeys.SubtitleAppearance), ) val store = newStore(repository = SettingsRepository(api)) store.refreshFromServer() assertTrue(store.subtitleUsesDeviceOverrideFlow.first()) - // Server stops returning the entry — e.g. another device cleared - // the override out-of-band. Flag must go false on the next - // refresh; iOS parity in `applyEffectiveSettings`'s `else` branch. - api.effective = emptyMap() - api.hasDeviceOverride = emptySet() + // Another device cleared the override out-of-band; the value now + // resolves from the contract default. Flag must go false on the + // next refresh; iOS parity in `applyEffectiveSettings`'s `else` + // branch. + api.effective = mapOf( + defaulted( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(SubtitleAppearance.DEFAULT.toJsonString()), + ), + ) store.refreshFromServer() assertFalse(store.subtitleUsesDeviceOverrideFlow.first()) } @Test - fun `resetAllDeviceSettings enqueues delete for every device key`() = runTest { + fun `resetAllDeviceSettings enqueues delete for every server-stored device key`() = runTest { val repo = SettingsRepository(FakeSettingsApi()) val store = newStore(repository = repo) store.resetAllDeviceSettings() val deletedKeys = fakeFlusher.calls.filter { it.isDelete }.map { it.key }.toSet() - for (key in PlaybackSettingsKeys.DeviceSettings) { + val remote = SettingKeys.REMOTE.toSet() + for (key in PlaybackSettingsKeys.DeviceSettings.filter { it in remote }) { assertTrue(deletedKeys.contains(key), "expected delete for $key") } + // The granular subtitle.* fields live inside the composite + // playback.subtitle_appearance object; deleting them individually + // would be refused as unknown_setting. + for (key in PlaybackSettingsKeys.DeviceSettings.filterNot { it in remote }) { + assertFalse(deletedKeys.contains(key), "must not delete non-contract key $key") + } } @Test @@ -337,7 +527,13 @@ class AndroidPlayerSettingsStoreTest { val repo = SettingsRepository( FakeSettingsApi( effective = mapOf( - PlaybackSettingsKeys.SubtitleAppearance to fallback.toJsonString(), + // Resolves from the profile scope once the device + // override is deleted. + stored( + PlaybackSettingsKeys.SubtitleAppearance, + Json.parseToJsonElement(fallback.toJsonString()), + scope = SettingScope.PROFILE.wire, + ), ), ), ) @@ -357,6 +553,165 @@ class AndroidPlayerSettingsStoreTest { assertEquals(custom, store.subtitleAppearanceFlow.first()) } + @Test + fun `setQuality writes both axes and flushes them together`() = runTest { + val store = newStore() + store.setQuality("1080p", 10000) + + assertEquals("1080p", store.preferredQualityFlow.first()) + assertEquals(10000, store.maxBitrateKbpsFlow.first()) + val flushed = fakeFlusher.calls.filterNot { it.isDelete }.associate { it.key to it.value } + assertEquals("1080p", flushed[PlaybackSettingsKeys.PreferredQuality]) + assertEquals("10000", flushed[PlaybackSettingsKeys.MaxBitrateKbps]) + } + + @Test + fun `an uncapped preset stores no bitrate`() = runTest { + val store = newStore() + store.setQuality("1080p", 6000) + store.setQuality("original", null) + + // null is uncapped, which the store spells as 0 — outside the + // contract's range, so it can never read back as a real cap. + assertEquals(null, store.maxBitrateKbpsFlow.first()) + assertEquals( + "0", + fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.MaxBitrateKbps }.value, + ) + } + + @Test + fun `a legacy compound quality normalizes on read and on write`() = runTest { + val store = newStore() + // The bitrate a compound value encoded lives on its own axis now; + // handing "1080p-high" to the player or the server would be refused. + store.setPreferredQuality("1080p-high") + + assertEquals("1080p", store.preferredQualityFlow.first()) + assertEquals( + "1080p", + fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.PreferredQuality }.value, + ) + } + + @Test + fun `a refresh resolving no bitrate clears a stale local cap`() = runTest { + val repo = SettingsRepository( + FakeSettingsApi( + effective = mapOf( + stored(PlaybackSettingsKeys.PreferredQuality, JsonPrimitive("720p")), + defaulted(PlaybackSettingsKeys.MaxBitrateKbps, JsonNull), + ), + ), + ) + val store = newStore(repository = repo) + store.setQuality("1080p", 10000) + + store.refreshFromServer() + + assertEquals("720p", store.preferredQualityFlow.first()) + assertEquals( + null, + store.maxBitrateKbpsFlow.first(), + "a null bitrate must clear the cap, not leave the previous one throttling playback", + ) + } + + @Test + fun `a value stored under a pre-cutover key name survives the rename`() = runTest { + // The upgrade case. Both keys were renamed by the settings cutover, and + // both read local-first — subtitle appearance drives downloaded + // playback with no server in the loop — so an orphaned slot is a + // silently reverted preference, not just a stale cache. + val appearance = SubtitleAppearance.DEFAULT + .copy(fontSize = SubtitleFontSizePreset.XXLarge) + .toJsonString() + val store = newStoreSeededWith { prefs -> + prefs[stringPreferencesKey("subtitle_appearance")] = appearance + prefs[intPreferencesKey("player.next_up_prompt_seconds")] = 12 + } + + assertEquals(SubtitleFontSizePreset.XXLarge, store.subtitleAppearanceFlow.first().fontSize) + assertEquals(12, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `the rename migration never overwrites a value already under the new key`() = runTest { + // A canonical refresh or a fresh edit outranks whatever the pre-rename + // build left on disk; copying over it would revert the newer value. + val store = newStoreSeededWith { prefs -> + prefs[intPreferencesKey("player.next_up_prompt_seconds")] = 12 + prefs[intPreferencesKey(PlaybackSettingsKeys.NextUpPromptSeconds)] = 45 + } + + assertEquals(45, store.nextUpPromptSecondsFlow.first()) + } + + @Test + fun `writes are stamped with the server they were authored against`() = runTest { + // The flusher is application-scoped and its requests are relative, so a + // queued op that outlives a server switch can only be told apart by the + // origin the store stamps on it here. + val store = newStore() + store.setIntroSkipMode(IntroSkipMode.ALWAYS) + + val call = fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.IntroSkipMode } + assertEquals(serverUrl, call.serverUrl) + } + + @Test + fun `a granular subtitle field projects into the composite on flush`() = runTest { + // A granular slot written WITHOUT a composite write — the state an + // upgrading user lands in, because ensureMigrated imports each legacy + // `subtitle.*` value straight into its granular slot. The contract has + // no key for those fields, so this is the only path that carries them + // to the server. + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleFontSize, "xxlarge") + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleTextColor, "#ff0000") + val store = newStore() + // Touch a flow so the migration import runs, then start clean. + store.subtitleAppearanceFlow.first() + fakeFlusher.calls.clear() + + store.flushProjectedSubtitleAppearance() + + val enqueued = fakeFlusher.calls.lastOrNull { it.key == PlaybackSettingsKeys.SubtitleAppearance } + assertTrue( + enqueued != null, + "a granular edit that never reaches the composite is device-local forever", + ) + val sent = SubtitleAppearance.decode(enqueued.value.orEmpty()) + assertEquals(SubtitleFontSizePreset.XXLarge, sent.fontSize) + assertEquals("#ff0000", sent.fontColor) + } + + @Test + fun `a granular subtitle field overlays the composite on read`() = runTest { + // The other half of the projection: until the flush catches up, the + // granular slot is the newer edit and reads must show it, or the + // settings screen renders the value the user just replaced. + fakeLegacyCache.putString(serverUrl, PlaybackSettingsKeys.SubtitleFontSize, "small") + val store = newStore() + + assertEquals(SubtitleFontSizePreset.Small, store.subtitleAppearanceFlow.first().fontSize) + } + + @Test + fun `flushing an unchanged projection enqueues nothing`() = runTest { + val store = newStore() + store.setSubtitleAppearance( + SubtitleAppearance.DEFAULT.copy(fontSize = SubtitleFontSizePreset.XXLarge), + ) + fakeFlusher.calls.clear() + + store.flushProjectedSubtitleAppearance() + + assertTrue( + fakeFlusher.calls.none { it.key == PlaybackSettingsKeys.SubtitleAppearance }, + "an unchanged projection must not enqueue a redundant write", + ) + } + @Test fun `flushPendingDeviceSettings delegates to flusher flushNow`() = runTest { val store = newStore() @@ -376,15 +731,21 @@ class AndroidPlayerSettingsStoreTest { } private class FakeServerSettingsFlusher : ServerSettingsFlusher { - data class Call(val profileId: String, val key: String, val value: String?, val isDelete: Boolean) + data class Call( + val profileId: String, + val key: String, + val value: String?, + val isDelete: Boolean, + val serverUrl: String, + ) val calls = mutableListOf() var flushNowCount: Int = 0 - override fun enqueue(profileId: String, key: String, value: String) { - calls.add(Call(profileId, key, value, isDelete = false)) + override fun enqueue(profileId: String, key: String, value: String, serverUrl: String) { + calls.add(Call(profileId, key, value, isDelete = false, serverUrl = serverUrl)) } - override fun enqueueDelete(profileId: String, key: String) { - calls.add(Call(profileId, key, value = null, isDelete = true)) + override fun enqueueDelete(profileId: String, key: String, serverUrl: String) { + calls.add(Call(profileId, key, value = null, isDelete = true, serverUrl = serverUrl)) } override suspend fun flushNow() { @@ -392,28 +753,41 @@ private class FakeServerSettingsFlusher : ServerSettingsFlusher { } } -/** Stub SettingsApi returning canned effective values; HttpClient never used. */ +/** One canned entry: a value stored at [scope] (default: profile_device). */ +private fun stored( + key: String, + value: JsonElement, + scope: String = SettingScope.PROFILE_DEVICE.wire, +): Pair = + key to EffectiveSettingValue(key = key, value = value, source = scope, scope = scope) + +/** One canned entry resolving to the contract default (nothing stored). */ +private fun defaulted(key: String, value: JsonElement): Pair = + key to EffectiveSettingValue( + key = key, + value = value, + source = EffectiveSettingValue.SOURCE_DEFAULT, + ) + +/** Stub SettingsApi returning canned canonical effective values; HttpClient never used. */ private class FakeSettingsApi( - effective: Map = emptyMap(), - hasDeviceOverride: Set = emptySet(), + effective: Map = emptyMap(), ) : SettingsApi(HttpClient()) { // Mutable so a single test can simulate the server's response // changing between two `refreshFromServer` calls without standing // up a second DataStore over the same file. - var effective: Map = effective - var hasDeviceOverride: Set = hasDeviceOverride - - override suspend fun getEffectiveSettings(keys: List): ApiResult { - val entries = keys.mapNotNull { key -> - val value = effective[key] ?: return@mapNotNull null - EffectiveSetting( - key = key, - effectiveValue = value, - source = "device", - hasDeviceOverride = key in hasDeviceOverride, - ) - } - return ApiResult.Success(EffectiveSettingsResponse(entries)) + var effective: Map = effective + var requestedKeys: List = emptyList() + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult { + requestedKeys = keys + // Like the server: answer only the keys this contract knows. + val entries = keys.mapNotNull { effective[it] } + return ApiResult.Success(EffectiveSettingValuesResponse(settings = entries, revision = 1)) } override suspend fun setDeviceSetting(key: String, value: String, profileId: String?) = diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt new file mode 100644 index 000000000..c7e6c5743 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/OverlayPrefsStoreTest.kt @@ -0,0 +1,434 @@ +package org.prairieserver.prairie.common.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingEntry +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.OverlayConfigResponse +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.overlays.CardOverlayPrefs +import org.prairieserver.prairie.overlays.OverlaySchema +import org.prairieserver.prairie.overlays.PresetId +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class OverlayPrefsStoreTest { + + @Test + fun `hydrate reads typed canonical profile value without legacy endpoint`() = runTest { + val expected = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(expected)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + store.refresh() + + assertEquals(expected, store.prefs.value) + assertTrue(store.hasUserOverride) + assertEquals(listOf(SettingKeys.UI_CARD_OVERLAYS), api.effectiveRequests.single()) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `clear lets the next session queue hydration behind an old refresh`() = runTest { + val firstUser = prefs(PresetId.Vibrant) + val secondUser = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(firstUser)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + + val oldRead = api.pauseNextEffectiveRead() + val oldRefresh = launch { store.refresh() } + oldRead.started.await() + + store.clear() + api.storedValue = Json.parseToJsonElement(OverlaySchema.serialize(secondUser)) + val nextHydration = launch { store.hydrateIfNeeded() } + runCurrent() + assertEquals(1, api.effectiveRequests.size) + + oldRead.release.complete(Unit) + oldRefresh.join() + nextHydration.join() + + assertEquals(2, api.effectiveRequests.size) + assertEquals(secondUser, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `save and reset use canonical profile scope with typed object`() = runTest { + val initial = prefs(PresetId.Vibrant) + val adminDefault = prefs(PresetId.Minimal) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(initial)), + adminDefaults = OverlaySchema.serialize(adminDefault), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + val edited = prefs(PresetId.Pill) + store.setPrefs(edited) + advanceUntilIdle() + + val put = api.puts.single() + assertEquals(SettingKeys.UI_CARD_OVERLAYS, put.key) + assertEquals(SettingScope.PROFILE, put.scope.scope) + assertIs(put.value) + assertEquals(edited, store.prefs.value) + assertTrue(store.hasUserOverride) + + store.resetToDefaults() + + assertEquals(1, api.deleteCount) + assertEquals(adminDefault, store.prefs.value) + assertFalse(store.hasUserOverride) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `failed canonical save restores last confirmed value`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + + store.setPrefs(prefs(PresetId.Square)) + advanceUntilIdle() + + assertEquals(confirmed, store.prefs.value) + assertTrue(store.hasUserOverride) + assertEquals("Rejected overlay settings", store.lastError.value) + assertEquals(0, api.legacyCalls) + } + + @Test + fun `stale refresh cannot replace a newer confirmed save`() = runTest { + val original = prefs(PresetId.Vibrant) + val saved = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(original)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.overlayEnabled = false + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + store.setPrefs(saved) + runCurrent() + assertEquals(saved, OverlaySchema.parse(api.storedValue.toString())) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(saved, store.prefs.value) + assertFalse(store.enabled.value) + + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + store.setPrefs(prefs(PresetId.Square)) + advanceUntilIdle() + + assertEquals(saved, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `refresh started during rejected save cannot erase its error`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val rejected = prefs(PresetId.Square) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + val pausedPut = api.pauseNextPut() + store.setPrefs(rejected) + pausedPut.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedPut.release.complete(Unit) + runCurrent() + assertEquals("Rejected overlay settings", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(confirmed, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + } + + @Test + fun `stale user read still applies new admin fallback after rejected save`() = runTest { + val oldDefault = prefs(PresetId.Minimal) + val newDefault = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + adminDefaults = OverlaySchema.serialize(oldDefault), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + assertEquals(oldDefault, store.prefs.value) + assertFalse(store.hasUserOverride) + + api.adminDefaults = OverlaySchema.serialize(newDefault) + api.putFailure = ApiResult.Error(400, "invalid_value", "Rejected overlay settings") + val pausedPut = api.pauseNextPut() + store.setPrefs(prefs(PresetId.Square)) + pausedPut.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedPut.release.complete(Unit) + runCurrent() + assertEquals(oldDefault, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(newDefault, store.prefs.value) + assertEquals("Rejected overlay settings", store.lastError.value) + + store.setPrefs(prefs(PresetId.Pill)) + advanceUntilIdle() + assertEquals(newDefault, store.prefs.value) + } + + @Test + fun `edit during reset is persisted after delete and remains visible`() = runTest { + val original = prefs(PresetId.Vibrant) + val saved = prefs(PresetId.Pill) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(original)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.overlayEnabled = false + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + val pausedDelete = api.pauseNextDelete() + val resetJob = launch { store.resetToDefaults() } + pausedDelete.started.await() + + store.setPrefs(saved) + runCurrent() + assertEquals(saved, store.prefs.value) + assertTrue(api.puts.isEmpty()) + + staleRead.release.complete(Unit) + refreshJob.join() + assertFalse(store.enabled.value) + assertEquals(saved, store.prefs.value) + + pausedDelete.release.complete(Unit) + resetJob.join() + advanceUntilIdle() + + assertEquals(listOf("DELETE", "PUT"), api.mutationEvents) + assertEquals(saved, OverlaySchema.parse(api.storedValue.toString())) + assertEquals(saved, store.prefs.value) + assertTrue(store.hasUserOverride) + } + + @Test + fun `refresh started during rejected reset cannot erase its error`() = runTest { + val confirmed = prefs(PresetId.Vibrant) + val api = RecordingOverlaySettingsApi( + storedValue = Json.parseToJsonElement(OverlaySchema.serialize(confirmed)), + ) + val store = DefaultOverlayPrefsStore(SettingsRepository(api), this) + store.refresh() + + api.deleteFailure = ApiResult.Error(500, "delete_failed", "Reset failed") + val pausedDelete = api.pauseNextDelete() + val resetJob = launch { store.resetToDefaults() } + pausedDelete.started.await() + + val staleRead = api.pauseNextEffectiveRead() + val refreshJob = launch { store.refresh() } + staleRead.started.await() + + pausedDelete.release.complete(Unit) + resetJob.join() + assertEquals("Reset failed", store.lastError.value) + + staleRead.release.complete(Unit) + refreshJob.join() + assertEquals(confirmed, store.prefs.value) + assertEquals("Reset failed", store.lastError.value) + } + + private fun prefs(preset: PresetId): CardOverlayPrefs = + OverlaySchema.buildDefaults().copy(preset = preset) +} + +private class RecordingOverlaySettingsApi( + var storedValue: JsonElement? = null, + var adminDefaults: String? = null, +) : SettingsApi(HttpClient()) { + + data class CallGate( + val started: CompletableDeferred = CompletableDeferred(), + val release: CompletableDeferred = CompletableDeferred(), + ) + + data class Put( + val key: String, + val scope: SettingScopeIdentity, + val value: JsonElement, + ) + + val effectiveRequests = mutableListOf>() + val puts = mutableListOf() + val mutationEvents = mutableListOf() + var deleteCount = 0 + var legacyCalls = 0 + var overlayEnabled = true + var putFailure: ApiResult? = null + var deleteFailure: ApiResult? = null + private var nextEffectiveReadGate: CallGate? = null + private var nextPutGate: CallGate? = null + private var nextDeleteGate: CallGate? = null + + fun pauseNextEffectiveRead(): CallGate = CallGate().also { + check(nextEffectiveReadGate == null) + nextEffectiveReadGate = it + } + + fun pauseNextPut(): CallGate = CallGate().also { + check(nextPutGate == null) + nextPutGate = it + } + + fun pauseNextDelete(): CallGate = CallGate().also { + check(nextDeleteGate == null) + nextDeleteGate = it + } + + override suspend fun overlayConfig(): ApiResult = + ApiResult.Success(OverlayConfigResponse(enabled = overlayEnabled, defaults = adminDefaults)) + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult { + effectiveRequests += keys + val value = storedValue + nextEffectiveReadGate?.also { gate -> + nextEffectiveReadGate = null + gate.started.complete(Unit) + gate.release.await() + } + return ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.UI_CARD_OVERLAYS, + value = value ?: JsonNull, + source = if (value == null) { + EffectiveSettingValue.SOURCE_DEFAULT + } else { + SettingScope.PROFILE.wire + }, + scope = SettingScope.PROFILE.wire.takeIf { value != null }, + ), + ), + revision = SettingKeys.REVISION, + ), + ) + } + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String?, + ): ApiResult { + puts += Put(key, scope, value) + nextPutGate?.also { gate -> + nextPutGate = null + gate.started.complete(Unit) + gate.release.await() + } + putFailure?.let { return it } + storedValue = value + mutationEvents += "PUT" + return ApiResult.Success( + StoredSettingValue( + key = key, + scope = scope.scope.wire, + value = value, + ), + ) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + deleteCount += 1 + assertEquals(SettingKeys.UI_CARD_OVERLAYS, key) + assertEquals(SettingScope.PROFILE, scope.scope) + nextDeleteGate?.also { gate -> + nextDeleteGate = null + gate.started.complete(Unit) + gate.release.await() + } + deleteFailure?.let { return it } + storedValue = null + mutationEvents += "DELETE" + return ApiResult.Success(Unit) + } + + override suspend fun getSetting(key: String): ApiResult { + legacyCalls += 1 + error("legacy getSetting must not be called") + } + + override suspend fun setSetting(key: String, value: String): ApiResult { + legacyCalls += 1 + error("legacy setSetting must not be called") + } + + override suspend fun deleteSetting(key: String): ApiResult { + legacyCalls += 1 + error("legacy deleteSetting must not be called") + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt index 63fcb8711..93843add7 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.settings.LibraryPlaybackPref import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult @@ -146,7 +147,7 @@ private class FakeLibraryPlaybackPrefsStore : LibraryPlaybackPrefsStore { } private class FakePlayerSettingsStore : PlayerSettingsStore { - override val autoSkipIntroFlow: Flow = flowOf(false) + override val introSkipModeFlow: Flow = flowOf(IntroSkipMode.ASK) override val autoSkipCreditsFlow: Flow = flowOf(false) override val autoPlayNextFlow: Flow = flowOf(true) override val hdrEnabledFlow: Flow = flowOf(true) @@ -164,12 +165,12 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val playbackSpeedFlow: Flow = flowOf(1.0) override val audioSyncMsFlow: Flow = flowOf(0) override val subtitleSyncMsFlow: Flow = flowOf(0) - override fun subtitleSyncMsFor(contentId: String?): Flow = subtitleSyncMsFlow override val nextUpPromptSecondsFlow: Flow = flowOf(30) override val sleepTimerDefaultMinutesFlow: Flow = flowOf(30) override val resumeRewindSecondsFlow: Flow = flowOf(7) override val passOutThresholdFlow: Flow = flowOf(3) override val preferredQualityFlow: Flow = flowOf("auto") + override val maxBitrateKbpsFlow: Flow = flowOf(null) override val audioLanguageFlow: Flow = flowOf("") override val videoGravityFlow: Flow = flowOf("fit") override val orientationModeFlow: Flow = flowOf("auto") @@ -177,7 +178,7 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val subtitleUsesDeviceOverrideFlow: Flow = flowOf(false) var refreshCalls = 0 - override suspend fun setAutoSkipIntro(value: Boolean) = Unit + override suspend fun setIntroSkipMode(value: IntroSkipMode) = Unit override suspend fun setAutoSkipCredits(value: Boolean) = Unit override suspend fun setAutoPlayNext(value: Boolean) = Unit override suspend fun setHdrEnabled(value: Boolean) = Unit @@ -193,16 +194,17 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun setPlaybackSpeed(value: Double) = Unit override suspend fun setAudioSyncMs(value: Int) = Unit override suspend fun setSubtitleSyncMs(value: Int) = Unit - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit override suspend fun setNextUpPromptSeconds(value: Int) = Unit override suspend fun setSleepTimerDefaultMinutes(value: Int) = Unit override suspend fun setResumeRewindSeconds(value: Int) = Unit override suspend fun setPassOutThreshold(value: Int) = Unit override suspend fun setPreferredQuality(value: String) = Unit + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) = Unit override suspend fun setAudioLanguage(value: String) = Unit override suspend fun setVideoGravity(value: String) = Unit override suspend fun setOrientationMode(value: String) = Unit override suspend fun setSubtitleAppearance(value: SubtitleAppearance) = Unit + override suspend fun flushProjectedSubtitleAppearance() = Unit override suspend fun refreshFromServer() { refreshCalls++ } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt index ed805671a..7f60454d8 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt @@ -1,5 +1,11 @@ package org.prairieserver.prairie.common.settings +import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.SettingsApi import io.ktor.client.HttpClient @@ -7,23 +13,42 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ServerSettingsFlusherTest { + // Real contract keys so the flusher's remote-key gate and type tables + // classify them the way production traffic is classified. + private val boolKey = SettingKeys.PLAYBACK_AUTO_SKIP_INTRO + private val intKey = SettingKeys.PLAYER_AUDIO_SYNC_MS + private val doubleKey = SettingKeys.PLAYER_PLAYBACK_SPEED + private val stringKey = SettingKeys.PLAYBACK_PREFERRED_QUALITY + private val languageKey = SettingKeys.PLAYBACK_AUDIO_LANGUAGE + private val objectKey = SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE + + // The server ops are authored against. The flusher's requests are relative + // and it outlives a server switch, so every op carries its origin. + private val serverUrl = "https://one.example" + private val otherServerUrl = "https://two.example" + @Test fun `enqueue debounces multiple writes for same key`() = runTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.a", "v1") + flusher.enqueue("p1", stringKey, "480p", serverUrl) advanceTimeBy(50) - flusher.enqueue("p1", "key.a", "v2") + flusher.enqueue("p1", stringKey, "720p", serverUrl) advanceTimeBy(50) - flusher.enqueue("p1", "key.a", "v3") + flusher.enqueue("p1", stringKey, "1080p", serverUrl) // Not yet — total elapsed 100, debounce 200. assertEquals(0, api.calls.size) @@ -31,8 +56,8 @@ class ServerSettingsFlusherTest { advanceUntilIdle() assertEquals(1, api.calls.size, "expected only the latest write to be sent (coalesced)") - assertEquals("key.a", api.calls.first().key) - assertEquals("v3", api.calls.first().value) + assertEquals(stringKey, api.calls.first().key) + assertEquals(JsonPrimitive("1080p"), api.calls.first().value) } @Test @@ -40,32 +65,67 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.a", "1") - flusher.enqueue("p1", "key.b", "2") - flusher.enqueue("p1", "key.c", "3") + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueue("p1", intKey, "120", serverUrl) + flusher.enqueue("p1", stringKey, "720p", serverUrl) advanceUntilIdle() assertEquals(3, api.calls.size) val byKey = api.calls.associate { it.key to it.value } - assertEquals("1", byKey["key.a"]) - assertEquals("2", byKey["key.b"]) - assertEquals("3", byKey["key.c"]) + assertEquals(JsonPrimitive(true), byKey[boolKey]) + assertEquals(JsonPrimitive(120L), byKey[intKey]) + assertEquals(JsonPrimitive("720p"), byKey[stringKey]) } @Test - fun `enqueue then enqueue with new value coalesces to latest`() = runTest { + fun `values are encoded as the contract JSON type`() = runTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.x", "first") - flusher.enqueue("p1", "key.x", "second") - flusher.enqueue("p1", "key.x", "final") + flusher.enqueue("p1", boolKey, "false", serverUrl) + flusher.enqueue("p1", intKey, "-250", serverUrl) + flusher.enqueue("p1", doubleKey, "1.5", serverUrl) + flusher.enqueue("p1", languageKey, "en-US", serverUrl) + flusher.enqueue("p1", objectKey, SubtitleAppearance.DEFAULT.toJsonString(), serverUrl) + + advanceUntilIdle() + + val byKey = api.calls.associate { it.key to it.value } + assertEquals(JsonPrimitive(false), byKey[boolKey]) + assertEquals(JsonPrimitive(-250L), byKey[intKey]) + assertEquals(JsonPrimitive(1.5), byKey[doubleKey]) + assertEquals(JsonPrimitive("en-US"), byKey[languageKey]) + assertTrue(byKey[objectKey] is JsonObject, "subtitle appearance must go up as a JSON object") + } + + @Test + fun `empty language tag is sent as JSON null`() = runTest { + // The store spells "no preference" as ""; the contract spells it as + // null (its language_tag validator rejects the empty string). + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + flusher.enqueue("p1", languageKey, "", serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size) - assertEquals("final", api.calls.first().value) + assertEquals(JsonNull, api.calls.first().value) + } + + @Test + fun `writes address the profile_device scope with the enqueued profile`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueueDelete("p2", intKey, serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertTrue(api.calls.all { it.scope == SettingScopeIdentity.profileDevice() }) + assertEquals("p1", api.calls.first { it.key == boolKey }.profileId) + assertEquals("p2", api.calls.first { it.key == intKey }.profileId) } @Test @@ -73,15 +133,15 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.shared", "one") - flusher.enqueue("p2", "key.shared", "two") + flusher.enqueue("p1", stringKey, "720p", serverUrl) + flusher.enqueue("p2", stringKey, "1080p", serverUrl) advanceUntilIdle() assertEquals(2, api.calls.size) val byProfile = api.calls.associate { it.profileId to it.value } - assertEquals("one", byProfile["p1"]) - assertEquals("two", byProfile["p2"]) + assertEquals(JsonPrimitive("720p"), byProfile["p1"]) + assertEquals(JsonPrimitive("1080p"), byProfile["p2"]) } @Test @@ -89,14 +149,14 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueueDelete("p1", "key.x") - flusher.enqueue("p1", "key.x", "after") + flusher.enqueueDelete("p1", stringKey, serverUrl) + flusher.enqueue("p1", stringKey, "480p", serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size, "set should win after delete since it was enqueued later") - assertEquals(RecordingSettingsApi.Call.Kind.SET, api.calls.first().kind) - assertEquals("after", api.calls.first().value) + assertEquals(RecordingSettingsApi.Call.Kind.PUT, api.calls.first().kind) + assertEquals(JsonPrimitive("480p"), api.calls.first().value) } @Test @@ -104,14 +164,14 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.x", "before") - flusher.enqueueDelete("p1", "key.x") + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.enqueueDelete("p1", stringKey, serverUrl) advanceUntilIdle() assertEquals(1, api.calls.size, "delete should win after set since it was enqueued later") assertEquals(RecordingSettingsApi.Call.Kind.DELETE, api.calls.first().kind) - assertEquals("key.x", api.calls.first().key) + assertEquals(stringKey, api.calls.first().key) } @Test @@ -119,17 +179,17 @@ class ServerSettingsFlusherTest { val api = RecordingSettingsApi() val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 5_000) - flusher.enqueue("p1", "key.a", "v1") - flusher.enqueueDelete("p1", "key.b") + flusher.enqueue("p1", boolKey, "true", serverUrl) + flusher.enqueueDelete("p1", intKey, serverUrl) // Don't advance — flushNow should drain immediately. flusher.flushNow() assertEquals(2, api.calls.size) val byKey = api.calls.associateBy { it.key } - assertEquals(RecordingSettingsApi.Call.Kind.SET, byKey["key.a"]?.kind) - assertEquals("v1", byKey["key.a"]?.value) - assertEquals(RecordingSettingsApi.Call.Kind.DELETE, byKey["key.b"]?.kind) + assertEquals(RecordingSettingsApi.Call.Kind.PUT, byKey[boolKey]?.kind) + assertEquals(JsonPrimitive(true), byKey[boolKey]?.value) + assertEquals(RecordingSettingsApi.Call.Kind.DELETE, byKey[intKey]?.kind) } @Test @@ -143,56 +203,360 @@ class ServerSettingsFlusherTest { } @Test - fun `errors from setDeviceSetting are swallowed and do not abort future flushes`() = runTest { - val api = RecordingSettingsApi(failNext = true) + fun `transient failure keeps the write queued and retries with the same mutation id`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(503, "unavailable", "restarting")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size, "failed write must be retried, not dropped") + assertEquals(api.calls[0].mutationId, api.calls[1].mutationId, + "a retry must replay the SAME mutation id so the server can dedupe it") + assertEquals(JsonPrimitive(true), api.calls[1].value) + } + + @Test + fun `network failure keeps the write queued and retries with the same mutation id`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(2, ApiResult.NetworkError(RuntimeException("offline"))) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", intKey, "500", serverUrl) + advanceUntilIdle() + + assertEquals(3, api.calls.size) + assertTrue(api.calls.all { it.mutationId == api.calls.first().mutationId }) + } + + @Test + fun `write survives exhausting automatic retries and flushes on the next trigger`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + // Initial attempt + capped automatic retries, then parked — the old + // behavior dropped the write on the first failure. + val attemptsWhileParked = api.calls.size + assertTrue(attemptsWhileParked >= 2, "expected automatic retries, got $attemptsWhileParked") + + // The op is still queued: a later explicit flush (app foreground, + // player exit) replays it — same id — and this time it lands. + api.failNextPuts(0, ApiResult.Error(500, "internal", "boom")) + flusher.flushNow() + + assertEquals(attemptsWhileParked + 1, api.calls.size) + assertTrue(api.calls.all { it.mutationId == api.calls.first().mutationId }) + } + + @Test + fun `contract rejection drops the write instead of retrying forever`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(400, "invalid_value", "expected a boolean")) val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) - flusher.enqueue("p1", "key.fail", "boom") + flusher.enqueue("p1", boolKey, "true", serverUrl) advanceUntilIdle() - // Even though that one errored internally, a subsequent enqueue should still flush. - flusher.enqueue("p1", "key.ok", "yay") + assertEquals(1, api.calls.size, "a 4xx contract rejection retries identically forever; drop it") + + // And the queue is actually empty afterwards. + flusher.flushNow() + assertEquals(1, api.calls.size) + } + + @Test + fun `mutation id conflict drops the write`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(409, "mutation_id_conflict", "id reused")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) advanceUntilIdle() - assertTrue(api.calls.any { it.key == "key.ok" && it.value == "yay" }) + assertEquals(1, api.calls.size) + } + + @Test + fun `delete answered not_found is treated as already done`() = runTest { + val api = RecordingSettingsApi() + api.failNextDeletes(Int.MAX_VALUE, ApiResult.Error(404, "not_found", "No value is set at this scope")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueueDelete("p1", stringKey, serverUrl) + advanceUntilIdle() + + assertEquals(1, api.calls.size, "nothing stored means the reset is already true; no retry") + } + + @Test + fun `transient delete failure keeps the delete queued`() = runTest { + val api = RecordingSettingsApi() + api.failNextDeletes(1, ApiResult.Error(502, "bad_gateway", "proxy hiccup")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueueDelete("p1", stringKey, serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertTrue(api.calls.all { it.kind == RecordingSettingsApi.Call.Kind.DELETE }) + } + + @Test + fun `re-enqueueing a different value mints a fresh mutation id`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + flusher.enqueue("p1", stringKey, "1080p", serverUrl) + advanceUntilIdle() + + assertEquals(2, api.calls.size) + assertNotEquals(api.calls[0].mutationId, api.calls[1].mutationId, + "different content must never reuse a mutation id (409 conflict by design)") + } + + @Test + fun `a retained retry is dropped once its origin server is no longer active`() = runTest { + // The failure mode: a write fails transiently against server one, stays + // queued, and the user switches to server two. Requests are relative and + // this flusher is application-scoped, so replaying the op now would + // write server one's device setting to server two — which a restored or + // cloned server recognizing the same profile id would accept. + val api = RecordingSettingsApi() + // Fail every attempt, so the op exhausts its automatic retries and is + // still sitting in the queue when the switch happens. + api.failNextPuts(Int.MAX_VALUE, ApiResult.Error(500, "internal", "boom")) + var activeServer = serverUrl + val flusher = DefaultServerSettingsFlusher( + api, this, debounceMs = 200, getServerUrl = { activeServer }, + ) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + val attemptsBeforeSwitch = api.calls.size + assertTrue(attemptsBeforeSwitch >= 1, "the write must have been attempted at least once") + + activeServer = otherServerUrl + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + attemptsBeforeSwitch, + api.calls.size, + "a queued write must never be replayed against a server it was not authored for", + ) + + // And it is gone, not merely deferred: a later flush against the + // original server must not resurrect it either. + activeServer = serverUrl + flusher.flushNow() + advanceUntilIdle() + assertEquals( + attemptsBeforeSwitch, + api.calls.size, + "the dropped op must not be revived by a later flush", + ) + } + + @Test + fun `a queued write still lands when the active server is unchanged`() = runTest { + // The other side of the guard: same setup, no switch. The retry has to + // go through, or the drop rule would quietly break normal persistence. + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher( + api, this, debounceMs = 200, getServerUrl = { serverUrl }, + ) + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + + assertTrue( + api.calls.count { it.key == stringKey } >= 2, + "a transient failure on the still-active server must retry", + ) + } + + @Test + fun `failure does not abort other queued writes or future flushes`() = runTest { + val api = RecordingSettingsApi() + api.failNextPuts(1, ApiResult.Error(500, "internal", "boom")) + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", boolKey, "true", serverUrl) + advanceUntilIdle() + + flusher.enqueue("p1", stringKey, "720p", serverUrl) + advanceUntilIdle() + + assertTrue(api.calls.any { it.key == stringKey && it.value == JsonPrimitive("720p") }) + // And the originally failed write also landed in the end. + assertTrue(api.calls.count { it.key == boolKey } >= 2) + } + + @Test + fun `a newer value flushed in the same drain is not reverted by the failed older one`() = runTest { + // The shape that loses a user edit: flushNow() drains on the caller's + // coroutine (nothing cancels it), so a value the user changes while + // the first PUT is in flight is drained and sent by a LATER pass of + // the same drain. If the failed older op stayed queued, the retry + // would replay it over the newer value the server already accepted. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextPuts(1, ApiResult.Error(502, "bad_gateway", "proxy hiccup")) + api.onPut = { call -> + // The user edits the same setting while the first PUT is in flight. + if (call.value == JsonPrimitive("480p")) flusher.enqueue("p1", stringKey, "1080p", serverUrl) + } + + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + listOf(JsonPrimitive("480p"), JsonPrimitive("1080p")), + api.calls.map { it.value }, + "the superseded value must not be replayed after the newer one landed", + ) + } + + @Test + fun `a delete that failed is not replayed after a newer set landed`() = runTest { + // Same defect, worse outcome: a re-queued delete clears a value the + // user explicitly chose after the reset. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextDeletes(1, ApiResult.Error(503, "unavailable", "restarting")) + api.onDelete = { flusher.enqueue("p1", stringKey, "1080p", serverUrl) } + + flusher.enqueueDelete("p1", stringKey, serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals( + listOf(RecordingSettingsApi.Call.Kind.DELETE, RecordingSettingsApi.Call.Kind.PUT), + api.calls.map { it.kind }, + "the failed delete must not be replayed over the value set after it", + ) + assertEquals(JsonPrimitive("1080p"), api.calls.last().value) + } + + @Test + fun `a newer value that also fails is the one retried`() = runTest { + // Evicting the stale entry must not lose a genuine failure: when the + // newer op fails too, it is the newer op — and its id — that stays + // queued. + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + api.failNextPuts(2, ApiResult.Error(500, "internal", "boom")) + api.onPut = { call -> + if (call.value == JsonPrimitive("480p")) flusher.enqueue("p1", stringKey, "1080p", serverUrl) + } + + flusher.enqueue("p1", stringKey, "480p", serverUrl) + flusher.flushNow() + advanceUntilIdle() + + assertEquals(3, api.calls.size, "the newer failed write must still be retried") + assertEquals(JsonPrimitive("1080p"), api.calls[2].value) + assertEquals( + api.calls[1].mutationId, api.calls[2].mutationId, + "the retry replays the newer write's own id", + ) + } + + @Test + fun `keys the contract does not store never reach the server`() = runTest { + val api = RecordingSettingsApi() + val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200) + + flusher.enqueue("p1", PlaybackSettingsKeys.SubtitleFontSize, "large", serverUrl) + flusher.enqueue("p1", "made.up_key", "x", serverUrl) + advanceUntilIdle() + flusher.flushNow() + + assertEquals(0, api.calls.size, "non-remote keys would 404 as unknown_setting; drop locally") } } /** - * Records every setDeviceSetting / deleteDeviceSetting call. Constructed - * with a no-op HttpClient because we override the only methods the - * flusher invokes — the underlying client is never touched. + * Records every putValue / deleteValue call. Constructed with a no-op + * HttpClient because we override the only methods the flusher invokes — + * the underlying client is never touched. */ -private class RecordingSettingsApi( - private val failNext: Boolean = false, -) : SettingsApi(HttpClient()) { +private class RecordingSettingsApi : SettingsApi(HttpClient()) { data class Call( val kind: Kind, val key: String, - val value: String?, + val value: JsonElement?, val profileId: String?, + val mutationId: String?, + val scope: SettingScopeIdentity, ) { - enum class Kind { SET, DELETE } + enum class Kind { PUT, DELETE } } val calls = mutableListOf() - private var failedOnce = false - override suspend fun setDeviceSetting( + /** + * Runs while a call is "in flight", before its result is returned — the + * hook for simulating the user editing the same setting during a flush. + */ + var onPut: ((Call) -> Unit)? = null + var onDelete: ((Call) -> Unit)? = null + + private var putFailuresRemaining = 0 + private var putFailure: ApiResult? = null + private var deleteFailuresRemaining = 0 + private var deleteFailure: ApiResult? = null + + fun failNextPuts(count: Int, failure: ApiResult) { + putFailuresRemaining = count + putFailure = failure + } + + fun failNextDeletes(count: Int, failure: ApiResult) { + deleteFailuresRemaining = count + deleteFailure = failure + } + + override suspend fun putValue( key: String, - value: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, profileId: String?, - ): ApiResult { - calls.add(Call(Call.Kind.SET, key, value, profileId)) - if (failNext && !failedOnce) { - failedOnce = true - return ApiResult.Error(500, "internal", "boom") + ): ApiResult { + val call = Call(Call.Kind.PUT, key, value, profileId, mutationId, scope) + calls.add(call) + onPut?.invoke(call) + if (putFailuresRemaining > 0) { + putFailuresRemaining-- + return putFailure ?: ApiResult.Error(500, "internal", "boom") } - return ApiResult.Success(Unit) + return ApiResult.Success( + StoredSettingValue(key = key, scope = SettingScope.PROFILE_DEVICE.wire, value = value), + ) } - override suspend fun deleteDeviceSetting(key: String): ApiResult { - calls.add(Call(Call.Kind.DELETE, key, null, null)) + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + val call = Call(Call.Kind.DELETE, key, null, profileId, null, scope) + calls.add(call) + onDelete?.invoke(call) + if (deleteFailuresRemaining > 0) { + deleteFailuresRemaining-- + return deleteFailure ?: ApiResult.Error(500, "internal", "boom") + } return ApiResult.Success(Unit) } } diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt deleted file mode 100644 index b863d5aa5..000000000 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/SubtitleSyncOverridesTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.prairieserver.prairie.common.settings - -import org.junit.Assert.assertEquals -import org.junit.Test - -class SubtitleSyncOverridesTest { - @Test - fun roundTripsEntries() { - val encoded = encodeSubtitleSyncOverrides(mapOf("movie-1" to -250, "episode-2" to 1_500)) - assertEquals(mapOf("movie-1" to -250, "episode-2" to 1_500), decodeSubtitleSyncOverrides(encoded)) - } - - @Test - fun malformedLinesAreDroppedNotGuessedAt() { - val decoded = decodeSubtitleSyncOverrides("good=100\nbroken\nbad=notanumber\n=500\n") - assertEquals(mapOf("good" to 100), decoded) - } - - @Test - fun idsCarryingSeparatorsAreRefused() { - val encoded = encodeSubtitleSyncOverrides( - mapOf("ok" to 1, "bad=id" to 2, "bad\nid" to 3, "" to 4), - ) - assertEquals(mapOf("ok" to 1), decodeSubtitleSyncOverrides(encoded)) - } - - @Test - fun theMapIsBoundedKeepingTheMostRecent() { - val decoded = decodeSubtitleSyncOverrides( - encodeSubtitleSyncOverrides((1..250).associate { "item-$it" to it }), - ) - assertEquals(200, decoded.size) - assertEquals(250, decoded["item-250"]) - assertEquals(null, decoded["item-1"]) - } -} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt new file mode 100644 index 000000000..bfabee051 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt @@ -0,0 +1,65 @@ +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.CrewMember +import org.prairieserver.prairie.model.catalog.ItemDetail +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DirectorCreditTest { + @Test + fun movieCreditMatchesExactDirectorJobAndCleansNames() { + val detail = ItemDetail( + contentId = "movie-1", + type = "MoViE", + title = "Movie", + crew = listOf( + CrewMember(name = " Alice ", job = " director "), + CrewMember(name = "Camera", job = "Director of Photography"), + CrewMember(name = "", job = "Director"), + CrewMember(name = "Alice", job = "DIRECTOR"), + CrewMember(name = "Bob", job = "Director"), + ), + ) + + assertEquals("Directed by Alice, Bob", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditKeepsServerOrderAndCapsAtThreeNames() { + val detail = ItemDetail( + contentId = "movie-2", + type = "movie", + title = "Movie", + crew = listOf("One", "Two", "Three", "Four").map { + CrewMember(name = it, job = "Director") + }, + ) + + assertEquals("Directed by One, Two, Three", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditIsAbsentForNonMoviesOrMissingDirectors() { + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "episode-1", + type = "episode", + title = "Episode", + crew = listOf(CrewMember(name = "Alice", job = "Director")), + ), + ), + ) + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "movie-3", + type = "movie", + title = "Movie", + crew = listOf(CrewMember(name = "Camera", job = "Cinematographer")), + ), + ), + ) + } +} diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupportTest.kt index 1d9f2d97c..16d787b29 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupportTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupportTest.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.common.ui.components +import org.prairieserver.prairie.model.profile.Profile import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -11,6 +12,17 @@ class ProfileAvatarSupportTest { "src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ProfileAvatarSupport.kt", ).readText() + private val uploadRef = + "upload:profile-avatars/1/8bf465bc-3a0b-4cca-87b9-4a1473890be6/original.webp" + private val uploadObject = + "https://r2.example.test/silos3private/silo/dev/profile-avatars/1/" + + "8bf465bc-3a0b-4cca-87b9-4a1473890be6/w256.webp" + + private fun signedUploadUrl(signature: String) = + "$uploadObject?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=$signature" + + // --- existing forms must keep working ------------------------------------ + @Test fun absoluteAvatarUrlsAreReturnedUnchanged() { assertEquals( @@ -32,6 +44,127 @@ class ProfileAvatarSupportTest { assertNull(resolveAvatarUrl("https://prairie.example", "JC")) } + @Test + fun diceBearPresetsStillResolveToTheDiceBearApi() { + // Uri.encode is stubbed under plain unit tests, so assert the routing + // rather than the fully-encoded query. + val resolved = resolveProfileAvatar( + "https://silo.example", + ProfileAvatarRef("preset:dicebear:fun-emoji:cosmic-otter"), + ) + assertTrue( + resolved?.url?.startsWith("https://api.dicebear.com/9.x") == true, + "DiceBear presets must still resolve against the DiceBear API", + ) + } + + @Test + fun emojiAndInitialsFallbacksAreUnchanged() { + assertEquals("🦊", profileAvatarDisplayText(ProfileAvatarRef("🦊"), "Laura Chen")) + assertEquals("LC", profileAvatarDisplayText(ProfileAvatarRef.None, "Laura Chen")) + assertTrue(isEmojiAvatar(ProfileAvatarRef("🦊"))) + assertFalse(isEmojiAvatar(ProfileAvatarRef.None)) + } + + // --- uploaded avatars ----------------------------------------------------- + + @Test + fun uploadRefsCountAsImagesSoTheyNeverRenderAsText() { + assertTrue(isUploadAvatarRef(uploadRef)) + assertTrue(isImageAvatar(uploadRef)) + // Otherwise the raw `upload:profile-avatars/…` string would be drawn + // into the circle as if it were an emoji. + assertEquals("LC", profileAvatarDisplayText(ProfileAvatarRef(uploadRef), "Laura Chen")) + assertFalse(isEmojiAvatar(ProfileAvatarRef(uploadRef))) + } + + @Test + fun uploadRefUsesTheServerSuppliedUrl() { + val signed = signedUploadUrl("abc123") + val resolved = resolveProfileAvatar( + "https://silo.example", + ProfileAvatarRef(uploadRef, signed), + ) + assertEquals(signed, resolved?.url) + } + + @Test + fun uploadRefWithoutAUrlResolvesToNullRatherThanAFabricatedServerPath() { + // The regression: this used to produce + // https://silo.example/upload:profile-avatars/… — a guaranteed 404. + assertNull(resolveProfileAvatar("https://silo.example", ProfileAvatarRef(uploadRef))) + assertNull(resolveAvatarUrl("https://silo.example", uploadRef)) + } + + @Test + fun serverSuppliedUrlWinsOverAServerRelativePath() { + val resolved = resolveProfileAvatar( + "https://silo.example", + ProfileAvatarRef("/api/v1/users/1/avatar.png", "https://cdn.example.test/a.webp"), + ) + assertEquals("https://cdn.example.test/a.webp", resolved?.url) + } + + @Test + fun profileAvatarRefCarriesBothServerFields() { + val profile = Profile( + id = "p1", + name = "Laura", + avatar = uploadRef, + avatarUrl = signedUploadUrl("abc123"), + avatarSource = "upload", + ) + assertEquals(ProfileAvatarRef(uploadRef, signedUploadUrl("abc123")), profile.avatarRef()) + } + + // --- cache-key stability across re-signing -------------------------------- + + @Test + fun resigningTheSameUploadKeepsOneStableCacheKey() { + // The presigned URL is re-signed on every GET /profiles (15-minute + // expiry), so keying the caches by the URL would re-download the same + // bytes forever. + val first = resolveProfileAvatar("", ProfileAvatarRef(uploadRef, signedUploadUrl("sigA"))) + val second = resolveProfileAvatar("", ProfileAvatarRef(uploadRef, signedUploadUrl("sigB"))) + + assertEquals(uploadObject, first?.cacheKey) + assertEquals(first?.cacheKey, second?.cacheKey) + // ...while the URLs themselves genuinely differ. + assertTrue(first?.url != second?.url) + } + + @Test + fun differentUploadsDoNotShareACacheKey() { + val other = "upload:profile-avatars/1/11111111-2222-3333-4444-555555555555/original.webp" + val otherUrl = "https://r2.example.test/silos3private/silo/dev/profile-avatars/1/" + + "11111111-2222-3333-4444-555555555555/w256.webp?X-Amz-Signature=zzz" + + val a = resolveProfileAvatar("", ProfileAvatarRef(uploadRef, signedUploadUrl("sigA"))) + val b = resolveProfileAvatar("", ProfileAvatarRef(other, otherUrl)) + assertTrue(a?.cacheKey != b?.cacheKey) + } + + @Test + fun nonUploadUrlsKeepTheirQueryInTheCacheKey() { + // DiceBear encodes the seed in the query. Stripping it would collapse + // every preset avatar onto a single cache entry, so these must opt out + // of the override entirely and let Coil key by URL. + val resolved = resolveProfileAvatar( + "", + ProfileAvatarRef( + "preset:dicebear:fun-emoji:cosmic-otter", + "https://api.dicebear.com/9.x/fun-emoji/png?seed=cosmic-otter&size=256", + ), + ) + assertNull(resolved?.cacheKey) + } + + @Test + fun cacheKeyToleratesAnUnsignedUrl() { + val resolved = resolveProfileAvatar("", ProfileAvatarRef(uploadRef, uploadObject)) + assertEquals(uploadObject, resolved?.cacheKey) + } + @Test fun rememberProfileServerUrlUsesServerRegistryInsteadOfLegacyPrefs() { assertTrue( diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 3ac60d930..5297434da 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -14,23 +14,64 @@ if (file("google-services.json").isFile) { apply(plugin = "com.google.gms.google-services") } -val prairieVersionName = providers - .gradleProperty("prairieVersionName") +// Play's own track vocabulary, plus the two ways a build reaches a device +// without Play. Validated so a typo can't reach the server as a header value. +val siloReleaseChannels = listOf("internal", "alpha", "beta", "production", "sideload", "dev") + +val siloVersionName = providers + .gradleProperty("siloVersionName") .orElse(providers.environmentVariable("PRAIRIE_VERSION_NAME")) // Release builds override this from the validated Git tag in // android-build.yml. Keep local/dev builds aligned with the latest release. .orElse("0.3.11") -val prairieVersionCode = providers - .gradleProperty("prairieVersionCode") +// The per-marketing-version build counter (TestFlight-style). It is folded into +// the versionCode by CI, but the app also reports it verbatim to the server +// (X-Prairie-Client-Build) and shows it on the About row, so it has to survive as +// its own value rather than being reverse-engineered from the versionCode. +val siloBuildNumber = providers + .gradleProperty("siloBuildNumber") + .orElse(providers.environmentVariable("PRAIRIE_BUILD_NUMBER")) + .map { value -> + val build = value.toIntOrNull() ?: error("siloBuildNumber must be an integer.") + // The same 0..999 window release.yml and the Fastfile enforce, so a + // hand-run build can't stamp a counter the release scheme could never + // produce. 0 is the unstamped local default; CI itself requires 1..999. + require(build in 0..999) { + "siloBuildNumber must be between 0 and 999 (0 marks an unstamped local build)." + } + build.toString() + } + // Local/dev builds have no CI build number; 0 marks "not a release build". + .orElse("0") + +// How the artifact reaches a user, reported as X-Prairie-Client-Channel. Release +// pipelines state it: the Fastfile passes the Play track it is actually +// uploading to, so a beta-track tester and a production user are told apart +// rather than both reporting "release". Everything else is a hand-built or +// sideloaded artifact, which is not on any track. +val siloReleaseChannel = providers + .gradleProperty("siloReleaseChannel") + .orElse(providers.environmentVariable("PRAIRIE_RELEASE_CHANNEL")) + .map { value -> + val channel = value.trim().lowercase() + require(channel in siloReleaseChannels) { + "siloReleaseChannel must be one of ${siloReleaseChannels.joinToString("/")} (got '$value')." + } + channel + } + .orElse("sideload") + +val siloVersionCode = providers + .gradleProperty("siloVersionCode") .orElse(providers.environmentVariable("PRAIRIE_VERSION_CODE")) .map { value -> - val code = value.toIntOrNull() ?: error("prairieVersionCode must be an integer.") - require(code > 0) { "prairieVersionCode must be positive." } + val code = value.toIntOrNull() ?: error("siloVersionCode must be an integer.") + require(code > 0) { "siloVersionCode must be positive." } // The *2 (+1 for TV) form-factor multiplier applied at versionCode // assignment must stay under Google Play's 2_100_000_000 ceiling. require(code <= 1_049_999_999) { - "prairieVersionCode must be <= 1_049_999_999 so the form-factor multiplier " + + "siloVersionCode must be <= 1_049_999_999 so the form-factor multiplier " + "keeps both artifacts under Google Play's 2_100_000_000 versionCode limit." } code @@ -76,6 +117,7 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) + implementation(libs.haze) implementation(libs.coil.network.ktor) implementation(libs.jsoup) implementation(libs.media3.exoplayer) @@ -94,6 +136,7 @@ kotlin { // on this; it uses the separate NSD/mDNS PrairieCast device-remote. implementation(libs.play.services.cast.framework) implementation(libs.androidx.mediarouter) + implementation(libs.androidx.window) implementation("androidx.palette:palette-ktx:1.0.0") // Installs the Baseline Profile (generated by :baselineprofile) into the // app at first run so hot paths are AOT-compiled — faster cold start. @@ -143,12 +186,17 @@ android { defaultConfig { applicationId = "org.prairieserver.prairie" minSdk = 24 - targetSdk = 35 + targetSdk = 36 // Shares one Play listing with the TV app (same applicationId). Two // artifacts under one listing need distinct versionCodes: phone = // base*2, TV = base*2+1, so each release bumps both by 2 with no reuse. - versionCode = prairieVersionCode.get() * 2 - versionName = prairieVersionName.get() + versionCode = siloVersionCode.get() * 2 + versionName = siloVersionName.get() + // Reported to the server as X-Prairie-Client-Build and shown on the About + // row, so both name the same build the way Play and TestFlight do: + // "1.0.0 (5)". Matches silo-apple, where CFBundleVersion feeds the + // header, the playback context and diagnostics alike. + buildConfigField("String", "BUILD_NUMBER", "\"${siloBuildNumber.get()}\"") // Shadow the android-shared BuildConfig field so per-app flavors // (e.g., a "no-FFmpeg" sideload build for size-constrained QA) can // override without rebuilding the shared module. The runtime reads @@ -180,7 +228,11 @@ android { } } buildTypes { + debug { + buildConfigField("String", "RELEASE_CHANNEL", "\"dev\"") + } release { + buildConfigField("String", "RELEASE_CHANNEL", "\"${siloReleaseChannel.get()}\"") // Launch-prep: full R8 + resource shrinking. Keep rules for this // reflection/JNI-heavy stack live in the shared root proguard-rules.pro // (Koin, kotlinx.serialization, Media3 FFmpeg, BouncyCastle, @@ -242,6 +294,25 @@ android { excludes += "/META-INF/versions/*/OSGI-INF/MANIFEST.MF" } } + + // Lint had never run on this project. The two crashes it would have caught + // — a Spatializer call gated at API 31 when the class arrives at 32, and a + // getAddress() call with no guard at all — both shipped from this module, + // which app-level lint does not analyse unless asked. Hence the gate here + // and checkDependencies in the apps. + // + // The baseline holds today's known findings (overwhelmingly desugared + // java.* calls and deliberate media3 @UnstableApi usage) so that only NEW + // violations fail. Delete it and regenerate deliberately; do not add to it + // to make a build pass. + lint { + // Without this, none of android-shared is examined. + checkDependencies = true + baseline = file("lint-baseline.xml") + abortOnError = true + fatal += setOf("NewApi", "InlinedApi") + checkReleaseBuilds = true + } } dependencies { diff --git a/androidApp/gradle.lockfile b/androidApp/gradle.lockfile index c8b77996f..756fde3e4 100644 --- a/androidApp/gradle.lockfile +++ b/androidApp/gradle.lockfile @@ -195,14 +195,18 @@ androidx.test.espresso:espresso-idling-resource:3.7.0=androidDebugUnitTestRuntim androidx.test:core-ktx:1.6.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.test:core:1.6.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.test:monitor:1.8.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.tracing:tracing-ktx:1.2.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing-android:1.3.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing-ktx:1.3.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.tracing:tracing:1.1.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata -androidx.tracing:tracing:1.2.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing:1.3.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.vectordrawable:vectordrawable:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.viewpager:viewpager:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.webkit:webkit:1.16.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.window:window-core-android:1.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.window:window-core:1.4.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.window:window:1.4.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime-ktx:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath co.touchlab:stately-concurrency-jvm:2.1.0=androidBenchmarkReleaseRuntimeClasspath,androidDebugRuntimeClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseRuntimeClasspath,benchmarkReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -313,6 +317,8 @@ com.squareup.okhttp3:okhttp:4.12.0=allInstrumentedTestSourceSetsCompileDependenc com.squareup.okio:okio-jvm:3.10.2=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okio:okio:3.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +dev.chrisbanes.haze:haze-android:1.6.10=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +dev.chrisbanes.haze:haze:1.6.10=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-android:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-compose-android:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-compose-core-android:3.1.0=androidBenchmarkReleaseCompileClasspath,androidBenchmarkReleaseRuntimeClasspath,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidNonMinifiedReleaseCompileClasspath,androidNonMinifiedReleaseRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath diff --git a/androidApp/lint-baseline.xml b/androidApp/lint-baseline.xml new file mode 100644 index 000000000..9754cf632 --- /dev/null +++ b/androidApp/lint-baseline.xml @@ -0,0 +1,4525 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index 7ab705e94..3aeb05db6 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -9,6 +9,19 @@ android:name="android.hardware.touchscreen" android:required="true" /> + + + + + + + @@ -42,6 +55,7 @@ android:name=".MainActivity" android:exported="true" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode" + android:launchMode="singleTop" android:supportsPictureInPicture="true" android:windowSoftInputMode="adjustResize"> @@ -56,6 +70,7 @@ + @@ -88,6 +103,14 @@ + + + diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt index 140974604..a060b6ced 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,12 +33,19 @@ import org.prairieserver.prairie.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMIS import org.prairieserver.prairie.android.downloads.hasLegacyPublicDownloadPermission import org.prairieserver.prairie.android.push.PushNotificationPresenter import org.prairieserver.prairie.android.ui.navigation.AppNavigation +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteRequest +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteRequestFactory import org.prairieserver.prairie.android.ui.navigation.Route +import org.prairieserver.prairie.android.ui.navigation.clearConsumedExternalRouteRequest import org.prairieserver.prairie.android.ui.navigation.contentDeepLinkRouteOrNull +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteScope +import org.prairieserver.prairie.android.ui.navigation.notificationExternalRouteOrNull import org.prairieserver.prairie.android.ui.navigation.deviceLoginPairRouteOrNull import org.prairieserver.prairie.android.ui.navigation.hasLocalDownloadsForScope +import org.prairieserver.prairie.android.ui.navigation.inviteClaimRouteOrNull import org.prairieserver.prairie.android.ui.navigation.notificationNavigationRouteOrNull import org.prairieserver.prairie.android.ui.navigation.shouldStartOnDownloads +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourLocalCache import org.prairieserver.prairie.android.ui.theme.PrairieTheme import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator @@ -57,7 +65,8 @@ import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.get @@ -70,9 +79,42 @@ class MainActivity : ComponentActivity() { // start. Mirrors the TV-side flag in MainTvActivity. @Volatile private var hasShownColdSplash = false + + /** + * Set on the launch Intent once its external route has been delivered. + * `putExtra` mutates the process-local Intent, which covers ordinary + * in-process Activity recreation but NOT process death — the system may + * rebuild the task from the original launch Intent, without this. The + * saved-state route below is what covers that case; this is the fast + * path. + */ + private const val EXTRA_EXTERNAL_ROUTE_CONSUMED = + "org.prairieserver.prairie.EXTERNAL_ROUTE_CONSUMED" + + /** + * Stands in for an active server whose identity could not be read, so a + * scope built from it matches nothing instead of everything. + */ + private const val UNRESOLVED_IDENTITY = "silo:unresolved-identity" + + /** Saved-state key for [consumedExternalRoute]. */ + private const val STATE_CONSUMED_EXTERNAL_ROUTE = + "org.prairieserver.prairie.CONSUMED_EXTERNAL_ROUTE" } - private val incomingExternalRoutes = MutableSharedFlow(extraBufferCapacity = 1) + private val externalRouteRequestFactory = ExternalRouteRequestFactory() + // Retain the latest request even while Compose is between collectors (for + // example while an existing top Activity is being resumed by onNewIntent). + // A replay-free SharedFlow can silently drop exactly that warm delivery. + private val pendingExternalRouteRequests = MutableStateFlow(null) + + /** + * The external route already delivered for the Intent this Activity was + * launched with, carried across process death in saved state so a restored + * task cannot replay a link the user already followed and navigated away + * from. + */ + private var consumedExternalRoute: String? = null // POST_NOTIFICATIONS is required on Android 13+ for any notification — // download progress / completion notifications silently never appear @@ -84,13 +126,14 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + consumedExternalRoute = savedInstanceState?.getString(STATE_CONSUMED_EXTERNAL_ROUTE) enableEdgeToEdge() maybeRequestNotificationPermission() maybeRequestLegacyPublicDownloadPermission() setContent { var startRoute by remember { mutableStateOf(null) } - var pendingExternalRoute by remember { mutableStateOf(null) } + val pendingExternalRoute by pendingExternalRouteRequests.collectAsState() var splashPlaybackComplete by remember { mutableStateOf(hasShownColdSplash) } LaunchedEffect(Unit) { @@ -101,14 +144,12 @@ class MainActivity : ComponentActivity() { // its target after auth instead of being silently dropped. // The pending route is only consumed once the main graph is // showing, so pre-auth starts just hold it. - (notificationRouteOrNull(intent) ?: contentDeepLinkRouteOrNull(intent?.dataString)) - ?.let { pendingExternalRoute = it } - launchAuthenticatedStartupWarmup(route) - } - LaunchedEffect(Unit) { - incomingExternalRoutes.collect { route -> - pendingExternalRoute = route + // Skip an Intent whose route was already delivered: it is only + // still here because the Activity retains it. + if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) != true) { + queueExternalRouteFrom(intent) } + launchAuthenticatedStartupWarmup(route) } PrairieTheme { @@ -143,7 +184,32 @@ class MainActivity : ComponentActivity() { AppNavigation( startDestination = resolvedRoute, pendingExternalRoute = pendingExternalRoute, - onExternalRouteConsumed = { pendingExternalRoute = null }, + onRequeueExternalRoute = { route -> + // A fresh request: clear the consumed marker so + // this re-delivery is not mistaken for the + // already-followed original. + consumedExternalRoute = null + intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) + pendingExternalRouteRequests.value = + externalRouteRequestFactory.create(route) + }, + onExternalRouteConsumed = { consumedRequest -> + // Record the delivery in two places. The Intent + // extra covers in-process Activity recreation, + // which re-parses the retained Intent in + // onCreate and would otherwise yank the user + // back to a link they already followed. It is + // process-local, so the saved-state route below + // is what covers process death. + intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true) + consumedExternalRoute = consumedRequest.route + pendingExternalRouteRequests.update { pendingRequest -> + clearConsumedExternalRouteRequest( + pendingRequest = pendingRequest, + consumedRequest = consumedRequest, + ) + } + }, ) } } @@ -160,13 +226,19 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch(Dispatchers.IO) { refresher.refreshIfStale() } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + consumedExternalRoute?.let { outState.putString(STATE_CONSUMED_EXTERNAL_ROUTE, it) } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + // A genuinely new Intent has not been consumed, whatever the old one + // carried. + intent.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED) + consumedExternalRoute = null setIntent(intent) - val route = deviceLoginPairRouteOrNull(intent.dataString) - ?: notificationRouteOrNull(intent) - ?: contentDeepLinkRouteOrNull(intent.dataString) - route?.let { incomingExternalRoutes.tryEmit(it) } + lifecycleScope.launch { queueExternalRouteFrom(intent) } } /** @@ -212,6 +284,95 @@ class MainActivity : ComponentActivity() { requestLegacyPublicDownloadPermission.launch(LEGACY_PUBLIC_DOWNLOAD_PERMISSION) } + + /** + * Parses an Intent into a pending external route, tagged with the identity + * it is only meaningful under. + * + * Everything that can wait through authentication has to declare its scope, + * because "wait" can mean days for a notification PendingIntent and several + * profile switches: + * - a pairing link names its issuing SERVER ORIGIN; + * - a notification was generated for one profile's inbox on one server, so + * it carries the identity stamped on it at post time; + * - a content link (`prairie://item`, `prairie://play`) carries no identity of + * its own, but its ids are server-local — so it is pinned to whoever is + * signed in when the link arrives. Arriving signed-out pins nothing, + * which is what lets a link opened before login still work after it. + */ + private suspend fun queueExternalRouteFrom(intent: Intent?) { + if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) == true) return + + // NOT gated on the active server. The route carries its issuing origin + // and the pairing destination refuses — and explains — a mismatch, with + // a switch action. Dropping it here was silent: the user scanned a code + // and nothing happened. A link whose origin cannot be read does not + // parse into a route at all. + val deviceRoute = deviceLoginPairRouteOrNull(intent?.dataString) + // Rejected outright unless it says whose it is — see + // [notificationExternalRouteOrNull]. + val notification = notificationExternalRouteOrNull( + route = notificationRouteOrNull(intent), + serverId = intent?.getStringExtra(PushNotificationPresenter.EXTRA_SERVER_ID), + profileId = intent?.getStringExtra(PushNotificationPresenter.EXTRA_PROFILE_ID), + ) + val notificationRoute = notification?.first + val contentRoute = contentDeepLinkRouteOrNull(intent?.dataString) + val inviteRoute = inviteClaimRouteOrNull(intent?.dataString) + + val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: return + if (route == consumedExternalRoute) return + + val scope = when { + // Unscoped for DELIVERY: the pairing screen owns the server check, + // so the request must actually arrive for it to be explained. + route === deviceRoute -> ExternalRouteScope.Unscoped + // Non-null by construction: `route` is only this when `notification` + // produced it, and that requires a complete identity. + route === notificationRoute -> checkNotNull(notification).second + route === contentRoute -> currentIdentityScope() + // An invite claim carries its own target server and is designed to + // work before authentication, so it must NOT be pinned to the + // current identity. + else -> ExternalRouteScope.Unscoped + } + + pendingExternalRouteRequests.value = + externalRouteRequestFactory.create(route = route, scope = scope) + } + + /** + * One cohesive read of the live identity. + * + * Reading the server and profile through separate getters could tear across + * a switch — the cached server id from before it, the profile id from after + * — producing a hybrid identity that belongs to nobody, which then either + * consumes a valid one-shot route or weakens it with a null wildcard. + */ + private suspend fun currentIdentityScope(): ExternalRouteScope { + val scope = get(TokenManager::class.java).snapshotCurrentScope() + if (scope != null) { + return ExternalRouteScope.Identity( + serverId = scope.serverId, + profileId = scope.profileId, + identityGeneration = scope.identityGeneration, + ) + } + // A null snapshot means "no active server" — nothing to pin to, and the + // link must survive setup and login. But it ALSO means "snapshotting + // failed" or "this manager does not model scopes", and turning those + // into a wildcard would quietly unpin a link that should have been + // pinned. Only an actually-absent server is allowed to be unpinned. + val registry = get(ServerRegistry::class.java) + return if (registry.activeServerId.value == null) { + ExternalRouteScope.Identity(serverId = null, profileId = null) + } else { + // An active server we cannot describe: pin to something nothing + // matches rather than to everything. + ExternalRouteScope.Identity(serverId = UNRESOLVED_IDENTITY, profileId = null) + } + } + private fun notificationRouteOrNull(intent: Intent?): String? = notificationNavigationRouteOrNull( intent?.getStringExtra(PushNotificationPresenter.EXTRA_NAV_ROUTE), @@ -234,11 +395,18 @@ class MainActivity : ComponentActivity() { * - All set → `Home` */ private suspend fun resolveStartDestination(): String { - deviceLoginPairRouteOrNull(intent?.dataString)?.let { return it } - val registry = get(ServerRegistry::class.java) val tokenManager = get(TokenManager::class.java) + // NOTE: a device link is deliberately NOT returned as the start + // destination. It used to be, which put Pair Device at the root of a + // signed-out app: its "Sign In" pushed Login, and the successful login + // then cleared the whole stack with popUpTo(0), losing the pairing + // request entirely. It is queued as a pending external route instead, + // so the normal server/token/profile gates run first and the pairing + // screen arrives on top of an authenticated stack — which also means + // its Back/Done has somewhere real to return to. + val activeEntry = registry.activeEntry.value ?: return Route.ServerSetup.route @@ -258,7 +426,7 @@ class MainActivity : ComponentActivity() { if (profileId.isNullOrBlank()) return Route.ProfileSelection.route // Offline-with-downloads fast path: if local media exists and either - // the device has no network OR the configured Prairie server fails the + // the device has no network OR the configured Silo server fails the // authoritative health probe, land directly on Downloads instead of // greeting the user with a dead Home request. // Filesystem walk + health probe off the main dispatcher: this runs @@ -285,6 +453,16 @@ class MainActivity : ComponentActivity() { return Route.Downloads.route } + // A warm start would otherwise bypass the tour gate entirely (e.g. + // process death mid-tour). Once completion is confirmed the local + // cache short-circuits inside the gate, so this costs nothing on + // launches after the first; the gate itself fails open to Home on + // any error, so it can't strand an offline start. + val tourCache = get(OnboardingTourLocalCache::class.java) + if (!tourCache.isDone(activeEntry.id, profileId)) { + return Route.OnboardingTour.route + } + return Route.Home.route } @@ -311,6 +489,9 @@ class MainActivity : ComponentActivity() { personalDataRepository = get(PersonalDataRepository::class.java), sectionRepository = get(SectionRepository::class.java), homeCache = get(HomeCachePort::class.java), + identityTransitions = get( + org.prairieserver.prairie.network.IdentityTransitionBarrier::class.java, + ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.phone(), ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt index bcb5e1ca3..ef700512e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt @@ -74,6 +74,14 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage }.onFailure { android.util.Log.w("PrairieApplication", "PrairieCast foreground starter init failed", it) } + runCatching { + org.prairieserver.prairie.android.cast.PrairieCastMediaSessionStarter( + context = this@PrairieApplication, + controller = koinApp.koin.get(), + ).start() + }.onFailure { + android.util.Log.w("PrairieApplication", "PrairieCast media-session starter init failed", it) + } // Configuration.Provider wasn't reliably picked up by WM's androidx.startup // auto-init (the auto-init seemed to win the race, leaving WM with its // default reflection-based WorkerFactory). Force-initialise explicitly @@ -111,13 +119,13 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage // for cold start. runCatching { org.prairieserver.prairie.common.downloads.installOrphanedServerDataPurge( - context = this@SiloApplication, + context = this@PrairieApplication, registry = koinApp.koin.get(), database = koinApp.koin.get(), storage = koinApp.koin.get(), ) }.onFailure { - android.util.Log.w("SiloApplication", "Orphaned server purge init failed", it) + android.util.Log.w("PrairieApplication", "Orphaned server purge init failed", it) } // One-time migration: drain the legacy .record.json download sidecar tree // into Room so pre-cutover downloads keep their metadata. Guarded — never diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt new file mode 100644 index 000000000..fb7aff144 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastArtworkResolver.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.android.cast + +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository + +/** Artwork resolved locally from the content identity in Remote Control state. */ +data class PrairieCastArtwork( + val posterUrl: String? = null, + val posterThumbhash: String? = null, + val backdropUrl: String? = null, + val backdropThumbhash: String? = null, +) { + val isEmpty: Boolean get() = posterUrl == null && backdropUrl == null +} + +/** + * Episodes use their series' portrait poster while retaining the episode + * still/backdrop for wide and blurred surfaces. + */ +internal suspend fun resolveCastArtwork( + repository: CatalogRepository, + contentId: String, +): PrairieCastArtwork { + val detail = repository.detailOrNull(contentId) ?: return PrairieCastArtwork() + val series = detail.seriesId + ?.takeIf { detail.type == "episode" } + ?.let { repository.detailOrNull(it) } + return PrairieCastArtwork( + posterUrl = series?.posterUrl ?: detail.posterUrl, + posterThumbhash = if (series?.posterUrl != null) series.posterThumbhash else detail.posterThumbhash, + backdropUrl = detail.backdropUrl ?: series?.backdropUrl, + backdropThumbhash = if (detail.backdropUrl != null) detail.backdropThumbhash else series?.backdropThumbhash, + ) +} + +private suspend fun CatalogRepository.detailOrNull(contentId: String): ItemDetail? = + getCachedItemDetail(contentId) + ?: (getItemDetail(contentId) as? ApiResult.Success)?.data diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt index 8568e938f..961becb00 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastController.kt @@ -284,6 +284,14 @@ class PrairieCastController( clock.setOptimisticPlaying(!isPlaying(), nowMs()) } + /** Idempotent transport command used by Android system media controls. */ + fun setPlaying(playing: Boolean) { + sendControl( + if (playing) PrairieCastControlCommand.play() else PrairieCastControlCommand.pause(), + ) + clock.setOptimisticPlaying(playing, nowMs()) + } + fun seek(seconds: Double) { sendControl(PrairieCastControlCommand.seek(seconds)) clock.setOptimisticTime(seconds, nowMs()) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionService.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionService.kt new file mode 100644 index 000000000..65f272c69 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionService.kt @@ -0,0 +1,275 @@ +package org.prairieserver.prairie.android.cast + +import android.content.Intent +import android.net.Uri +import android.os.Looper +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.prairieserver.prairie.cast.PrairieCastPlaybackState +import org.prairieserver.prairie.common.player.PrairieMediaSessionBitmapLoader +import org.prairieserver.prairie.repository.CatalogRepository +import kotlin.math.roundToLong + +/** + * Publishes Silo Remote Control as an Android Media3 session. The player is a + * projection of the TV's state: system play/pause/seek/next commands are sent + * over PrairieCast and incoming TV state invalidates the Media3 timeline. + * + * Keeping this session in a MediaSessionService also gives an engaged remote + * session a foreground-service lifetime while the TV is playing, so swiping + * away the phone UI does not immediately tear down the control socket. + */ +@UnstableApi +class PrairieCastMediaSessionService : MediaSessionService() { + private val controller: PrairieCastController by inject() + private val catalogRepository: CatalogRepository by inject() + + private lateinit var player: PrairieCastRemotePlayer + private var mediaSession: MediaSession? = null + private var mediaSessionBitmapLoader: PrairieMediaSessionBitmapLoader? = null + private lateinit var scope: CoroutineScope + private var stateJob: Job? = null + private var artworkJob: Job? = null + private var artworkContentId: String? = null + private var artworkUrl: String? = null + + override fun onCreate() { + super.onCreate() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + player = PrairieCastRemotePlayer(Looper.getMainLooper(), controller) + val bitmapLoader = PrairieMediaSessionBitmapLoader(this) + mediaSessionBitmapLoader = bitmapLoader + mediaSession = MediaSession.Builder(this, player) + .setBitmapLoader(bitmapLoader) + .build() + + stateJob = scope.launch { + controller.state.collect { state -> + val playback = state.playbackState + player.update( + playback = playback, + targetName = state.connectedTarget?.name, + artworkUrl = artworkUrl.takeIf { playback?.contentId == artworkContentId }, + ) + if (playback?.contentId.isNullOrBlank()) { + pauseAllPlayersAndStopSelf() + } + } + } + artworkJob = scope.launch { + controller.state + .map { it.playbackState?.contentId } + .distinctUntilChanged() + .collectLatest { contentId -> + artworkContentId = contentId + artworkUrl = null + player.updateArtwork(contentId = contentId, artworkUrl = null) + if (contentId.isNullOrBlank()) return@collectLatest + + val artwork = resolveCastArtwork(catalogRepository, contentId) + if (artworkContentId == contentId) { + // Wide backdrop is intentional for Android's wide + // system media canvas; the poster is only a fallback. + artworkUrl = artwork.backdropUrl ?: artwork.posterUrl + player.updateArtwork(contentId, artworkUrl) + } + } + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return START_NOT_STICKY + } + + override fun onTaskRemoved(rootIntent: Intent?) { + // Media3's default stops a paused session as soon as Recents dismisses + // the Activity. Keep an active remote session for the foreground grace + // window; once it is no longer foreground, the platform requires stop. + val hasRemoteMedia = !controller.state.value.playbackState?.contentId.isNullOrBlank() + if (!hasRemoteMedia || !isPlaybackOngoing()) { + super.onTaskRemoved(rootIntent) + } + } + + override fun onDestroy() { + stateJob?.cancel() + artworkJob?.cancel() + scope.cancel() + mediaSession?.release() + mediaSession = null + mediaSessionBitmapLoader?.close() + mediaSessionBitmapLoader = null + player.release() + super.onDestroy() + } +} + +@UnstableApi +internal class PrairieCastRemotePlayer( + looper: Looper, + private val controller: PrairieCastController, +) : SimpleBasePlayer(looper) { + private var playback: PrairieCastPlaybackState? = null + private var targetName: String? = null + private var artworkContentId: String? = null + private var artworkUrl: String? = null + + fun update( + playback: PrairieCastPlaybackState?, + targetName: String?, + artworkUrl: String?, + ) { + verifyApplicationThread() + this.playback = playback + this.targetName = targetName + this.artworkContentId = playback?.contentId + this.artworkUrl = artworkUrl + invalidateState() + } + + fun updateArtwork(contentId: String?, artworkUrl: String?) { + verifyApplicationThread() + if (playback?.contentId != contentId) return + artworkContentId = contentId + this.artworkUrl = artworkUrl + invalidateState() + } + + override fun getState(): State { + val remote = playback + val contentId = remote?.contentId?.takeIf(String::isNotBlank) + ?: return State.Builder() + .setAvailableCommands(Player.Commands.Builder().add(Player.COMMAND_RELEASE).build()) + .setPlaybackState(Player.STATE_IDLE) + .build() + + val durationMs = remote.duration + .takeIf { it.isFinite() && it > 0.0 } + ?.times(1000.0) + ?.roundToLong() + ?: C.TIME_UNSET + val positionMs = controller.displayTime() + .takeIf { it.isFinite() } + ?.times(1000.0) + ?.roundToLong() + ?.coerceAtLeast(0L) + ?.let { position -> + if (durationMs == C.TIME_UNSET) position else position.coerceAtMost(durationMs) + } + ?: 0L + val wantsToPlay = controller.isPlaying() || remote.isLoading || remote.isBuffering + val playbackState = if (remote.isLoading || remote.isBuffering) { + Player.STATE_BUFFERING + } else { + Player.STATE_READY + } + val metadata = MediaMetadata.Builder() + .setTitle(remote.title) + .setSubtitle(remote.subtitle ?: targetName?.let { "Playing on $it" }) + .setIsPlayable(true) + .apply { + remote.subtitle?.takeIf(String::isNotBlank)?.let { setArtist(it) } + if (durationMs != C.TIME_UNSET) setDurationMs(durationMs) + artworkUrl + ?.takeIf { artworkContentId == contentId && it.isNotBlank() } + ?.let { setArtworkUri(Uri.parse(it)) } + } + .build() + val mediaItem = MediaItem.Builder() + .setMediaId(contentId) + .setMediaMetadata(metadata) + .build() + val itemData = MediaItemData.Builder(remote.sessionId ?: contentId) + .setMediaItem(mediaItem) + .setMediaMetadata(metadata) + .setDurationUs(if (durationMs == C.TIME_UNSET) C.TIME_UNSET else durationMs * 1_000L) + .setIsSeekable(durationMs != C.TIME_UNSET) + .build() + val commands = Player.Commands.Builder() + .addAll( + Player.COMMAND_PLAY_PAUSE, + Player.COMMAND_STOP, + Player.COMMAND_RELEASE, + Player.COMMAND_GET_CURRENT_MEDIA_ITEM, + Player.COMMAND_GET_TIMELINE, + Player.COMMAND_GET_METADATA, + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_BACK, + Player.COMMAND_SEEK_FORWARD, + ) + .apply { + if (remote.hasNextEpisode) add(Player.COMMAND_SEEK_TO_NEXT) + } + .build() + + return State.Builder() + .setAvailableCommands(commands) + .setPlaylist(listOf(itemData)) + .setCurrentMediaItemIndex(0) + .setPlaybackState(playbackState) + .setIsLoading(remote.isLoading || remote.isBuffering) + .setPlayWhenReady(wantsToPlay, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE) + .setContentPositionMs(positionMs) + .setSeekBackIncrementMs(SEEK_BACK_MS) + .setSeekForwardIncrementMs(SEEK_FORWARD_MS) + .setPlaybackParameters( + PlaybackParameters( + remote.playbackSpeed.toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f, + ), + ) + .build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + controller.setPlaying(playWhenReady) + return Futures.immediateVoidFuture() + } + + override fun handleSeek( + mediaItemIndex: Int, + positionMs: Long, + seekCommand: Int, + ): ListenableFuture<*> { + if (seekCommand == Player.COMMAND_SEEK_TO_NEXT) { + controller.playNext() + } else if (positionMs != C.TIME_UNSET) { + controller.seek(positionMs.coerceAtLeast(0L) / 1000.0) + } + return Futures.immediateVoidFuture() + } + + override fun handleStop(): ListenableFuture<*> { + controller.stopPlayback() + return Futures.immediateVoidFuture() + } + + override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() + + private companion object { + const val SEEK_BACK_MS = 10_000L + const val SEEK_FORWARD_MS = 30_000L + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarter.kt new file mode 100644 index 000000000..ea1acc852 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarter.kt @@ -0,0 +1,111 @@ +package org.prairieserver.prairie.android.cast + +import android.content.Context +import android.content.Intent +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Starts the phone-only Remote Control media service while the application is + * foregrounded, then leaves Media3 to maintain its foreground lifetime after + * the Activity moves to the background. + * + * Android 12+ rejects a new foreground-service start from the background. A + * TV may begin a new title while the phone is backgrounded, so those starts + * are deliberately deferred until [onStart]. An already-running service keeps + * receiving controller state directly and does not need to be started again. + */ +class PrairieCastMediaSessionStarter( + context: Context, + private val controller: PrairieCastController, +) : DefaultLifecycleObserver { + private val appContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var appForeground = false + private var latestState = controller.state.value.toRemoteServiceState() + + fun start() { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + scope.launch { + controller.state + .map { it.toRemoteServiceState() } + .distinctUntilChanged() + .collect { state -> + latestState = state + applyServiceAction(resolveRemoteMediaServiceAction(state, appForeground)) + } + } + } + + override fun onStart(owner: LifecycleOwner) { + appForeground = true + applyServiceAction(resolveRemoteMediaServiceAction(latestState, appForeground = true)) + } + + override fun onStop(owner: LifecycleOwner) { + appForeground = false + } + + @OptIn(UnstableApi::class) + private fun applyServiceAction(action: RemoteMediaServiceAction) { + val intent = Intent(appContext, PrairieCastMediaSessionService::class.java) + runCatching { + when (action) { + RemoteMediaServiceAction.None -> Unit + RemoteMediaServiceAction.Stop -> appContext.stopService(intent) + RemoteMediaServiceAction.Start -> appContext.startService(intent) + RemoteMediaServiceAction.StartForeground -> + ContextCompat.startForegroundService(appContext, intent) + } + }.onFailure { error -> + android.util.Log.w(TAG, "Could not apply Remote Control media-service action $action", error) + } + } + + private companion object { + const val TAG = "PrairieCastMediaStarter" + } +} + +internal data class RemoteServiceState( + val hasMedia: Boolean, + val needsForegroundStart: Boolean, +) + +internal enum class RemoteMediaServiceAction { + None, + Stop, + Start, + StartForeground, +} + +internal fun resolveRemoteMediaServiceAction( + state: RemoteServiceState, + appForeground: Boolean, +): RemoteMediaServiceAction = when { + !state.hasMedia -> RemoteMediaServiceAction.Stop + !appForeground -> RemoteMediaServiceAction.None + state.needsForegroundStart -> RemoteMediaServiceAction.StartForeground + else -> RemoteMediaServiceAction.Start +} + +private fun PrairieCastControllerState.toRemoteServiceState(): RemoteServiceState { + val playback = playbackState + return RemoteServiceState( + hasMedia = !playback?.contentId.isNullOrBlank(), + needsForegroundStart = playback?.let { + it.isPlaying || it.isLoading || it.isBuffering + } == true, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt index 56a4e7d61..4e1f4bc3c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/PrairieCastSessionManager.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.android.cast import android.content.Context import android.net.Uri +import android.os.SystemClock import android.util.Log import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter @@ -23,7 +24,17 @@ import com.google.android.gms.common.images.WebImage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.prairieserver.prairie.common.player.cast.CastSeekResult import org.prairieserver.prairie.common.player.cast.CastMediaSpec +import org.prairieserver.prairie.common.player.cast.CastStagedSubtitleChange +import org.prairieserver.prairie.common.player.cast.CastSubtitleChangeResult +import org.prairieserver.prairie.common.player.cast.castReceiverTrackId import org.prairieserver.prairie.common.diagnostics.DiagnosticsCastLogger data class PrairieCastState( @@ -34,13 +45,13 @@ data class PrairieCastState( val duration: Double = 0.0, val title: String = "", val fileId: Int? = null, - /** Text tracks declared on the loaded media, for the phone-side CC picker. */ + /** Complete authoritative v3 subtitle inventory for the phone-side picker. */ val subtitleOptions: List = emptyList(), - /** Track id (from [subtitleOptions]) the receiver is rendering, null = off. */ + /** Stable option id selected by the active v3 plan, null = off. */ val activeSubtitleId: Long? = null, ) -/** A selectable receiver text track (id is the declared MediaTrack id). */ +/** A selectable authoritative subtitle row (id encodes its combined index). */ data class CastSubtitleOption( val id: Long, val label: String, @@ -53,6 +64,11 @@ data class PrairieCastRoute( val isSelected: Boolean = false, ) +private data class PendingSubtitleLoad( + val change: CastStagedSubtitleChange, + val predecessor: CastMediaSpec, +) + /** * Google Cast (Chromecast) session manager for the phone app. * @@ -73,11 +89,6 @@ data class PrairieCastRoute( * exposed via [getLastPosition] so local playback resumes where casting left. */ class PrairieCastSessionManager(private val context: Context) { - private companion object { - private const val TAG = "PrairieCastSessionMgr" - } - - private val playServicesAvailable: Boolean = runCatching { GoogleApiAvailability.getInstance() @@ -92,6 +103,7 @@ class PrairieCastSessionManager(private val context: Context) { private var routeSelector: MediaRouteSelector = MediaRouteSelector.EMPTY private var initialized = false private var activeScanning = false + private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val _castState = MutableStateFlow(PrairieCastState()) val castState: StateFlow = _castState.asStateFlow() @@ -100,22 +112,62 @@ class PrairieCastSessionManager(private val context: Context) { // Pending media set by prepareMedia; loaded when a session connects. private var pending: CastMediaSpec? = null + private var pendingSubtitleLoad: PendingSubtitleLoad? = null + /** Source-time position exposed to the phone UI. */ private var lastPosition: Double = 0.0 + /** Receiver-local position used for protocol timeline translation. */ + private var lastPlayerPosition: Double = 0.0 + private var lastProgressReportAtMs: Long = 0L + private var progressReportJob: Job? = null private var listenersAttachedTo: CastSession? = null private val remoteCallback = object : RemoteMediaClient.Callback() { - override fun onStatusUpdated() = syncCastState() + override fun onStatusUpdated() { + syncCastState() + val remoteClient = listenersAttachedTo?.remoteMediaClient + if ( + remoteClient?.playerState == MediaStatus.PLAYER_STATE_IDLE && + remoteClient.idleReason == MediaStatus.IDLE_REASON_FINISHED + ) { + finalizePending("ended") + } + } override fun onMetadataUpdated() = syncCastState() } // Fix 1: 1s progress ticks drive the live scrubber position while casting. private val progressListener = RemoteMediaClient.ProgressListener { progressMs, durationMs -> val position = progressMs / 1000.0 - lastPosition = position + val spec = pending + lastPlayerPosition = position + lastPosition = spec?.playbackSession?.sourcePositionForPlayer(position) ?: position + val duration = if (durationMs > 0) durationMs / 1000.0 else 0.0 _castState.value = _castState.value.copy( - position = position, - duration = if (durationMs > 0) durationMs / 1000.0 else _castState.value.duration, + position = lastPosition, + duration = if (duration > 0.0) { + spec?.playbackSession?.sourceDurationSeconds() ?: duration + } else { + _castState.value.duration + }, ) + val now = SystemClock.elapsedRealtime() + if ( + spec != null && + progressReportJob?.isActive != true && + now - lastProgressReportAtMs >= SERVER_PROGRESS_INTERVAL_MS + ) { + lastProgressReportAtMs = now + val paused = listenersAttachedTo?.remoteMediaClient?.playerState != + MediaStatus.PLAYER_STATE_PLAYING + val job = lifecycleScope.launch(start = CoroutineStart.LAZY) { + spec.playbackSession.reportProgress(position, paused) + } + progressReportJob = job + job.invokeOnCompletion { + if (progressReportJob === job) progressReportJob = null + } + job.start() + } } private val sessionListener = object : SessionManagerListener { @@ -128,6 +180,7 @@ class PrairieCastSessionManager(private val context: Context) { } override fun onSessionStartFailed(session: CastSession, error: Int) { DiagnosticsCastLogger.warning("cast session start failed") + finalizePending("session_start_failed") detachRemoteListeners() _castState.value = PrairieCastState() } @@ -138,6 +191,7 @@ class PrairieCastSessionManager(private val context: Context) { override fun onSessionEnded(session: CastSession, error: Int) { DiagnosticsCastLogger.event("cast session ended") captureRemotePosition(session) + finalizePending("disconnected") detachRemoteListeners() _castState.value = PrairieCastState() } @@ -149,12 +203,18 @@ class PrairieCastSessionManager(private val context: Context) { } override fun onSessionResumeFailed(session: CastSession, error: Int) { DiagnosticsCastLogger.warning("cast session resume failed") + finalizePending("session_resume_failed") detachRemoteListeners() _castState.value = PrairieCastState() } override fun onSessionSuspended(session: CastSession, reason: Int) { DiagnosticsCastLogger.warning("cast session suspended") captureRemotePosition(session) + pending?.let { spec -> + lifecycleScope.launch { + spec.playbackSession.reportProgress(lastPlayerPosition, isPaused = true) + } + } detachRemoteListeners() } } @@ -231,13 +291,34 @@ class PrairieCastSessionManager(private val context: Context) { fun prepareMedia(spec: CastMediaSpec) { DiagnosticsCastLogger.event("cast media prepared") ensureInitialized() - pending = spec - lastPosition = spec.positionSeconds - _castState.value = _castState.value.copy(title = spec.title, fileId = spec.fileId) + abandonPendingSubtitleLoad(spec) + val previous = pending + if (previous != null && previous.playbackSession !== spec.playbackSession) { + finalizeSpec(previous, "superseded") + } + publishPendingSpec(spec) val session = sessionManager?.currentCastSession if (session != null && session.isConnected) loadPendingMedia(session) } + private fun publishPendingSpec(spec: CastMediaSpec) { + progressReportJob?.cancel() + progressReportJob = null + pending = spec + lastPlayerPosition = spec.positionSeconds + lastPosition = spec.playbackSession.sourcePositionForPlayer(spec.positionSeconds) + lastProgressReportAtMs = 0L + _castState.value = _castState.value.copy( + title = spec.title, + fileId = spec.fileId, + subtitleOptions = spec.subtitles.map { subtitle -> + CastSubtitleOption(castReceiverTrackId(subtitle.combinedIndex), subtitle.label) + }, + activeSubtitleId = spec.subtitles.firstOrNull { it.selected } + ?.let { castReceiverTrackId(it.combinedIndex) }, + ) + } + private fun loadPendingMedia(session: CastSession) { val spec = pending ?: return val remoteClient = session.remoteMediaClient ?: return @@ -247,18 +328,27 @@ class PrairieCastSessionManager(private val context: Context) { spec.posterUrl?.let { addImage(WebImage(Uri.parse(it))) } } - // Fix 5: each subtitle track is text/vtt; assign stable ids for activation. - val tracks = spec.subtitles.mapIndexed { index, sub -> - MediaTrack.Builder((index + 1).toLong(), MediaTrack.TYPE_TEXT) + // The phone exposes every authoritative inventory row. Only sidecars + // the Default Media Receiver can render become receiver MediaTracks; + // burn-in/bitmap choices are represented by the replanned video itself. + val tracks = spec.subtitles.mapNotNull { sub -> + val receiverUrl = sub.receiverUrl ?: return@mapNotNull null + MediaTrack.Builder(castReceiverTrackId(sub.combinedIndex), MediaTrack.TYPE_TEXT) .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) - .setContentId(sub.url) + .setContentId(receiverUrl) .setContentType("text/vtt") .setName(sub.label) .setLanguage(sub.language ?: "") .build() } val activeTrackIds = spec.subtitles - .mapIndexedNotNull { index, sub -> if (sub.selected) (index + 1).toLong() else null } + .mapNotNull { sub -> + if (sub.selected && sub.receiverUrl != null) { + castReceiverTrackId(sub.combinedIndex) + } else { + null + } + } .toLongArray() // Fix 4: real container mime + VOD stream type. @@ -283,20 +373,86 @@ class PrairieCastSessionManager(private val context: Context) { remoteClient.load(loadRequest).setResultCallback { result -> Log.i(TAG, "cast load result success=${result.status.isSuccess} code=${result.status.statusCode}") - // The MediaInfo-embedded style alone doesn't reach the receiver's - // renderer; re-assert via the tracks channel once the load lands. - if (result.status.isSuccess) { - remoteClient.setTextTrackStyle(castTextTrackStyle()).setResultCallback { styleResult -> - Log.i( - TAG, - "cast setTextTrackStyle result success=${styleResult.status.isSuccess} " + - "code=${styleResult.status.statusCode} msg=${styleResult.status.statusMessage}", + val subtitleLoad = pendingSubtitleLoad?.takeIf { it.change.spec === spec } + if (subtitleLoad != null) { + lifecycleScope.launch { + if (result.status.isSuccess && pending === spec) { + spec.playbackSession.confirmReceiverLoadSucceeded() + val committed = subtitleLoad.change.commit() + if ( + committed != null && + pendingSubtitleLoad === subtitleLoad && + pending === spec + ) { + pendingSubtitleLoad = null + pending = committed + applyTextTrackStyle(remoteClient) + syncCastState() + } else { + restorePendingSubtitleLoad(subtitleLoad, session) + } + } else { + subtitleLoad.change.discard() + restorePendingSubtitleLoad(subtitleLoad, session) + } + } + } else if (result.status.isSuccess && pending === spec) { + spec.playbackSession.confirmReceiverLoadSucceeded() + // The MediaInfo-embedded style alone doesn't reach the + // receiver's renderer; re-assert via the tracks channel once + // the load lands. + applyTextTrackStyle(remoteClient) + } else if (!result.status.isSuccess && pending === spec) { + lifecycleScope.launch { + val replacement = spec.playbackSession.recoverFromLoadFailure( + playerPositionSeconds = lastPlayerPosition, + message = "Cast load failed (${result.status.statusCode})", ) + if (replacement != null && pending === spec) { + prepareMedia(replacement) + } else if (replacement == null && pending === spec) { + pending = null + } } } } } + private fun applyTextTrackStyle(remoteClient: RemoteMediaClient) { + remoteClient.setTextTrackStyle(castTextTrackStyle()).setResultCallback { styleResult -> + Log.i( + TAG, + "cast setTextTrackStyle result success=${styleResult.status.isSuccess} " + + "code=${styleResult.status.statusCode} msg=${styleResult.status.statusMessage}", + ) + } + } + + private fun abandonPendingSubtitleLoad(incoming: CastMediaSpec) { + val subtitleLoad = pendingSubtitleLoad ?: return + if (subtitleLoad.change.spec === incoming) return + pendingSubtitleLoad = null + if (pending === subtitleLoad.change.spec) { + pending = subtitleLoad.predecessor + } + lifecycleScope.launch { subtitleLoad.change.discard() } + } + + private fun restorePendingSubtitleLoad( + subtitleLoad: PendingSubtitleLoad, + session: CastSession, + ) { + if (pendingSubtitleLoad !== subtitleLoad) return + pendingSubtitleLoad = null + if (pending !== subtitleLoad.change.spec) return + publishPendingSpec(subtitleLoad.predecessor) + if (sessionManager?.currentCastSession === session && session.isConnected) { + loadPendingMedia(session) + } else { + syncCastState() + } + } + /** White text, black outline, no background box — the app's default look. */ private fun castTextTrackStyle(): TextTrackStyle = TextTrackStyle().apply { // GMS gotcha (verified against play-services-cast bytecode): @@ -338,7 +494,10 @@ class PrairieCastSessionManager(private val context: Context) { private fun captureRemotePosition(session: CastSession) { val position = session.remoteMediaClient?.approximateStreamPosition?.let { it / 1000.0 } - if (position != null && position >= 0.0) lastPosition = position + if (position != null && position >= 0.0) { + lastPlayerPosition = position + lastPosition = pending?.playbackSession?.sourcePositionForPlayer(position) ?: position + } } /** Fix 6: last known remote position, so local playback resumes there. */ @@ -351,7 +510,10 @@ class PrairieCastSessionManager(private val context: Context) { val session = sessionManager?.currentCastSession if (session != null) { captureRemotePosition(session) + finalizePending("disconnect_requested") sessionManager?.endCurrentSession(true) + } else { + finalizePending("disconnect_requested") } syncCastState() } @@ -360,13 +522,27 @@ class PrairieCastSessionManager(private val context: Context) { * scrubber doesn't snap back while the receiver applies the seek. */ fun seekTo(seconds: Double) { val remoteClient = sessionManager?.currentCastSession?.remoteMediaClient ?: return - remoteClient.seek( - MediaSeekOptions.Builder() - .setPosition((seconds * 1000).toLong()) - .build(), - ) lastPosition = seconds _castState.value = _castState.value.copy(position = seconds) + val spec = pending + if (spec == null) { + seekRemotePlayer(remoteClient, seconds) + return + } + lifecycleScope.launch { + when (val result = spec.playbackSession.seekToSource(seconds)) { + is CastSeekResult.Native -> { + if (pending !== spec) return@launch + lastPlayerPosition = result.playerPositionSeconds + seekRemotePlayer(remoteClient, result.playerPositionSeconds) + } + is CastSeekResult.Replanned -> { + if (pending !== spec) return@launch + prepareMedia(result.spec) + } + CastSeekResult.Failed -> if (pending === spec) syncCastState() + } + } } fun togglePlayback() { @@ -376,21 +552,54 @@ class PrairieCastSessionManager(private val context: Context) { _castState.value = _castState.value.copy(isPlaying = !isPlayingNow) } - /** Activates a declared receiver text track; null turns captions off. */ + /** Applies subtitle intent through protocol v3, then reloads the returned plan. */ fun selectSubtitleTrack(trackId: Long?) { val remoteClient = sessionManager?.currentCastSession?.remoteMediaClient ?: return - remoteClient.setActiveMediaTracks( - if (trackId == null) longArrayOf() else longArrayOf(trackId), - ) - if (trackId != null) remoteClient.setTextTrackStyle(castTextTrackStyle()) + val spec = pending ?: return + val combinedIndex = trackId?.let { selectedId -> + spec.subtitles.singleOrNull { + castReceiverTrackId(it.combinedIndex) == selectedId + }?.combinedIndex ?: return + } + val playerPosition = remoteClient.approximateStreamPosition + .takeIf { it >= 0 } + ?.div(1000.0) + ?: lastPlayerPosition _castState.value = _castState.value.copy(activeSubtitleId = trackId) + lifecycleScope.launch { + when ( + val result = spec.playbackSession.selectSubtitleTrack( + playerPositionSeconds = playerPosition, + combinedIndex = combinedIndex, + ) + ) { + is CastSubtitleChangeResult.Staged -> { + val session = sessionManager?.currentCastSession + if (pending !== spec || session == null || !session.isConnected) { + result.change.discard() + if (pending === spec) syncCastState() + return@launch + } + val subtitleLoad = PendingSubtitleLoad( + change = result.change, + predecessor = spec.copy(positionSeconds = playerPosition), + ) + pendingSubtitleLoad = subtitleLoad + publishPendingSpec(result.change.spec) + loadPendingMedia(session) + } + CastSubtitleChangeResult.Failed -> if (pending === spec) syncCastState() + } + } } /** Relative seek from the receiver's live position, clamped to the item. */ fun skipBy(deltaSeconds: Double) { val remoteClient = sessionManager?.currentCastSession?.remoteMediaClient ?: return - val current = remoteClient.approximateStreamPosition + val playerPosition = remoteClient.approximateStreamPosition .takeIf { it > 0 }?.let { it / 1000.0 } + val current = playerPosition + ?.let { pending?.playbackSession?.sourcePositionForPlayer(it) ?: it } ?: _castState.value.position val duration = _castState.value.duration var target = current + deltaSeconds @@ -414,6 +623,7 @@ class PrairieCastSessionManager(private val context: Context) { } fun release() { + finalizePending("released") detachRemoteListeners() mediaRouter?.removeCallback(routeCallback) sessionManager?.removeSessionManagerListener(sessionListener, CastSession::class.java) @@ -449,7 +659,10 @@ class PrairieCastSessionManager(private val context: Context) { _castState.value = if (session != null && session.isConnected) { val textTracks = remoteClient?.mediaInfo?.mediaTracks.orEmpty() .filter { it.type == MediaTrack.TYPE_TEXT } - val subtitleOptions = textTracks.mapIndexed { index, track -> + val plannedSubtitles = pending?.subtitles + val subtitleOptions = plannedSubtitles?.map { subtitle -> + CastSubtitleOption(castReceiverTrackId(subtitle.combinedIndex), subtitle.label) + } ?: textTracks.mapIndexed { index, track -> CastSubtitleOption( id = track.id, label = track.name?.takeIf { it.isNotBlank() } @@ -462,8 +675,14 @@ class PrairieCastSessionManager(private val context: Context) { isConnected = true, deviceName = session.castDevice?.friendlyName, isPlaying = remoteClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING, - position = remoteClient?.approximateStreamPosition?.div(1000.0) ?: lastPosition, - duration = remoteClient?.mediaInfo?.streamDuration?.takeIf { it > 0 }?.div(1000.0) + position = remoteClient?.approximateStreamPosition + ?.div(1000.0) + ?.let { player -> pending?.playbackSession?.sourcePositionForPlayer(player) ?: player } + ?: lastPosition, + duration = remoteClient?.mediaInfo?.streamDuration + ?.takeIf { it > 0 } + ?.div(1000.0) + ?.let { player -> pending?.playbackSession?.sourceDurationSeconds() ?: player } ?: _castState.value.duration, title = pending?.title ?: _castState.value.title, // Preserve the staged file id: rebuilding without it re-arms @@ -472,12 +691,52 @@ class PrairieCastSessionManager(private val context: Context) { // their server sessions, and burns the start rate limit. fileId = pending?.fileId ?: _castState.value.fileId, subtitleOptions = subtitleOptions, - activeSubtitleId = subtitleOptions - .firstOrNull { option -> activeIds?.contains(option.id) == true } - ?.id, + activeSubtitleId = if (plannedSubtitles != null) { + plannedSubtitles + .firstOrNull { it.selected } + ?.let { castReceiverTrackId(it.combinedIndex) } + } else { + subtitleOptions + .firstOrNull { option -> activeIds?.contains(option.id) == true } + ?.id + }, ) } else { _castState.value.copy(isConnected = false, deviceName = null, isPlaying = false) } } + + private fun seekRemotePlayer(remoteClient: RemoteMediaClient, playerSeconds: Double) { + remoteClient.seek( + MediaSeekOptions.Builder() + .setPosition((playerSeconds * 1000).toLong()) + .build(), + ) + } + + private fun finalizePending(event: String) { + val spec = pending ?: return + progressReportJob?.cancel() + progressReportJob = null + pendingSubtitleLoad = null + pending = null + finalizeSpec(spec, event) + } + + private fun finalizeSpec(spec: CastMediaSpec, event: String) { + val paused = listenersAttachedTo?.remoteMediaClient?.playerState != + MediaStatus.PLAYER_STATE_PLAYING + lifecycleScope.launch { + spec.playbackSession.stop( + playerPositionSeconds = lastPlayerPosition, + isPaused = paused, + reason = event, + ) + } + } + + private companion object { + private const val TAG = "PrairieCastSessionMgr" + private const val SERVER_PROGRESS_INTERVAL_MS = 10_000L + } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/RemoteControlBatteryOptimization.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/RemoteControlBatteryOptimization.kt new file mode 100644 index 000000000..e8a1ca694 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/cast/RemoteControlBatteryOptimization.kt @@ -0,0 +1,51 @@ +package org.prairieserver.prairie.android.cast + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings + +/** Android battery-policy integration for the long-lived TV control socket. */ +object RemoteControlBatteryOptimization { + private const val PREFERENCES_NAME = "remote_control" + private const val PROMPT_SHOWN_KEY = "battery_optimization_prompt_shown" + + fun isExempt(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + val powerManager = context.getSystemService(PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + fun shouldShowPrompt(context: Context): Boolean = + !isExempt(context) && + !context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .getBoolean(PROMPT_SHOWN_KEY, false) + + fun markPromptShown(context: Context) { + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(PROMPT_SHOWN_KEY, true) + .apply() + } + + /** + * Opens Android's exemption list. Using the system list instead of + * ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS avoids the restricted + * REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission while still taking the + * user directly to the setting they need to change. + */ + fun openSettings(context: Context) { + val flags = Intent.FLAG_ACTIVITY_NEW_TASK + val batterySettings = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) + .addFlags(flags) + val fallback = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}"), + ).addFlags(flags) + + runCatching { context.startActivity(batterySettings) } + .recoverCatching { context.startActivity(fallback) } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt index 2fdb061a7..13f3e28f2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt @@ -20,15 +20,21 @@ import org.prairieserver.prairie.common.pairing.RepositoryCompanionDeviceLoginAp import org.prairieserver.prairie.common.pairing.TlsPskPairingClientTransport import org.prairieserver.prairie.common.player.AudioCapabilityManager import org.prairieserver.prairie.common.player.AudioTrackManager +import org.prairieserver.prairie.common.player.AndroidSubtitlePresentation import org.prairieserver.prairie.common.player.PrairiePlayerFactory import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackSessionManager +import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionScope +import org.prairieserver.prairie.common.di.AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER +import org.prairieserver.prairie.common.di.AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.player.cast.CastPlaybackPreparer import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStarter +import org.prairieserver.prairie.android.BuildConfig import org.prairieserver.prairie.common.network.AndroidDeviceMetadataProvider +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.common.network.CleartextConsentStore import org.prairieserver.prairie.common.network.DataStoreCleartextConsentStore import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache @@ -44,16 +50,9 @@ import org.prairieserver.prairie.android.push.AndroidPushTokenProvider import org.prairieserver.prairie.android.push.FirebaseAndroidPushTokenProvider import org.prairieserver.prairie.android.push.PushMessageHandler import org.prairieserver.prairie.android.push.PushNotificationPresenter -import org.prairieserver.prairie.android.ui.screens.admin.AdminEntryViewModel -import org.prairieserver.prairie.android.ui.screens.admin.AdminLogsViewModel -import org.prairieserver.prairie.android.ui.screens.admin.AdminScansViewModel -import org.prairieserver.prairie.android.ui.screens.admin.AdminSessionsViewModel import org.prairieserver.prairie.android.ui.screens.browse.BrowseViewModel import org.prairieserver.prairie.android.ui.screens.collections.CollectionDetailViewModel import org.prairieserver.prairie.android.ui.screens.collections.LibraryCollectionsViewModel -import org.prairieserver.prairie.viewmodel.AdminStatsViewModel -import org.prairieserver.prairie.viewmodel.AdminUserEditViewModel -import org.prairieserver.prairie.viewmodel.AdminUsersViewModel import org.prairieserver.prairie.viewmodel.CalendarViewModel import org.prairieserver.prairie.viewmodel.CollectionsViewModel import org.prairieserver.prairie.android.ui.screens.detail.ItemDetailViewModel @@ -61,7 +60,9 @@ import org.prairieserver.prairie.android.ui.screens.people.PersonDetailViewModel import org.prairieserver.prairie.android.ui.screens.auth.LoginViewModel import org.prairieserver.prairie.android.ui.screens.auth.ServerSetupViewModel import org.prairieserver.prairie.android.ui.screens.auth.SetupViewModel +import org.prairieserver.prairie.android.ui.screens.auth.InviteClaimViewModel import org.prairieserver.prairie.android.ui.screens.auth.SignupViewModel +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourViewModel import org.prairieserver.prairie.android.ui.screens.MainHeaderViewModel import org.prairieserver.prairie.viewmodel.DevicePairingViewModel import org.prairieserver.prairie.android.ui.screens.profiles.CreateProfileViewModel @@ -78,6 +79,8 @@ import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.prairieserver.prairie.viewmodel.RequestDetailViewModel import org.prairieserver.prairie.viewmodel.RequestSearchViewModel import org.prairieserver.prairie.viewmodel.RequestsViewModel +import org.prairieserver.prairie.viewmodel.LiveTvViewModel +import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel import org.prairieserver.prairie.viewmodel.WatchlistViewModel import org.prairieserver.prairie.android.ui.screens.player.MobileVideoPlaybackStarter import org.prairieserver.prairie.android.ui.screens.player.PlayerViewModel @@ -94,8 +97,6 @@ import org.koin.androidx.workmanager.dsl.worker import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module -import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel -import org.prairieserver.prairie.viewmodel.LiveTvViewModel /** * Android-specific Koin module. @@ -121,6 +122,7 @@ val androidModule = module { // when the redefining module is loaded after the original — sharedModules() // is registered first in PrairieApplication, so this wins. single { EncryptedTokenManagerImpl(get(), get(), get()) } + single { org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourLocalCache(androidContext()) } // Offline-first Room store (Track B). Bound after sharedModules() so the // commonMain PersonalDataRepository's `getOrNull()` picks @@ -146,6 +148,7 @@ val androidModule = module { org.prairieserver.prairie.common.data.repository.RoomHomeCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -153,6 +156,7 @@ val androidModule = module { org.prairieserver.prairie.common.data.repository.RoomCatalogCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -173,13 +177,21 @@ val androidModule = module { // App-wide services single { AndroidServerSettingsCache(androidContext()) } + // The one place the phone app's BuildConfig crosses into android-shared: + // every collaborator that reports client identity (headers, playback + // context, diagnostics) resolves this rather than deriving its own answer. + single { PrairieClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } single { - AndroidDeviceMetadataProvider(androidContext(), platform = "android") + AndroidDeviceMetadataProvider( + androidContext(), + platform = "android", + buildIdentity = get(), + ) } single { PrairieCastNsdBrowser(androidContext()) } single { CompanionPairingNsdBrowser(androidContext()) } single { RegistryCompanionPairingServerStore(get(), get()) } - single { RepositoryCompanionDeviceLoginApprover(get()) } + single { RepositoryCompanionDeviceLoginApprover(get(), get()) } single { CompanionPairingTransportFactory { target -> TlsPskPairingClientTransport.connect(target.host, target.port) @@ -212,12 +224,18 @@ val androidModule = module { PushNotificationPresenter( context = androidContext(), notificationsRepository = get(), + tokenManager = get(), ) } single { PushMessageHandler(presenter = get()) } // Player infrastructure - single { SubtitleManager(get()) } + single { + SubtitleManager( + libassBridge = get(), + presentation = AndroidSubtitlePresentation.Phone, + ) + } single { AudioTrackManager() } single { VideoPlaybackBackendFactory( @@ -227,7 +245,7 @@ val androidModule = module { ) } single { AudioCapabilityManager(androidContext()) } - single { PlaybackCapabilityDetector(androidContext(), get(), get()) } + single { PlaybackCapabilityDetector(androidContext(), get(), get(), get()) } single { PrairiePlayerFactory( context = androidContext(), @@ -242,6 +260,14 @@ val androidModule = module { ) } single { PlaybackSessionManager(get(), get(), get()) } + single(AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER) { + PlaybackSessionManager( + playbackRepository = get(), + tokenManager = get(), + networkEvidenceProvider = get(), + passthroughSuppression = PassthroughSuppressionScope.None, + ) + } // Google Cast (Chromecast) — phone only. The session manager owns the Cast // SDK lifecycle; the preparer opens the separate Tier-2 cast-capability // playback session so the raw phone stream is never cast. @@ -262,6 +288,7 @@ val androidModule = module { playerSettingsStore = get(), sessionLifecycle = get(), reachabilityMonitor = get(), + userItemStatePort = get(), ) } factory { @@ -272,7 +299,7 @@ val androidModule = module { // Offline downloads — public MediaStore bytes plus private sidecars. // Media files keep original names so other Android readers/players can - // discover them under Downloads/Prairie. + // discover them under Downloads/Silo. single { DownloadStorage(androidContext()) } // Download metadata now lives in Room (replaces the .record.json sidecars). single { org.prairieserver.prairie.common.downloads.DownloadMetadataStore(get()) } @@ -338,7 +365,7 @@ val androidModule = module { castPlaybackPreparer = get(), ) } - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) } viewModel { MainHeaderViewModel(get()) } viewModel { LibrariesViewModel( @@ -367,14 +394,22 @@ val androidModule = module { } viewModel { params -> ItemDetailViewModel( - get(), get(), get(), get(), get(), get(), params.get(), + get(), get(), get(), get(), get(), get(), get(), params.get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, ) } viewModel { params -> PersonDetailViewModel(get(), params.get()) } viewModel { params -> LibraryCollectionsViewModel(get(), params.get()) } - viewModel { FavoritesViewModel(get()) } - viewModel { WatchlistViewModel(get()) } + viewModel { FavoritesViewModel(get(), get()) } + viewModel { WatchlistViewModel(get(), get()) } + // Sort/filter for one saved list; callers scope it to the Activity keyed + // by source so the For You grid and the standalone screens share it. + viewModel { params -> + org.prairieserver.prairie.android.ui.screens.personal.PersonalListControlsViewModel( + source = params.get(), + catalogRepository = get(), + ) + } viewModel { HistoryViewModel(get()) } viewModel { CollectionsViewModel(get()) } viewModel { params -> CollectionDetailViewModel(get(), get(), params.get()) } @@ -388,6 +423,7 @@ val androidModule = module { repository = get(), timezoneId = java.util.TimeZone.getDefault().id, todayProvider = { java.time.LocalDate.now().toString() }, + filterStore = org.prairieserver.prairie.android.ui.screens.calendar.CalendarPrefsStore(androidContext()), ) } viewModel { params -> @@ -400,19 +436,14 @@ val androidModule = module { } viewModel { SettingsViewModel(get(), get(), get(), get(), get(), get()) } viewModel { DiagnosticsViewModel(get()) } - viewModel { AdminEntryViewModel(get(), get()) } - viewModel { AdminStatsViewModel(get()) } - viewModel { AdminUsersViewModel(get()) } - viewModel { AdminUserEditViewModel(get()) } - viewModel { AdminSessionsViewModel(get()) } - viewModel { AdminLogsViewModel(get()) } - viewModel { AdminScansViewModel(get(), get()) } viewModel { DownloadsViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel(get(), get()) } viewModel { ServerSetupViewModel(get(), get()) } viewModel { LoginViewModel(get()) } viewModel { SetupViewModel(get()) } viewModel { SignupViewModel(get()) } + viewModel { InviteClaimViewModel(get(), get()) } + viewModel { OnboardingTourViewModel(get(), get(), get(), get(), get()) } viewModel { ProfileSelectionViewModel(get()) } viewModel { CreateProfileViewModel(get()) } viewModel { EditProfileViewModel(get()) } @@ -444,7 +475,8 @@ val androidModule = module { viewModel { org.prairieserver.prairie.common.player.AudiobookPlayerViewModel( catalogRepository = get(), - playbackSessionManager = get(), + playbackSessionManager = get(AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER), + playbackSessionLifecycle = get(AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER), capabilityDetector = get(), bookmarksStore = get(), userItemStatePort = get(), @@ -471,13 +503,6 @@ val androidModule = module { } viewModel { org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModel(get()) } viewModel { org.prairieserver.prairie.android.ui.screens.watchtogether.SuggestToRoomViewModel(get()) } - viewModel { - LiveTvViewModel( - repository = get(), - nowMillisProvider = { System.currentTimeMillis() }, - ) - } - viewModel { LiveTvPlayerViewModel(get()) } viewModel { params -> org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyViewModel( roomId = params.get(), @@ -485,4 +510,12 @@ val androidModule = module { roomSession = get(), ) } + + viewModel { + LiveTvViewModel( + repository = get(), + nowMillisProvider = { System.currentTimeMillis() }, + ) + } + viewModel { LiveTvPlayerViewModel(get()) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt index 82d30f966..b7e05c20f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/downloads/AppWorkerFactory.kt @@ -13,7 +13,9 @@ import org.prairieserver.prairie.common.downloads.DownloadSubscriptionWorker import org.prairieserver.prairie.common.downloads.DownloadWorker import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadWorker -import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploader +import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsDeletionWorker +import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsReportDeleter +import org.prairieserver.prairie.common.diagnostics.PendingReportStore import org.prairieserver.prairie.repository.DownloadSubscriptionRepository import org.prairieserver.prairie.repository.DownloadsRepository import io.ktor.client.HttpClient @@ -83,10 +85,18 @@ class AppWorkerFactory : WorkerFactory() { DiagnosticsUploadWorker( appContext = appContext, params = workerParameters, - uploader = koin.get(), coordinator = koin.get(), ) } + HostedDiagnosticsDeletionWorker::class.java.name -> { + Log.i(TAG, "Building HostedDiagnosticsDeletionWorker via Koin") + HostedDiagnosticsDeletionWorker( + appContext = appContext, + params = workerParameters, + reports = koin.get(), + deleter = koin.get(), + ) + } else -> { Log.w(TAG, "No factory match for $workerClassName — returning null") null diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt index 6bc0bd391..4d8ea48cb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt @@ -16,17 +16,26 @@ import org.prairieserver.prairie.android.ui.navigation.Route import org.prairieserver.prairie.model.notifications.NotificationRow import org.prairieserver.prairie.model.notifications.NotificationType import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.NotificationsRepository class PushNotificationPresenter( private val context: Context, private val notificationsRepository: NotificationsRepository, + private val tokenManager: TokenManager, ) { suspend fun present( deliveryId: String, fallbackTitle: String? = null, fallbackBody: String? = null, ) { + // Captured BEFORE the fetch, which can take seconds: reading identity + // afterwards attributed the notification to whatever the user had + // switched to in the meantime. The scope snapshot (not just the server + // id) is what detects an A→B→A round trip across the fetch, which a + // plain id comparison reports as "unchanged". + val scopeBeforeFetch = tokenManager.snapshotCurrentScope() + // Fetch before the permission check: on a direct-lookup miss the fallback // refreshes the inbox, which keeps the in-app badge current even for a // profile that has denied POST_NOTIFICATIONS. @@ -34,15 +43,61 @@ class PushNotificationPresenter( if (!canPostNotifications()) return ensureChannel() + // Stamp the identity this notification belongs to. A notification is + // generated for one profile's inbox on one server, and its route (an + // item id, or the inbox itself) means something different — or nothing + // — under another. Without this the tap acted on whoever was signed in + // when it was opened, which for a PendingIntent can be days later and + // several profile switches away. + // + // The issuer must be established COMPLETELY or not at all. A partial + // identity is worse than none: a null component is treated as a + // wildcard at delivery, so a half-attributed notification can act under + // an identity that never generated it. + // + // The fetched row is authoritative for the profile — it IS the row from + // that profile's inbox. The server is only trusted if the scope did not + // move across the fetch. + // + // KNOWN LIMIT: the push payload carries no issuing server, and the FCM + // token stays registered with previously-active servers, so a push from + // server A arriving while B is active simply misses its lookup — it + // cannot be attributed to A at all, and is posted non-navigable below. + // Attributing it to B is what this used to do, and is wrong. Fixing it + // properly needs issuer fields in the push protocol: server-side work. + val scopeAfterFetch = tokenManager.snapshotCurrentScope() + val attribution = pushNotificationAttribution( + rowProfileId = row?.profileId, + serverIdBefore = scopeBeforeFetch?.serverId, + identityGenerationBefore = scopeBeforeFetch?.identityGeneration, + serverIdAfter = scopeAfterFetch?.serverId, + identityGenerationAfter = scopeAfterFetch?.identityGeneration, + ) + val issuingServerId = attribution?.serverId + val issuingProfileId = attribution?.profileId + val attributable = attribution != null + val content = notificationContentFor( - row = row, - fallbackTitle = fallbackTitle, - fallbackBody = fallbackBody, + // Both sources of text are withheld when we cannot say whose this + // is. The ROW matters as much as the payload: if the identity moved + // across the fetch, its series/episode details belong to whoever we + // just stopped being. An unattributable notification is generic as + // well as non-navigable. + row = row.takeIf { attributable }, + fallbackTitle = fallbackTitle.takeIf { attributable }, + fallbackBody = fallbackBody.takeIf { attributable }, ) val contentIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra(EXTRA_DELIVERY_ID, deliveryId) - putExtra(EXTRA_NAV_ROUTE, content.route) + // Only navigable when we know whose it is. Unattributable ones still + // post — the user should see the event — but tapping just opens the + // app rather than acting on someone else's library. + if (attributable) { + putExtra(EXTRA_NAV_ROUTE, content.route) + putExtra(EXTRA_SERVER_ID, issuingServerId) + putExtra(EXTRA_PROFILE_ID, issuingProfileId) + } } val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) @@ -97,7 +152,10 @@ class PushNotificationPresenter( if (row == null) { return PushNotificationContent( title = fallbackTitle?.takeIf { it.isNotBlank() } ?: "Prairie notification", - body = fallbackBody?.takeIf { it.isNotBlank() } ?: "Open Prairie to view it.", + // Deliberately does not promise this specific event is visible + // under the active identity — it may not be ours to show. + body = fallbackBody?.takeIf { it.isNotBlank() } + ?: "Open Prairie to check notifications.", route = Route.Inbox.route, ) } @@ -161,9 +219,13 @@ class PushNotificationPresenter( context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED companion object { - const val CHANNEL_ID = "prairie_notifications" - const val EXTRA_DELIVERY_ID = "prairie_notification_delivery_id" - const val EXTRA_NAV_ROUTE = "prairie_notification_nav_route" + const val CHANNEL_ID = "silo_notifications" + const val EXTRA_DELIVERY_ID = "silo_notification_delivery_id" + const val EXTRA_NAV_ROUTE = "silo_notification_nav_route" + + /** Identity the notification was generated for; see [present]. */ + const val EXTRA_SERVER_ID = "silo_notification_server_id" + const val EXTRA_PROFILE_ID = "silo_notification_profile_id" } } @@ -172,3 +234,40 @@ private data class PushNotificationContent( val body: String, val route: String, ) + +/** A notification's established issuer, or null when it cannot be attributed. */ +data class PushNotificationAttribution(val serverId: String, val profileId: String) + +/** + * Establishes who a notification belongs to — completely, or not at all. + * + * A partial identity is worse than none: a missing component is a wildcard at + * delivery, so a half-attributed notification can act under an identity that + * never generated it. + * + * The profile comes from the fetched ROW or nowhere. Falling back to the active + * profile is the original misattribution: a push issued by server A that + * arrives while B is active misses its lookup, and the fallback stamped it as + * B's and navigated into B's library. + * + * The scope must also have held across the fetch, which can take seconds. + * Deliberately compares `serverId + identityGeneration` and NOT + * `credentialEpoch`: the epoch moves on persistent credential writes, which are + * not identity changes, so including it would let ordinary token churn make a + * legitimate notification generic. Comparing generations rather than ids is + * what catches an A→B→A round trip. + */ +fun pushNotificationAttribution( + rowProfileId: String?, + serverIdBefore: String?, + identityGenerationBefore: Long?, + serverIdAfter: String?, + identityGenerationAfter: Long?, +): PushNotificationAttribution? { + val profileId = rowProfileId?.takeIf { it.isNotBlank() } ?: return null + val serverId = serverIdBefore?.takeIf { it.isNotBlank() } ?: return null + if (serverIdAfter == null || identityGenerationBefore == null) return null + if (serverId != serverIdAfter) return null + if (identityGenerationBefore != identityGenerationAfter) return null + return PushNotificationAttribution(serverId = serverId, profileId = profileId) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/HeroBackdropLayers.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/HeroBackdropLayers.kt deleted file mode 100644 index 710b2fc95..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/HeroBackdropLayers.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.prairieserver.prairie.android.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.CompositingStrategy -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.common.ui.components.ThumbhashImage - -/** - * Page-level tinted gradient sampled from the active hero's dominant color. - * Mirrors iOS `HomeView.heroTintBackground`. - * Sits behind the scrollable content so the tint extends past the hero - * region without leaving a seam where the artwork ends. - */ -@Composable -internal fun HeroTintBackground(tint: Color) { - val background = MaterialTheme.colorScheme.background - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0.0f to tint, - 0.35f to tint.copy(alpha = 0.55f), - 0.8f to background, - 1.0f to background, - ), - ), - ) -} - -/** - * Full-bleed blurred backdrop painted at the page level. Ignores the top - * window inset so the blur reaches behind the status bar / chrome and - * fades into the page tint via a vertical mask. No-op when [url] is null - * or blank. Mirrors iOS `HomeView.heroBackdropImage`. - */ -@Composable -internal fun HeroBackdropImage( - url: String?, - thumbhash: String?, -) { - if (url.isNullOrBlank()) return - val config = LocalConfiguration.current - val backdropHeight = (config.screenHeightDp.dp * 0.72f).coerceIn(420.dp, 580.dp) + 260.dp - Box( - modifier = Modifier - .fillMaxWidth() - .height(backdropHeight) - .graphicsLayer { - compositingStrategy = CompositingStrategy.Offscreen - } - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - 0.0f to Color.Black, - 0.42f to Color.Black, - 0.66f to Color.Black.copy(alpha = 0.7f), - 0.86f to Color.Black.copy(alpha = 0.25f), - 1.0f to Color.Transparent, - ), - blendMode = BlendMode.DstIn, - ) - }, - ) { - ThumbhashImage( - url = url, - thumbhash = thumbhash, - contentDescription = null, - contentScale = ContentScale.Crop, - // Heavily blurred + full-screen: full-res decode is wasted, so cap it. - decodeSizePx = 360, - modifier = Modifier - .fillMaxSize() - .blur(22.dp) - .graphicsLayer { scaleX = 1.04f; scaleY = 1.04f }, - ) - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.34f)), - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(140.dp) - .background( - Brush.verticalGradient( - listOf(Color.Black.copy(alpha = 0.54f), Color.Transparent), - ), - ), - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt index b192265f4..de391427f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt @@ -1,60 +1,47 @@ package org.prairieserver.prairie.android.ui.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState import org.prairieserver.prairie.android.R -import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar import org.prairieserver.prairie.model.profile.Profile // Height of the floating top bar's body, excluding the status-bar inset -// (6dp top + 42dp action row + 28dp bottom). Callers add WindowInsets.statusBars -// so tab content clears the bar regardless of status-bar height. -val MainAppHeaderBodyHeight = 76.dp +// (4dp top + 40dp action row + 8dp bottom — iOS headerTopInset / smallPadding, +// same as Home's chrome). Callers add WindowInsets.statusBars so tab content +// clears the bar regardless of status-bar height. +val MainAppHeaderBodyHeight = 52.dp +/** + * Shared floating header for the tabs that do not paint their own chrome + * (For You, Calendar, Downloads). Same recipe as Home: glass over the tab + * content (registered on [hazeState]) capped with a hairline, a leading + * title or wordmark, and the shared trailing action cluster. + */ @Composable fun MainAppTopBar( activeProfile: Profile?, isProfileLoading: Boolean, + hazeState: HazeState, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, - onLiveTvClick: (() -> Unit)? = null, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -63,169 +50,51 @@ fun MainAppTopBar( PrairieWordmark() }, ) { - var menuExpanded by rememberSaveable { mutableStateOf(false) } val statusBarPadding = WindowInsets.statusBars.asPaddingValues() Box( modifier = Modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0.96f), - MaterialTheme.colorScheme.background.copy(alpha = 0.82f), - MaterialTheme.colorScheme.background.copy(alpha = 0.42f), - MaterialTheme.colorScheme.background.copy(alpha = 0f), - ), - ), - ) - .padding( - top = statusBarPadding.calculateTopPadding() + 6.dp, - start = 16.dp, - end = 16.dp, - bottom = 28.dp, - ), + .topBarGlass(hazeState), ) { Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .padding( + top = statusBarPadding.calculateTopPadding() + 4.dp, + start = 16.dp, + end = 16.dp, + bottom = 8.dp, + ) + .fillMaxWidth(), ) { Box( + modifier = Modifier.align(Alignment.CenterStart), contentAlignment = Alignment.CenterStart, ) { leadingContent() } - Row( + TabTopBarActions( modifier = Modifier.align(Alignment.CenterEnd), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - HeaderActionButton( - onClick = onSearchClick, - ) { - androidx.compose.material3.Icon( - imageVector = Icons.Outlined.Search, - contentDescription = "Search", - ) - } - - Box { - HeaderActionButton( - onClick = { menuExpanded = true }, - ) { - if (activeProfile != null) { - ProfileAvatar( - avatar = activeProfile.avatar, - name = activeProfile.name, - size = 34.dp, - ) - } else { - Box( - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f)), - contentAlignment = Alignment.Center, - ) { - androidx.compose.material3.Icon( - imageVector = Icons.Outlined.Person, - contentDescription = "Account and menu", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - if (onRequestsClick != null) { - DropdownMenuItem( - text = { Text("Requests") }, - onClick = { - menuExpanded = false - onRequestsClick() - }, - ) - } - if (onLiveTvClick != null) { - DropdownMenuItem( - text = { Text("Live TV") }, - onClick = { - menuExpanded = false - onLiveTvClick() - }, - ) - } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } - DropdownMenuItem( - text = { Text("Settings") }, - onClick = { - menuExpanded = false - onSettingsClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Profile") }, - onClick = { - menuExpanded = false - onSwitchProfileClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Server") }, - onClick = { - menuExpanded = false - onSwitchServerClick() - }, - ) - DropdownMenuItem( - text = { - Text( - text = "Sign Out", - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - menuExpanded = false - onSignOutClick() - }, - ) - } - } - } + activeProfile = activeProfile, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) } - } -} -@Composable -private fun HeaderActionButton( - onClick: () -> Unit, - content: @Composable BoxScope.() -> Unit, -) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - border = androidx.compose.foundation.BorderStroke( - width = 1.dp, - color = Color.White.copy(alpha = 0.06f), - ), - ) { + // Bottom hairline (iOS 0.75pt, white 0.10). Box( modifier = Modifier - .size(42.dp) - .padding(4.dp), - contentAlignment = Alignment.Center, - ) { - content() - } + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.75.dp) + .drawBehind { drawRect(color = Color.White, alpha = 0.10f) }, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt index 9ece26055..a7f8baf2c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaCard.kt @@ -39,7 +39,11 @@ import org.prairieserver.prairie.model.catalog.MediaItemUserState import org.prairieserver.prairie.overlays.OverlayData object MediaGridDefaults { - val PosterGridMinWidth = 110.dp + // Same minimum as the Library grid's Normal density (CatalogViewDensity), + // so collections and saved lists break into the same column count as + // Library on every screen width — 110dp tipped them into one fewer column + // on scaled-up displays. + val PosterGridMinWidth = 104.dp val PosterGridHorizontalSpacing = 12.dp val PosterGridVerticalSpacing = 16.dp } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt index fcc75c5dc..0ec1115a2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt @@ -25,7 +25,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.overlays.OverlayDataExtractor @@ -78,17 +80,13 @@ fun MediaRow( } else { null } - val isEpisode = item.seriesTitle != null - val imageUrl = if (isEpisode) { - item.posterUrl ?: item.backdropUrl - } else { - item.backdropUrl ?: item.posterUrl - } - val imageThumbhash = if (isEpisode) { - item.posterThumbhash ?: item.backdropThumbhash - } else { - item.backdropThumbhash ?: item.posterThumbhash - } + // Landscape cards take the backdrop first for every item type + // (iOS EpisodeThumbCard). For episodes the server's backdrop_url + // IS the episode still (falling back to the series backdrop), + // while poster_url is the season/series portrait — which the + // 16:9 frame used to crop down to a sliver of the title art. + val imageUrl = item.backdropUrl ?: item.posterUrl + val imageThumbhash = item.backdropThumbhash ?: item.posterThumbhash MediaRowItemModel( item = item, progress = progress, @@ -104,7 +102,7 @@ fun MediaRow( Column(modifier = modifier) { // iOS MediaRow header: optional leading icon (16pt semibold onSurface, - // 6pt to the title), prairieHeadline (16sp) title, plain caption + // 6pt to the title), siloHeadline (16sp) title, plain caption // "See All" at onSurface 0.6 — no chevron. rowVerticalSpacing = // smallPadding (8dp) below the header. Row( @@ -124,12 +122,22 @@ fun MediaRow( imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(20.dp), ) } + // Row headings read a step larger than the 16sp headline so + // "Continue Watching" / "Next Up" carry the feed against + // 14sp card captions. 20sp at the default font scale (and it + // grows with larger settings as usual), but floored at 20dp + // so a "small" system font cannot shrink it into a caption. + val density = LocalDensity.current + val headingSize = with(density) { maxOf(20.sp.toPx(), 20.dp.toPx()).toSp() } Text( text = title, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = headingSize, + lineHeight = headingSize * 1.3f, + ), color = MaterialTheme.colorScheme.onSurface, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt new file mode 100644 index 000000000..fee0cf720 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieConfirmDialog.kt @@ -0,0 +1,113 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer + +/** + * The one confirmation dialog. + * + * Extracted from the "Remove all downloads?" dialog that was inline in + * `SettingsScreen`, because sign-out now needs the identical gate from two + * unrelated places and a third hand-rolled `AlertDialog` is how a surface ends + * up with three reds and three button orders. + * + * Cancel is the safe choice and sits where M3 puts the dismissive action; the + * confirm button carries [PrairieDestructive] when the action destroys or + * discards something. + */ +@Composable +fun PrairieConfirmDialog( + title: String, + body: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + dismissLabel: String = "Cancel", + destructive: Boolean = true, + confirmEnabled: Boolean = true, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + shape = RoundedCornerShape(SettingsDimens.cardRadius), + containerColor = PrairieSurfaceContainer, + titleContentColor = PrairieForeground, + textContentColor = PrairieMutedText, + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { + TextButton( + enabled = confirmEnabled, + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors( + contentColor = if (destructive) PrairieDestructive else PrairieForeground, + disabledContentColor = (if (destructive) PrairieDestructive else PrairieForeground) + .copy(alpha = SettingsDimens.disabledAlpha), + ), + ) { + Text(confirmLabel) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + colors = ButtonDefaults.textButtonColors(contentColor = PrairieForeground), + ) { + Text(dismissLabel) + } + }, + ) +} + +/** + * The sign-out gate, shared by both places that can sign this device out: the + * profile menu in the top bar, and the Sign out row in the settings Account + * card. Neither confirmed before, and gating only one of them would make the + * app's answer to "are you sure?" depend on which button the user happened to + * reach for. + * + * The body states what sign-out actually does, which is less than users tend + * to assume: `AuthRepository.logout` clears the tokens and profile state for + * the active server and deliberately keeps its `ServerRegistry` entry, and + * downloaded files are only ever deleted by `OrphanedServerDataPurger`, which + * fires on a server being *removed from the registry* — never on sign-out. So + * the copy promises the downloads and the saved server survive, because they + * do. + * + * @param accountName Named in the body where the caller knows it. The settings + * Account card has the signed-in [org.prairieserver.prairie.model.auth.User]; the + * top bar knows only the active profile, which is not the account being + * signed out and must not be substituted for it. + */ +@Composable +fun SignOutConfirmDialog( + visible: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + accountName: String? = null, +) { + if (!visible) return + PrairieConfirmDialog( + title = "Sign out?", + body = buildString { + append("This signs this device out of ") + append(if (accountName.isNullOrBlank()) "your account" else "$accountName's account") + append(". Downloads stay on this device and the server stays saved, ") + append("so you can sign back in without setting it up again.") + }, + confirmLabel = "Sign out", + onConfirm = onConfirm, + onDismiss = onDismiss, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActions.kt new file mode 100644 index 000000000..9b1d433fd --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActions.kt @@ -0,0 +1,24 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.text.KeyboardActions + +/** + * Keyboard actions for a Silo phone text field. + * + * Installing `KeyboardActions(onAny = ...)` takes ownership of the IME event. + * The shared field families did that unconditionally against a no-op default + * callback, which silently replaced Compose's default handling with nothing: + * pressing Next on Login, Signup, Setup, Create Profile or Edit Profile ran an + * empty lambda and left the cursor where it was, so every multi-field form + * needed a tap to advance. + * + * With a callback supplied the field keeps its explicit behavior and invokes it + * once. With none, the platform defaults apply: Next and Previous move focus, + * Done closes the IME, and Go, Search and Send do nothing. + */ +fun siloKeyboardActions(onImeAction: (() -> Unit)?): KeyboardActions = + if (onImeAction == null) { + KeyboardActions.Default + } else { + KeyboardActions(onAny = { onImeAction() }) + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt new file mode 100644 index 000000000..3086ec33e --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieMenu.kt @@ -0,0 +1,113 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.android.ui.theme.MenuDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieBorder +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer +import org.prairieserver.prairie.android.ui.theme.siloRowTopDivider + +// The popup half of the grouped-surface pass. +// +// A stock `DropdownMenu` full of `DropdownMenuItem`s renders on M3's own +// container colour at M3's own radius with M3's own `bodyLarge` label, which +// is why the profile menu read as visibly cheaper than the settings screen it +// opens. These two primitives put a menu on the same surface, radius, hairline +// and label type as a settings card — and nothing else: no descriptions, no +// leading icons, no trailing controls. A menu is terse by definition, and the +// settings rows dropped their leading icons for exactly the reason a menu +// should not gain them. + +/** + * A [DropdownMenu] wearing the grouped-surface treatment. + * + * Every colour the popup paints is passed explicitly. M3 would otherwise + * default `containerColor` to `surfaceContainer` and `tonalElevation` to 3dp — + * currently harmless (the scheme's `surfaceContainer` *is* the card colour and + * its `surfaceTint` is transparent, so the tonal overlay resolves to nothing), + * but harmless by coincidence of three unrelated theme values. Naming them + * keeps a future tweak to any one of those from quietly re-tinting every menu. + * + * The drop shadow is left at the M3 default: a popup floats over artwork and + * needs the lift, and unlike the tonal overlay a shadow does not push the + * surface colour off-palette. + */ +@Composable +fun PrairieDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + content: @Composable ColumnScope.() -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = modifier, + offset = offset, + shape = RoundedCornerShape(MenuDimens.cornerRadius), + containerColor = PrairieSurfaceContainer, + tonalElevation = 0.dp, + border = BorderStroke(MenuDimens.borderThickness, PrairieBorder), + content = content, + ) +} + +/** + * One row of a [PrairieDropdownMenu]: a label, and nothing else. + * + * @param showDivider Draws the settings hairline above this row. Menus use it + * to separate groups, not to rule every row — a settings card separates its + * groups by being a different card, which a single popup cannot do. + */ +@Composable +fun PrairieMenuItem( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + labelColor: Color = PrairieForeground, + showDivider: Boolean = false, +) { + Row( + // `fillMaxWidth` resolves against the menu's `IntrinsicSize.Max` + // column, so every row ends up as wide as the widest label rather + // than as wide as the window. + modifier = modifier + .fillMaxWidth() + .widthIn(min = MenuDimens.minWidth) + .heightIn(min = MenuDimens.rowMinHeight) + .siloRowTopDivider(showDivider) + .clickable(onClick = onClick) + .padding( + horizontal = MenuDimens.rowHorizontalPadding, + vertical = MenuDimens.rowVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt index 2c9ac7d62..1411e855c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/PrairieTopBar.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color /** * Reusable top app bar for Prairie screens. @@ -18,6 +19,9 @@ import androidx.compose.runtime.Composable * @param title Screen title displayed in the top bar. * @param onBackClick When non-null, a back arrow is shown as the navigation icon. * @param actions Composable slot for trailing action icons. + * @param containerColor Bar background. Defaults to the app's `surface`; the + * grouped-settings screens pass their own lifted page ground so the bar and + * the list below it are one continuous surface instead of two tones. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,6 +29,7 @@ fun PrairieTopBar( title: String, onBackClick: (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, + containerColor: Color = MaterialTheme.colorScheme.surface, ) { TopAppBar( title = { @@ -45,7 +50,7 @@ fun PrairieTopBar( }, actions = actions, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = containerColor, titleContentColor = MaterialTheme.colorScheme.onSurface, navigationIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt new file mode 100644 index 000000000..eab68342e --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt @@ -0,0 +1,115 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive + +/** + * The profile-avatar dropdown, in one place. + * + * Home, Libraries and the shared top bar each paint their own avatar button — + * a chip on the floating bar, a bare 40dp target on the two screens that own + * their chrome — but the menu behind all three was the same six items copied + * three times, which is how "Switch Profile" survived the move to sentence + * case in three files at once. The anchors stay where they are; the menu is + * this. + * + * Item order and gating are unchanged. A null [onRequestsClick] is a server + * with `requests_enabled` off, and a null [onWatchTogetherClick] is the + * client-side Watch Together gate; neither is ever shown unconditionally, and + * nothing new was added. Reading/ebooks are phone-only and reached from + * Libraries, and Requests keeps its two entry points (this menu and search). + * + * Sign out is gated by [SignOutConfirmDialog] — the same dialog the settings + * Account card raises, so the confirmation does not depend on the route taken. + */ +@Composable +fun ProfileMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + onSettingsClick: () -> Unit, + onSwitchProfileClick: () -> Unit, + onSwitchServerClick: () -> Unit, + onSignOutClick: () -> Unit, + onRequestsClick: (() -> Unit)? = null, + onWatchTogetherClick: (() -> Unit)? = null, +) { + var confirmSignOut by rememberSaveable { mutableStateOf(false) } + + // Whether anything sits above the account actions. + // + // The menu carries exactly one hairline, and this is what decides whether + // it is drawn at all. A settings card rules every row but its first and + // separates *groups* by being a different card; a single popup cannot be + // two cards, so ruling every row here would spend the same line on both + // jobs and the feature/account split would stop reading as a split. The + // old menu drew its divider unconditionally, so a server with requests + // disabled opened onto a stray rule above its first item. + val hasFeatureGroup = onRequestsClick != null || onWatchTogetherClick != null + + PrairieDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + if (onRequestsClick != null) { + PrairieMenuItem( + label = "Requests", + onClick = { + onDismissRequest() + onRequestsClick() + }, + ) + } + if (onWatchTogetherClick != null) { + PrairieMenuItem( + label = "Watch together", + onClick = { + onDismissRequest() + onWatchTogetherClick() + }, + ) + } + PrairieMenuItem( + label = "Settings", + showDivider = hasFeatureGroup, + onClick = { + onDismissRequest() + onSettingsClick() + }, + ) + PrairieMenuItem( + label = "Switch profile", + onClick = { + onDismissRequest() + onSwitchProfileClick() + }, + ) + PrairieMenuItem( + label = "Switch server", + onClick = { + onDismissRequest() + onSwitchServerClick() + }, + ) + PrairieMenuItem( + label = "Sign out", + labelColor = PrairieDestructive, + onClick = { + onDismissRequest() + confirmSignOut = true + }, + ) + } + + SignOutConfirmDialog( + visible = confirmSignOut, + onConfirm = { + confirmSignOut = false + onSignOutClick() + }, + onDismiss = { confirmSignOut = false }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt new file mode 100644 index 000000000..45866d92d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SortFilterControls.kt @@ -0,0 +1,174 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** One entry in the sort dropdown. [selectedLabel] (e.g. "Title · A–Z") shows while active. */ +data class SortMenuOption( + val id: String, + val label: String, + val selectedLabel: String = label, + /** Re-picking an active option with a direction keeps the menu open to show the flip. */ + val flipsOnReselect: Boolean = false, + /** A divider is drawn above this entry (separates the default from the rest). */ + val dividerAbove: Boolean = false, +) + +/** + * The shared "Sort ▾ · Filter (n) · × Reset" control row used by every grid + * that sorts and filters (Browse, Watchlist, Favorites). Sits in the grid's + * spanning header so it scrolls with the content and stays reachable when + * the list is empty. [trailing] renders at the end (e.g. an item count). + */ +@Composable +fun SortFilterControlsRow( + sortLabel: String, + sortActive: Boolean, + sortOptions: List, + selectedSortId: String, + onSelectSort: (String) -> Unit, + filterCount: Int, + onOpenFilters: () -> Unit, + showReset: Boolean, + onReset: () -> Unit, + modifier: Modifier = Modifier, + trailing: @Composable RowScope.() -> Unit = {}, +) { + var sortMenuOpen by remember { mutableStateOf(false) } + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + ControlPill( + icon = Icons.AutoMirrored.Filled.Sort, + label = sortLabel, + active = sortActive, + trailingChevron = true, + onClick = { sortMenuOpen = true }, + ) + DropdownMenu( + expanded = sortMenuOpen, + onDismissRequest = { sortMenuOpen = false }, + ) { + sortOptions.forEach { option -> + val selected = option.id == selectedSortId + if (option.dividerAbove) HorizontalDivider() + DropdownMenuItem( + text = { + Text( + text = if (selected) option.selectedLabel else option.label, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + }, + trailingIcon = if (selected) { + { Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(18.dp)) } + } else { + null + }, + onClick = { + onSelectSort(option.id) + if (!(selected && option.flipsOnReselect)) sortMenuOpen = false + }, + ) + } + } + } + + ControlPill( + icon = Icons.Filled.FilterList, + label = if (filterCount > 0) "Filter · $filterCount" else "Filter", + active = filterCount > 0, + onClick = onOpenFilters, + ) + + // Reset appears only once something is customised — one tap back to + // the defaults. + if (showReset) { + TextButton( + onClick = onReset, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.height(34.dp), + ) { + Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Reset", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.weight(1f)) + trailing() + } +} + +/** Same capsule as the For You saved-list pills, brightened when active. */ +@Composable +private fun ControlPill( + icon: ImageVector, + label: String, + active: Boolean, + onClick: () -> Unit, + trailingChevron: Boolean = false, +) { + OutlinedButton( + onClick = onClick, + shape = CircleShape, + contentPadding = PaddingValues(horizontal = 12.dp), + border = BorderStroke(1.5.dp, Color.White.copy(alpha = if (active) 0.9f else 0.3f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.height(34.dp), + ) { + Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) + if (trailingChevron) { + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SwipeBack.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SwipeBack.kt new file mode 100644 index 000000000..de46cacfc --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/SwipeBack.kt @@ -0,0 +1,107 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +private const val DismissFraction = 0.35f +private val FlingMinTravel = 24.dp +private const val FlingVelocityPxPerSec = 1800f +private const val MinScale = 0.96f +private val CornerRadius = 24.dp + +/** + * iOS-style interactive "swipe back": a rightward drag on the page moves it + * with the finger (slight shrink, corners rounding as it lifts), and + * releasing past [DismissFraction] of the width — or flicking fast — calls + * [onDismiss]; anything short springs back. + * + * Attach to the page root. It is a horizontal draggable, so it only receives + * drags that no child consumed: vertical lists scroll as usual, and + * horizontal rails / pagers keep their own swipes. On gesture-nav devices the + * far-left edge still belongs to the system back gesture; this covers the + * rest of the page. + */ +@Composable +fun Modifier.swipeBackToDismiss( + onDismiss: () -> Unit, + enabled: Boolean = true, +): Modifier { + val density = LocalDensity.current + val flingMinTravelPx = with(density) { FlingMinTravel.toPx() } + val cornerPx = with(density) { CornerRadius.toPx() } + val scope = rememberCoroutineScope() + val offset = remember { Animatable(0f) } + var widthPx by remember { mutableIntStateOf(0) } + var dismissing by remember { mutableStateOf(false) } + val currentOnDismiss by rememberUpdatedState(onDismiss) + + val dragState = rememberDraggableState { delta -> + if (dismissing) return@rememberDraggableState + // Only ever move right; a leftward drag past home is ignored. + val next = (offset.value + delta).coerceAtLeast(0f) + scope.launch { offset.snapTo(next) } + } + + return this + .onSizeChanged { widthPx = it.width } + .draggable( + state = dragState, + orientation = Orientation.Horizontal, + enabled = enabled, + onDragStopped = { velocity -> + if (dismissing || offset.value <= 0f) return@draggable + val threshold = widthPx * DismissFraction + val flick = velocity > FlingVelocityPxPerSec && offset.value > flingMinTravelPx + if (offset.value >= threshold || flick) { + dismissing = true + // Finish in the composable's scope, not this suspend + // callback: a new touch during the slide-off cancels + // onDragStopped, which used to strand the page mid-way + // with the dismiss never delivered. The pop is called + // even if the animation is interrupted. + scope.launch { + try { + offset.animateTo(widthPx.toFloat(), tween(durationMillis = 180)) + } finally { + currentOnDismiss() + } + } + } else { + offset.animateTo(0f, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium)) + } + }, + ) + .graphicsLayer { + // Reads happen in the draw phase, so dragging redraws the layer + // without recomposing the page. + val progress = if (widthPx > 0) (offset.value / widthPx).coerceIn(0f, 1f) else 0f + translationX = offset.value + val scale = 1f - (1f - MinScale) * progress + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0f, 0.5f) + clip = progress > 0f + shape = RoundedCornerShape(cornerPx * (progress * 4f).coerceAtMost(1f)) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt new file mode 100644 index 000000000..fdbe15818 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/TopBarActions.kt @@ -0,0 +1,196 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.model.profile.Profile + +/** + * Shared vocabulary for the phone tab headers, mirroring iOS + * `TabTopBarActions` / `TopBarIconButton` / `ProfileAvatarMenu`: bare 40dp + * circular hit targets with no chip fill or border, a 36dp avatar, and a + * tight trailing cluster. Home, Libraries and the shared [MainAppTopBar] all + * draw from here so the three headers read as one bar. + */ + +/** iOS `topBarIconSpacing`. */ +val TopBarActionSpacing = 4.dp + +/** iOS `TopBarIconButton`: a plain 40pt hit target, optionally a filled disc when active. */ +@Composable +fun TopBarIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isActive: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + onClick = onClick, + modifier = modifier, + color = if (isActive) MaterialTheme.colorScheme.onSurface else Color.Transparent, + contentColor = if (isActive) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurface, + shape = CircleShape, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center, + content = content, + ) + } +} + +/** The 36dp avatar disc that anchors the profile menu (iOS `ProfileAvatarView` size 36). */ +@Composable +fun TopBarProfileMenu( + activeProfile: Profile?, + onRequestsClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, + onSettingsClick: () -> Unit, + onSwitchProfileClick: () -> Unit, + onSwitchServerClick: () -> Unit, + onSignOutClick: () -> Unit, +) { + var menuExpanded by rememberSaveable { mutableStateOf(false) } + Box { + TopBarIconButton(onClick = { menuExpanded = true }) { + if (activeProfile != null) { + ProfileAvatar( + avatar = activeProfile.avatarRef(), + name = activeProfile.name, + size = 36.dp, + ) + } else { + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Person, + contentDescription = "Account and menu", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + ProfileMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) + } +} + +/** + * iOS `TabTopBarActions`: search then the profile avatar menu. [leadingActions] + * lets a tab prepend its own button (Home's remote-control button). + */ +@Composable +fun TabTopBarActions( + activeProfile: Profile?, + onSearchClick: () -> Unit, + onRequestsClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, + onSettingsClick: () -> Unit, + onSwitchProfileClick: () -> Unit, + onSwitchServerClick: () -> Unit, + onSignOutClick: () -> Unit, + modifier: Modifier = Modifier, + leadingActions: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(TopBarActionSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingActions() + TopBarIconButton(onClick = onSearchClick) { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "Search", + ) + } + TopBarProfileMenu( + activeProfile = activeProfile, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) + } +} + +// iOS `siloGlass(tint: black 0.08)`: a light blur with a faint dark wash. +private val TopBarGlassTint = Color.Black.copy(alpha = 0.08f) +private val TopBarGlassBlurRadius = 20.dp +// Below API 31 Haze cannot blur; a heavier flat wash keeps the header +// legible over scrolled content. +private val TopBarGlassFallback = Color(0xFF0A0A0A).copy(alpha = 0.86f) + +/** + * The header glass shared by every tab bar: content registered on [state] + * via `hazeSource` is blurred and lightly tinted beneath this node. With + * [progressive] the glass feathers out along its bottom edge. Over an + * empty top runway the blur is invisible, so pinned bars only "turn on" once + * content slides beneath — iOS's scroll-edge effect for free. To fade the + * glass with scroll (Home), put it on a background-only box behind a + * `graphicsLayer { alpha }` rather than in the Haze block: Haze does not + * re-run its style block on snapshot reads. + */ +fun Modifier.topBarGlass(state: HazeState, progressive: Boolean = false): Modifier = + hazeEffect(state = state) { + blurRadius = TopBarGlassBlurRadius + noiseFactor = 0f + tints = listOf(HazeTint(TopBarGlassTint)) + fallbackTint = HazeTint(TopBarGlassFallback) + if (progressive) { + // Progressive glass: solid for the top ~80% of the bar, then + // feathering to clear so content dissolves into the header + // instead of meeting a hard edge (iOS scroll-edge effect for a + // taller chrome that carries a pinned selector row). + mask = Brush.verticalGradient( + 0f to Color.Black, + ProgressiveGlassSolidFraction to Color.Black, + 1f to Color.Transparent, + ) + } + } + +private const val ProgressiveGlassSolidFraction = 0.78f diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt index 9aabdfe63..5c09cb487 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraChrome.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import org.prairieserver.prairie.android.ui.components.siloKeyboardActions import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -131,10 +132,20 @@ fun AuroraErrorLabel(text: String, modifier: Modifier = Modifier) { * sheen + soft drop shadow; optional gold halo). Compose has no backdrop blur, * so the tint is kept translucent enough for the aurora to glow through. */ -fun Modifier.auroraGlass(cornerRadius: Dp = 28.dp, emphasized: Boolean = false): Modifier { +fun Modifier.auroraGlass( + cornerRadius: Dp = 28.dp, + emphasized: Boolean = false, + /** + * The drop shadow reads as depth under a small panel floating on a static + * screen. Pass 0.dp for a large or moving panel: the fill is translucent, + * so at full size the shadow's own outline shows *through* the glass as a + * faint hard-edged box rather than sitting behind it. + */ + elevation: Dp = 60.dp, +): Modifier { val shape = RoundedCornerShape(cornerRadius) return this - .shadow(elevation = 60.dp, shape = shape, clip = false) + .then(if (elevation > 0.dp) Modifier.shadow(elevation, shape, clip = false) else Modifier) .clip(shape) .background(AuroraGlassTint.copy(alpha = 0.62f)) .background( @@ -241,7 +252,7 @@ fun AuroraTextField( placeholder: String = "", keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, - onImeAction: () -> Unit = {}, + onImeAction: (() -> Unit)? = null, visualTransformation: VisualTransformation = VisualTransformation.None, trailing: (@Composable () -> Unit)? = null, ) { @@ -296,7 +307,7 @@ fun AuroraTextField( cursorBrush = SolidColor(if (isFocused) AuroraActiveInk else AuroraAccent), visualTransformation = visualTransformation, keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction), - keyboardActions = KeyboardActions(onAny = { onImeAction() }), + keyboardActions = siloKeyboardActions(onImeAction), interactionSource = interactionSource, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt index 5027701f9..2798c3e65 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/aurora/AuroraScreen.kt @@ -1,31 +1,54 @@ package org.prairieserver.prairie.android.ui.components.aurora +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * Aurora backdrop + a vertically scrollable, keyboard-friendly column capped to - * a comfortable reading width and centered over the plum backdrop. Callers - * supply the wordmark + content. Port of prairie-apple's `AuroraScreen`. + * Aurora backdrop + a keyboard-friendly column capped to a comfortable reading + * width and centered over the plum backdrop. Callers supply the wordmark + + * content. Port of prairie-apple's `AuroraScreen`. */ @Composable fun AuroraScreen( variant: AuroraVariant, modifier: Modifier = Modifier, scrim: AuroraScrim = AuroraScrim.Soft, - maxContentWidth: androidx.compose.ui.unit.Dp = 480.dp, + maxContentWidth: Dp = 480.dp, + /** + * Scrolls the content as one block and sizes it to its children — right for + * the form screens, which are shorter than the display until the keyboard + * opens. + * + * Screens that lay themselves out against the full height (a `weight`ed + * body between fixed chrome) must pass `false`. A scrolling parent measures + * its children with unbounded height, which leaves nothing for `weight` to + * divide, so weighted children collapse to zero and vanish. + */ + scrollable: Boolean = true, + /** + * Gutter between the content and the display edge. Screens that run a + * full-bleed element (a pager whose neighbouring pages should peek past the + * gutter rather than be clipped at it) set this to zero and pad their own + * rows instead. + */ + horizontalPadding: Dp = 24.dp, content: @Composable ColumnScope.() -> Unit, ) { Box(modifier = modifier.fillMaxSize()) { @@ -33,16 +56,21 @@ fun AuroraScreen( Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding(), + .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier) + // safeDrawing covers the status/navigation bars, the cutout and + // the IME — the activity draws edge to edge, so without this the + // first and last rows of a full-height screen sit under system + // chrome. + .windowInsetsPadding(WindowInsets.safeDrawing), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + verticalArrangement = Arrangement.Center, ) { Column( modifier = Modifier .fillMaxWidth() .widthIn(max = maxContentWidth) - .padding(horizontal = 24.dp, vertical = 32.dp), + .then(if (scrollable) Modifier else Modifier.fillMaxHeight()) + .padding(horizontal = horizontalPadding, vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, content = content, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index 0eb444197..71bf98300 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingWrongServerScreen +import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingUnknownServerScreen import androidx.compose.runtime.collectAsState import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,6 +34,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navDeepLink import androidx.navigation.navArgument +import kotlinx.coroutines.flow.map import org.prairieserver.prairie.android.cast.GoogleCastMiniBar import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.android.cast.PrairieCastSessionManager @@ -40,7 +45,9 @@ import org.prairieserver.prairie.android.ui.screens.auth.LoginScreen import org.prairieserver.prairie.android.ui.screens.auth.DevicePairingScreen import org.prairieserver.prairie.android.ui.screens.auth.ServerSetupScreen import org.prairieserver.prairie.android.ui.screens.auth.SetupScreen +import org.prairieserver.prairie.android.ui.screens.auth.InviteClaimScreen import org.prairieserver.prairie.android.ui.screens.auth.SignupScreen +import org.prairieserver.prairie.android.ui.screens.onboarding.OnboardingTourScreen import org.prairieserver.prairie.android.ui.screens.browse.BrowseScreen import org.prairieserver.prairie.android.ui.screens.browse.BrowseViewModel import org.prairieserver.prairie.android.ui.screens.calendar.CalendarScreen @@ -58,21 +65,20 @@ import org.prairieserver.prairie.android.ui.screens.personal.FavoritesScreen import org.prairieserver.prairie.android.ui.screens.personal.HistoryScreen import org.prairieserver.prairie.android.ui.screens.personal.PersonalListsScreen import org.prairieserver.prairie.android.ui.screens.personal.WatchlistScreen +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget import org.prairieserver.prairie.android.ui.screens.player.PlayerScreen +import org.prairieserver.prairie.android.ui.screens.player.PlayerViewModel import org.prairieserver.prairie.android.ui.screens.profiles.CreateProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.EditProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionScreen import org.prairieserver.prairie.android.ui.screens.requests.MyRequestsScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestDetailScreen -import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvPlayerScreen -import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestsScreen import org.prairieserver.prairie.android.ui.screens.search.MobileSearchMediaType import org.prairieserver.prairie.android.ui.screens.search.SearchScreen import org.prairieserver.prairie.android.ui.screens.search.SearchViewModel import org.prairieserver.prairie.android.ui.screens.servers.ServerListScreen import org.prairieserver.prairie.android.ui.screens.servers.ServerSwitchDestination -import org.prairieserver.prairie.android.ui.screens.settings.CardOverlaySettingsScreen import org.prairieserver.prairie.android.ui.screens.settings.SettingsScreen import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsPromptDialog import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger @@ -90,23 +96,48 @@ import org.koin.compose.viewmodel.koinViewModel /** Page-to-page cross-fade duration (ms). Snappier than Compose Nav's 700ms default. */ private const val PageFadeDurationMs = 200 +internal class PlayerTargetProviderRegistration( + val backStackEntryId: String, + val target: () -> MobilePlayerRouteTarget?, +) + +internal fun currentPlayerTargetOrNull( + currentBackStackEntryId: String?, + registration: PlayerTargetProviderRegistration?, +): MobilePlayerRouteTarget? { + if (currentBackStackEntryId == null || registration?.backStackEntryId != currentBackStackEntryId) { + return null + } + return registration.target() +} + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun AppNavigation( navController: NavHostController = rememberNavController(), startDestination: String = Route.Login.route, - pendingExternalRoute: String? = null, - onExternalRouteConsumed: () -> Unit = {}, + pendingExternalRoute: ExternalRouteRequest? = null, + onExternalRouteConsumed: (ExternalRouteRequest) -> Unit = {}, + /** + * Re-queues [route] as a pending external request. Used when an action + * inside a destination is about to send the user through authentication, + * which clears the back stack and would otherwise lose that destination. + */ + onRequeueExternalRoute: (String) -> Unit = {}, ) { val tokenManager: TokenManager = koinInject() + val serverRegistry: org.prairieserver.prairie.network.ServerRegistry = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() - val prairieCastController: PrairieCastController = koinInject() + val siloCastController: PrairieCastController = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() + var activePlayerTargetProvider by remember { + mutableStateOf(null) + } - DisposableEffect(prairieCastController) { - prairieCastController.startBrowsing() - onDispose { prairieCastController.stopBrowsing() } + DisposableEffect(siloCastController) { + siloCastController.startBrowsing() + onDispose { siloCastController.stopBrowsing() } } // Graceful handling of server-side session invalidation (refresh 401'd). @@ -122,37 +153,110 @@ fun AppNavigation( } } - // Keyed on the route so a notification arriving later restarts the - // collection; currentBackStackEntryFlow emits the current entry - // immediately on collect, so both "route arrives while on Main" and - // "Main arrives with route queued" are covered. - LaunchedEffect(pendingExternalRoute) { - // Consume only while the main (authenticated) graph is showing — - // a notification tapped pre-sign-in stays queued until auth lands, - // instead of pushing its target over Login. The back-stack flow makes - // this re-fire when Main arrives with the route still pending. - navController.currentBackStackEntryFlow.collect { entry -> - val route = pendingExternalRoute?.takeIf { it.isNotBlank() } ?: return@collect - // Every pre-auth / onboarding destination — a notification tapped - // on any of these stays queued until the authenticated graph - // shows, instead of pushing a content route that would 401. - val authRoutes = setOf( - Route.Login.route, - Route.ServerSetup.route, - Route.ServerList.ROUTE, - Route.Setup.route, - Route.Signup.route, - Route.ProfileSelection.route, - Route.CreateProfile.route, - Route.EditProfile.ROUTE, - Route.PairDevice.ROUTE, - ) - if (entry.destination.route in authRoutes) return@collect - navController.navigate(route) { - launchSingleTop = true - } - onExternalRouteConsumed() - } + // Keyed on request identity, not route text, so delivering the same deep + // link again after Back still restarts the wait. The back-stack flow emits + // the current entry immediately, covering both "request arrives on Main" + // and "Main arrives with a request queued". Delivery itself is one-shot. + LaunchedEffect(pendingExternalRoute?.generation) { + consumeExternalRouteOnce( + pendingExternalRoute = pendingExternalRoute, + currentDestinationRoutes = navController.currentBackStackEntryFlow + .map { entry -> entry.destination.route }, + isAlreadyAtRoute = { route -> + navController.isDisplayingExactPlayerRoute( + route = route, + currentPlayerTarget = currentPlayerTargetOrNull( + currentBackStackEntryId = navController.currentBackStackEntry?.id, + registration = activePlayerTargetProvider, + ), + ) + }, + navigate = { route -> + // An external link to a TAB (prairie://downloads) must switch tabs, + // not push a second copy of that tab. A duplicate tab entry also + // makes the tab anchor ambiguous: popUpTo(route) resolves to the + // NEWEST match, so the older anchor entry would survive and Back + // could loop through a hidden tab. + if (tabForRoute(route) != null) { + // Tear the player down BEFORE the tab switch, and without + // saving it. tabSwitchNavOptions saves state so a tab keeps + // its stack, which is right for a tab — but a saved player + // entry keeps its ViewModelStore alive, so onCleared never + // runs and the playback session it owns is never stopped. + // The save is also keyed to the LOWEST popped destination, + // so a later clearBackStack on the player route would not + // even find it. Popping first means the player's teardown + // runs the ordinary way. + // ONLY when the player is the current destination. An + // inclusive pop also removes everything above its target, + // so a player sitting BELOW other entries — an external + // item link pushed over it, say — would take those with it + // and silently discard state the viewer expected back. + // Leaving that rarer case saved is the pre-existing + // behaviour; destroying history to fix it is worse. + if (shouldPopPlayerBeforeExternalTab( + navController.currentBackStackEntry?.destination?.route, + ) + ) { + navController.popBackStack( + route = Route.Player.ROUTE, + inclusive = true, + saveState = false, + ) + } + navController.navigate(route) { + tabSwitchNavOptions(navController.bottomMostTabRoute()) + } + } else { + val replaceCurrentPlayer = shouldReplaceCurrentPlayer( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + targetRoute = route, + ) + // Single-top only when the arguments agree it really is the + // same screen. AndroidX matches the destination NODE, so an + // external link to item B while item A's detail is showing + // reused A's entry and Back skipped A entirely — the same + // defect this branch fixes for in-app navigation. + val useSingleTop = shouldLaunchExternalRouteSingleTop( + currentDestinationRoute = navController.currentBackStackEntry + ?.destination?.route, + currentContentId = navController.currentBackStackEntry + ?.arguments + ?.getString("contentId"), + targetRoute = route, + ) + navController.navigate(route) { + if (replaceCurrentPlayer) { + popUpTo(Route.Player.ROUTE) { inclusive = true } + } + // Decided from the finite external-route producer set, + // not punctuation. A route's spelling does not say + // whether its arguments identify a distinct request. + launchSingleTop = useSingleTop + } + } + }, + isStillValidForScope = { scope -> + // Checked AFTER the wait: the identity can move while a request + // sits through setup, login and profile selection. + when (scope) { + ExternalRouteScope.Unscoped -> true + is ExternalRouteScope.Identity -> { + // One snapshot, for the same reason the capture side + // takes one: separate getters can tear across a switch + // and validate against an identity that never existed. + val live = tokenManager.snapshotCurrentScope() + scope.matches( + serverId = live?.serverId, + profileId = live?.profileId, + identityGeneration = live?.identityGeneration, + ) + } + } + }, + onConsumed = onExternalRouteConsumed, + ) } // Re-read the authenticated profile id whenever the current destination @@ -226,9 +330,8 @@ fun AppNavigation( } }, onChangeServer = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = true)) { + navController.navigate(Route.ServerSetup.route) { popUpTo(Route.Login.route) { inclusive = true } - launchSingleTop = true } }, ) @@ -254,6 +357,44 @@ fun AppNavigation( }, ) } + composable( + route = Route.InviteClaim.ROUTE, + arguments = listOf( + navArgument("server") { type = NavType.StringType }, + navArgument("token") { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "prairie://invite?server={server}&token={token}" }, + ), + ) { backStackEntry -> + val server = backStackEntry.arguments?.getString("server").orEmpty() + val claimToken = backStackEntry.arguments?.getString("token").orEmpty() + InviteClaimScreen( + serverUrl = server, + token = claimToken, + onNavigateToLogin = { + navController.navigate(Route.Login.route) { + popUpTo(0) { inclusive = true } + } + }, + onClaimComplete = { + navController.navigate(Route.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + } + }, + ) + } + + composable(Route.OnboardingTour.route) { + OnboardingTourScreen( + onDone = { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } + }, + ) + } + composable( route = Route.PairDevice.ROUTE, arguments = listOf( @@ -267,46 +408,135 @@ fun AppNavigation( nullable = true defaultValue = null }, + navArgument("serverOrigin") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), - deepLinks = listOf( - navDeepLink { uriPattern = "prairie://device?token={token}" }, - navDeepLink { uriPattern = "prairie://device?code={code}" }, - ), + // Deliberately NO navDeepLink registrations. While they existed, + // Navigation matched the Activity's launch Intent itself when the + // graph was installed and landed Pair Device before any + // server/token/profile gate had run — the exact bypass + // MainActivity's pending-route queue exists to prevent. The + // manifest filter still delivers the Intent; MainActivity parses + // and queues it. ) { backStackEntry -> val token = backStackEntry.arguments?.getString("token") val code = backStackEntry.arguments?.getString("code") - DevicePairingScreen( - token = token, - code = code, - onDone = { - if (!navController.popBackStack()) { - navController.navigate(Route.Home.route) { - popUpTo(0) { inclusive = true } + val requiredOrigin = backStackEntry.arguments?.getString("serverOrigin") + val knownServers by serverRegistry.entries.collectAsState() + val activeServer by serverRegistry.activeEntry.collectAsState() + val match = remember(requiredOrigin, activeServer, knownServers) { + deviceLoginServerMatch( + requiredOrigin = requiredOrigin, + activeServerUrl = activeServer?.url, + entries = knownServers, + ) + } + val pairingScope = rememberCoroutineScope() + when (val resolved = match) { + is DeviceLoginServerMatch.SwitchRequired -> + DevicePairingWrongServerScreen( + serverName = resolved.entry.displayName, + onSwitch = { + pairingScope.launch { + serverRegistry.switchTo(resolved.entry.id) + // Re-queue ONLY if the target server will send + // the user through auth: that flow ends at + // profile selection, whose popUpTo(0) wipes this + // destination and the code would have to be + // scanned again. Re-queueing unconditionally was + // worse — with no sign-in needed the request just + // waited for this screen to close and then + // reopened it. + val authRoute = pairingAuthRouteOrNull( + tokenManager = tokenManager, + activeEntryProfileId = serverRegistry.activeEntry.value + ?.profileId, + ) + if (authRoute != null) { + onRequeueExternalRoute( + Route.PairDevice( + token = token, + code = code, + serverOrigin = requiredOrigin, + ).route, + ) + // Requeueing alone left the user sitting on + // a pairing screen for a server they are not + // signed in to; the queued request only + // fires once something else takes them + // somewhere authenticated. Send them. + navController.navigate(authRoute) { + popUpTo(0) { inclusive = true } + } + } + } + }, + onCancel = { + if (!navController.popBackStack()) { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } + } + }, + ) + is DeviceLoginServerMatch.UnknownServer -> + DevicePairingUnknownServerScreen( + origin = resolved.origin, + onAddServer = { + // Adding a server always runs setup and login, which + // clear this destination — so this one always + // re-queues. + onRequeueExternalRoute( + Route.PairDevice( + token = token, + code = code, + serverOrigin = requiredOrigin, + ).route, + ) + navController.navigate(Route.ServerSetup.route) + }, + onCancel = { + if (!navController.popBackStack()) { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } + } + }, + ) + DeviceLoginServerMatch.Active -> DevicePairingScreen( + token = token, + code = code, + onDone = { + if (!navController.popBackStack()) { + navController.navigate(Route.Home.route) { + popUpTo(0) { inclusive = true } + } } - } - }, - onSignIn = { - navController.navigate(Route.Login.route) - }, - ) + }, + onSignIn = { + // Same preservation as the switch path: signing in ends + // at profile selection, whose popUpTo(0) wipes this + // destination, and the code would have to be scanned + // again. + onRequeueExternalRoute( + Route.PairDevice( + token = token, + code = code, + serverOrigin = requiredOrigin, + ).route, + ) + navController.navigate(Route.Login.route) + }, + ) + } } - // ---- Server list (first-run connect + multi-server management) ---- - composable( - route = Route.ServerList.ROUTE, - arguments = listOf( - navArgument(Route.ServerList.ARG_AUTO_SCAN) { - type = NavType.BoolType - defaultValue = false - }, - ), - ) { backStackEntry -> - val autoScan = backStackEntry.arguments - ?.getBoolean(Route.ServerList.ARG_AUTO_SCAN) - ?: false - val canGoBack = navController.previousBackStackEntry != null + // ---- Server list (multi-server management) ---- + composable(Route.ServerList.route) { ServerListScreen( - autoScan = autoScan, onAddServer = { navController.navigate(Route.ServerSetup.route) }, @@ -314,23 +544,21 @@ fun AppNavigation( // Route to whichever screen the new server's stored // credentials can support — Home if a token+profile // already exist (so the user stays signed in), else - // ProfileSelection, Login, or Setup as appropriate. + // ProfileSelection or Login as appropriate. val target = when (destination) { - ServerSwitchDestination.Home -> Route.Home.route + // Through the tour gate, not straight to Home — the + // switched-to server's profile may not have seen the + // tour; the gate short-circuits when it has. + ServerSwitchDestination.Home -> Route.OnboardingTour.route ServerSwitchDestination.ProfileSelection -> Route.ProfileSelection.route ServerSwitchDestination.Login -> Route.Login.route - ServerSwitchDestination.Setup -> Route.Setup.route } navController.navigate(target) { popUpTo(0) { inclusive = true } launchSingleTop = true } }, - onBack = if (canGoBack) { - { navController.popBackStack() } - } else { - null - }, + onBack = { navController.popBackStack() }, ) } @@ -338,7 +566,10 @@ fun AppNavigation( composable(Route.ProfileSelection.route) { ProfileSelectionScreen( onNavigateToHome = { - navController.navigate(Route.Home.route) { + // Route through the tour gate: OnboardingTourScreen checks + // server-side state and immediately hands off to Home when + // the profile has already completed or skipped the tour. + navController.navigate(Route.OnboardingTour.route) { popUpTo(0) { inclusive = true } } }, @@ -411,29 +642,53 @@ fun AppNavigation( LaunchedEffect(Unit) { navController.navigate(Route.Home.route) { popUpTo(legacyRoute) { inclusive = true } + // A restored stack can already hold Home IMMEDIATELY + // below the legacy entry; without this the redirect adds + // a second one, and a duplicate tab route makes the tab + // anchor ambiguous (popUpTo resolves to the newest + // match). Home further down is not collapsed — this + // checks the new top after the alias is popped. + launchSingleTop = true } } } } + // Same reasoning for the withdrawn admin dashboard, except that Settings + // is where its entry point used to live, so that is where it lands. + // Registered, never rendered — the admin surface stays deleted. + composable("admin") { + LaunchedEffect(Unit) { + navController.navigate(Route.Settings.route) { + popUpTo("admin") { inclusive = true } + launchSingleTop = true + } + } + } + // The Card overlays editor was removed (overlays are edited on the web + // app); a saved back stack from an older build can still hold its + // route, so keep a hidden redirect to Settings rather than crash on + // restore. Registered, never rendered. + composable("settings/card_overlays") { + LaunchedEffect(Unit) { + navController.navigate(Route.Settings.route) { + popUpTo("settings/card_overlays") { inclusive = true } + launchSingleTop = true + } + } + } composable(Route.Settings.route) { SettingsScreen( onNavigateToServers = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onPairDevice = { navController.navigate(Route.PairDevice().route) }, - onNavigateToAdmin = { - navController.navigate(Route.Admin.route) - }, onSwitchProfile = { navController.navigate(Route.ProfileSelection.route) }, onNavigateToWatchlist = { navController.navigate(Route.Watchlist.route) }, onNavigateToFavorites = { navController.navigate(Route.Favorites.route) }, onNavigateToHistory = { navController.navigate(Route.History.route) }, onNavigateToCollections = { navController.navigate(Route.Collections().route) }, - onNavigateToCardOverlays = { - navController.navigate(Route.CardOverlays.route) - }, onNavigateToDiagnostics = { navController.navigate(Route.Diagnostics.route) }, @@ -446,12 +701,6 @@ fun AppNavigation( onBackClick = { navController.popBackStack() }, ) } - composable(Route.CardOverlays.route) { - CardOverlaySettingsScreen( - store = overlayPrefsStore, - onBackClick = { navController.popBackStack() }, - ) - } composable(Route.Diagnostics.route) { DiagnosticsSettingsScreen( onBackClick = { navController.popBackStack() }, @@ -524,40 +773,6 @@ fun AppNavigation( }, ) } - - // ---- Live TV ---- - composable(Route.LiveTv.route) { - LiveTvScreen( - onBackClick = { navController.popBackStack() }, - onChannelClick = { channel -> - navController.navigate( - Route.LiveTvPlayer(channel.id, channel.displayName).route, - ) - }, - onPlayLibraryItem = { contentId -> - navController.navigate(Route.ItemDetail(contentId).route) - }, - ) - } - composable( - route = Route.LiveTvPlayer.ROUTE, - arguments = listOf( - navArgument(Route.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, - navArgument(Route.LiveTvPlayer.ARG_NAME) { - type = NavType.StringType - nullable = true - defaultValue = "" - }, - ), - ) { backStackEntry -> - val channelId = backStackEntry.arguments?.getString(Route.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty() - val channelName = backStackEntry.arguments?.getString(Route.LiveTvPlayer.ARG_NAME).orEmpty() - LiveTvPlayerScreen( - channelId = channelId, - channelName = channelName, - onBackClick = { navController.popBackStack() }, - ) - } composable( route = Route.RequestDetail.ROUTE, arguments = listOf( @@ -641,7 +856,7 @@ fun AppNavigation( ItemDetailScreen( onBackClick = { navController.popBackStack() }, onPlayClick = { contentId, fileId, audioTrackIndex, subtitleTrackIndex, resumePositionSeconds -> - val launchedRemotely = prairieCastController.launchOnConnectedTarget( + val launchedRemotely = siloCastController.launchOnConnectedTarget( PrairieCastPlaybackRequest( contentId = contentId, fileId = fileId, @@ -821,6 +1036,19 @@ fun AppNavigation( }, ), ) { backStackEntry -> + val playerViewModel = koinViewModel() + DisposableEffect(backStackEntry.id, playerViewModel) { + val registration = PlayerTargetProviderRegistration( + backStackEntryId = backStackEntry.id, + target = playerViewModel::currentExternalRouteTarget, + ) + activePlayerTargetProvider = registration + onDispose { + if (activePlayerTargetProvider === registration) { + activePlayerTargetProvider = null + } + } + } PlayerScreen( contentId = backStackEntry.arguments?.getString("contentId") ?: "", initialFileId = backStackEntry.arguments?.getString("fileId")?.toIntOrNull(), @@ -834,6 +1062,7 @@ fun AppNavigation( ), roomId = backStackEntry.arguments?.getString("roomId"), navController = navController, + viewModel = playerViewModel, ) } @@ -846,11 +1075,6 @@ fun AppNavigation( }, ) } - composable(Route.Admin.route) { - org.prairieserver.prairie.android.ui.screens.admin.AdminStatsScreen( - onBackClick = { navController.popBackStack() }, - ) - } composable(Route.Watchlist.route) { WatchlistScreen( onBackClick = { navController.popBackStack() }, @@ -919,6 +1143,7 @@ fun AppNavigation( onSend = { diagnosticsViewModel.uploadPrompt(prompt) }, onAlwaysSend = { diagnosticsViewModel.alwaysSendPrompt(prompt) }, onDontSend = { diagnosticsViewModel.declinePrompt(prompt) }, + allowAlwaysSend = diagnosticsState.allowsAutomaticUpload, ) } // Menu-less routes (detail screens etc.) get the cast bar as a bottom @@ -937,7 +1162,7 @@ fun AppNavigation( ) if (currentRoute !in castBarInlineRoutes) { PrairieCastMiniBar( - controller = prairieCastController, + controller = siloCastController, onOpenRemote = { navController.navigate(Route.PrairieCastRemote.route) }, modifier = Modifier .align(Alignment.BottomCenter) @@ -976,3 +1201,40 @@ fun AppNavigation( } } } + +/** + * Exact player redelivery is idempotent. Navigating the same concrete route + * with launchSingleTop replaces the top entry and tears down active playback; + * a different content/file/quality/track route must still navigate normally. + */ +private fun NavHostController.isDisplayingExactPlayerRoute( + route: String, + currentPlayerTarget: MobilePlayerRouteTarget?, +): Boolean { + val entry = currentBackStackEntry ?: return false + if (entry.destination.route != Route.Player.ROUTE) return false + val arguments = entry.arguments ?: return false + // A normal prairie://play link is a solo-playback request. Never swallow it + // merely because a Watch Together room currently happens to play the same + // content/file. + if (!arguments.getString("roomId").isNullOrBlank()) return false + val requestedTarget = playerRouteIntentOrNull(route) ?: return false + return currentPlayerTarget?.let(requestedTarget::matches) == true +} + +/** + * The route the newly active server must pass through before pairing is + * possible, or null when it can pair immediately. + * + * Same credential check `ServerListViewModel` uses to pick a switch + * destination, including its preference for the registry entry's profile id + * over the token manager's cached one. + */ +private suspend fun pairingAuthRouteOrNull( + tokenManager: TokenManager, + activeEntryProfileId: String?, +): String? { + if (tokenManager.getAccessToken().isNullOrBlank()) return Route.Login.route + val profileId = activeEntryProfileId ?: tokenManager.getProfileId() + return if (profileId.isNullOrBlank()) Route.ProfileSelection.route else null +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/BottomNavBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/BottomNavBar.kt index 79151f45e..da9f94f84 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/BottomNavBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/BottomNavBar.kt @@ -1,5 +1,22 @@ package org.prairieserver.prairie.android.ui.navigation +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.CalendarMonth @@ -13,23 +30,27 @@ import androidx.compose.material.icons.outlined.GridView import androidx.compose.material.icons.outlined.Home import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect /** * Total height of the translucent bottom chrome (cast mini bar + nav bar + @@ -62,76 +83,178 @@ enum class Tab( } /** - * Material 3 bottom navigation bar themed for Prairie's dark-first design. + * The tab destination sitting lowest on the back stack — the anchor tab + * switching pops to. + * + * Derived from the live stack rather than remembered: `MainScreen` is composed + * per tab destination, so a remembered anchor gave every tab its own copy, and + * the graph's declared start destination keeps naming a tab even after that tab + * is removed. Popping to a route that is not on the stack pops nothing, so + * every tab tap stacked and Back walked back through previously visited tabs. + * + * This finds the oldest tab entry deterministically; what needs care is turning + * it back into a route string, because `popUpTo(route)` resolves to the NEWEST + * matching entry. The result is therefore unambiguous only while a tab route + * appears at most once. Every path in this build that can add a tab entry keeps + * that true: tab switching and the disappearing-tab cleanup both collapse to + * the anchor before pushing, external tab links switch rather than push, and the + * legacy-route aliases pop themselves inclusively before navigating, so their + * `launchSingleTop` sees Home on top when Home sat immediately below them. + * Duplicate tab routes are otherwise unsupported — a back stack restored from an + * older build could arrive holding them, and this does not repair that, so older + * tab entries may be left underneath. + */ +internal fun NavHostController.bottomMostTabRoute(): String? { + val tabRoutes = Tab.entries.mapTo(mutableSetOf()) { it.route } + return currentBackStack.value + .firstOrNull { entry -> entry.destination.route in tabRoutes } + ?.destination + ?.route +} + +/** The route's tab, if it is one. */ +internal fun tabForRoute(route: String): Tab? = Tab.entries.firstOrNull { it.route == route } + +/** + * Standard tab-switch options: replace the current tab rather than stack it, + * preserving each tab's own state. + * + * External links to a tab use these too, so `prairie://downloads` behaves exactly + * like tapping Downloads — one definition of what entering a tab means, rather + * than two that drift. + */ +internal fun NavOptionsBuilder.tabSwitchNavOptions(anchorRoute: String?) { + anchorRoute?.let { popUpTo(it) { saveState = true } } + launchSingleTop = true + restoreState = true +} + +private val PillHeight = 60.dp +private val PillHorizontalMargin = 20.dp +private val PillBottomMargin = 10.dp +private val PillTopMargin = 8.dp +// Glass recipe: content beneath is blurred by Haze, then tinted with this +// wash so labels stay legible over bright posters. On API < 31 Haze cannot +// blur and paints only the tint, so the fallback fill is heavier. +private val PillGlassTint = Color(0xFF1C1C1E).copy(alpha = 0.72f) +private val PillFallbackFill = Color(0xFF1C1C1E).copy(alpha = 0.96f) +private val PillBlurRadius = 24.dp +private val PillHairline = Color.White.copy(alpha = 0.12f) +private val SelectedChipFill = Color.White.copy(alpha = 0.14f) + +/** + * Floating pill tab bar, matching the iOS app's detached bottom capsule. + * + * The bar draws no full-width scrim: tab content scrolls edge-to-edge and + * shows around the capsule. The capsule itself is real glass — [hazeState] + * must be the state the tab content is registered on via `hazeSource`, so + * the pill blurs whatever scrolls beneath it and tints the result. The selected tab + * carries a soft chip highlight and a filled icon; unselected tabs are + * outlined and muted. Colors animate on switch so the highlight reads as + * moving rather than popping. */ @Composable fun PrairieBottomNavBar( currentTab: Tab, onTabSelected: (Tab) -> Unit, + hazeState: HazeState, // Caller decides which tabs to render — used to hide the Downloads tab // when the user has no downloads in flight or on disk. Defaults to all // tabs for backwards-compat. tabs: List = Tab.entries.toList(), ) { - // Paint the bar background on the outer Box so it extends behind the - // gesture-nav inset, then apply the inset as padding around the - // NavigationBar itself. This keeps a clean 60dp content area for the - // items so they sit vertically centered, instead of getting squeezed - // toward the top by NavigationBar's internal inset padding. - // - // Translucent glass (iOS tab bar): content scrolls edge-to-edge beneath - // the bar, so the fill is a light-to-heavier scrim — enough see-through - // to read as glass, enough ink to keep labels legible over bright - // posters — capped with the same hairline the top chrome uses. True - // backdrop blur needs API 31 + a blur pipeline; the scrim is the - // dependency-free equivalent. - val glass = MaterialTheme.colorScheme.background Box( modifier = Modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - 0f to glass.copy(alpha = 0.72f), - 1f to glass.copy(alpha = 0.94f), - ), + .navigationBarsPadding() + .padding( + start = PillHorizontalMargin, + end = PillHorizontalMargin, + top = PillTopMargin, + bottom = PillBottomMargin, ), ) { - Box( + Row( modifier = Modifier .fillMaxWidth() - .height(0.75.dp) - .background(Color.White.copy(alpha = 0.08f)), - ) - Box(modifier = Modifier.navigationBarsPadding()) { - NavigationBar( - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - windowInsets = WindowInsets(0), - modifier = Modifier.height(60.dp), - ) { - tabs.forEach { tab -> - val selected = tab == currentTab - NavigationBarItem( - selected = selected, - onClick = { onTabSelected(tab) }, - icon = { - Icon( - imageVector = if (selected) tab.selectedIcon else tab.icon, - contentDescription = tab.label, - ) - }, - label = { Text(text = tab.label, style = MaterialTheme.typography.labelSmall) }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.onSurface, - selectedTextColor = MaterialTheme.colorScheme.onSurface, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, - indicatorColor = Color.White.copy(alpha = 0.08f), - ), - ) + .height(PillHeight) + .shadow(elevation = 16.dp, shape = CircleShape, clip = false) + .clip(CircleShape) + .hazeEffect(state = hazeState) { + blurRadius = PillBlurRadius + noiseFactor = 0f + backgroundColor = PillFallbackFill + tints = listOf(HazeTint(PillGlassTint)) + fallbackTint = HazeTint(PillFallbackFill) } + .border(0.75.dp, PillHairline, CircleShape) + .padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + PillTabItem( + tab = tab, + selected = tab == currentTab, + onClick = { onTabSelected(tab) }, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) } } } } + +@Composable +private fun PillTabItem( + tab: Tab, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val chip by animateColorAsState( + targetValue = if (selected) SelectedChipFill else Color.Transparent, + animationSpec = tween(durationMillis = 220), + label = "tabChip", + ) + val tint by animateColorAsState( + targetValue = if (selected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + animationSpec = tween(durationMillis = 220), + label = "tabTint", + ) + val interaction = remember { MutableInteractionSource() } + Column( + modifier = modifier + .clip(CircleShape) + .background(chip) + // selectable (not clickable) so TalkBack announces which tab is + // active — the chip and filled icon alone are not perceivable. + .selectable( + selected = selected, + interactionSource = interaction, + indication = ripple(bounded = true, color = Color.White), + role = Role.Tab, + onClick = onClick, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + ) { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.icon, + contentDescription = tab.label, + tint = tint, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = tab.label, + color = tint, + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkRoutes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkRoutes.kt index e4544dff6..daf7bb9a9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkRoutes.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkRoutes.kt @@ -24,14 +24,22 @@ internal fun contentDeepLinkRouteOrNull(rawUri: String?): String? { ?: return null if (!uri.scheme.equals("prairie", ignoreCase = true)) return null - val contentId = uri.path.orEmpty() + // Read the RAW path and decode exactly one segment. `URI.path` is already + // percent-decoded, so taking it and interpolating the result back into a + // route re-parsed the decoded bytes as route syntax: an id containing an + // encoded `?` or `/` could truncate the id or inject an argument. The + // route constructors below re-encode, so this must hand them the decoded + // id exactly once. + val contentId = uri.rawPath.orEmpty() .trim('/') .substringBefore('/') + .let(::decodePathSegment) + .orEmpty() .trim() return when (uri.host?.lowercase()) { "downloads" -> Route.Downloads.route - "item" -> contentId.takeIf { it.isNotBlank() }?.let { "item/$it" } + "item" -> contentId.takeIf { it.isNotBlank() }?.let { Route.ItemDetail(it).route } "play" -> contentId.takeIf { it.isNotBlank() }?.let { Route.Player( contentId = it, @@ -56,3 +64,15 @@ private fun URI.queryParameter(name: String): String? = rawQuery private fun decodeQueryComponent(value: String): String? = runCatching { URLDecoder.decode(value, StandardCharsets.UTF_8.name()) }.getOrNull() + +/** + * Percent-decoding for a PATH segment. Deliberately not [decodeQueryComponent]: + * `URLDecoder` implements form encoding, where `+` means space — but in a path + * a `+` is a literal plus, so an id containing one would be corrupted. + */ +private fun decodePathSegment(value: String): String? = runCatching { + // Escape `+` first: URLDecoder implements form encoding where `+` means + // space, but in a path a `+` is a literal plus. android.net.Uri.decode + // would do this correctly, but it is stubbed in plain JVM unit tests. + URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.name()) +}.getOrNull() diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt index b1337fda4..78e7b682c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParser.kt @@ -20,12 +20,92 @@ internal fun deviceLoginPairRouteOrNull(rawUri: String?): String? { if (!uri.isDeviceLoginUri()) return null + // A device-SHAPED http(s) link whose origin cannot be read is not a usable + // pairing request. Letting it through produced a route with no + // `serverOrigin`, which downstream reads as "names no server" and pairs + // against whichever server is active — exactly what the origin check + // exists to stop. + val scope = deviceLoginScope(rawUri) + if (scope == DeviceLoginScope.Invalid) return null + val params = uri.queryParameters() val token = params["token"]?.takeIf { it.isNotBlank() } val code = params["code"]?.takeIf { it.isNotBlank() } if (token == null && code == null) return null - return buildPairDeviceRoute(token = token, code = if (token == null) code else null) + return buildPairDeviceRoute( + token = token, + code = if (token == null) code else null, + serverOrigin = (scope as? DeviceLoginScope.Origin)?.origin, + ) +} + +/** + * What server, if any, a device link names. + * + * The three cases must stay distinct. Collapsing "names no server" and "names + * something unparseable" into one null meant a malformed link such as + * `https:///device?code=...` — which still satisfies the device-path check but + * has no host — was treated as unscoped and accepted against whichever server + * was active, which is the behaviour this guard exists to remove. + */ +internal sealed interface DeviceLoginScope { + /** An app-scheme link (`prairie://device`): names no server. */ + data object Unscoped : DeviceLoginScope + + /** An http(s) link naming [origin], already normalized. */ + data class Origin(val origin: String) : DeviceLoginScope + + /** Not a device link, or an http(s) one whose origin cannot be read. */ + data object Invalid : DeviceLoginScope +} + +internal fun deviceLoginScope(rawUri: String?): DeviceLoginScope { + val uri = rawUri + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { URI(it) }.getOrNull() } + ?: return DeviceLoginScope.Invalid + if (!uri.isDeviceLoginUri()) return DeviceLoginScope.Invalid + val scheme = uri.scheme?.lowercase() ?: return DeviceLoginScope.Invalid + if (scheme != "http" && scheme != "https") return DeviceLoginScope.Unscoped + return uri.normalizedOrigin()?.let(DeviceLoginScope::Origin) ?: DeviceLoginScope.Invalid +} + +/** + * `scheme://host[:port]`, with the scheme's default port dropped so + * `https://silo.example` and `https://silo.example:443` compare equal. + */ +private fun URI.normalizedOrigin(): String? { + val scheme = scheme?.lowercase() ?: return null + val host = host?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + val defaultPort = if (scheme == "https") 443 else 80 + // URI reports -1 for "omitted". Anything else must be a real port: 0 is not + // a valid origin and must not quietly compare equal to the default one. + val port = port + if (port != -1 && port !in 1..65535) return null + val explicitPort = port.takeIf { it != -1 && it != defaultPort } + return if (explicitPort != null) "$scheme://$host:$explicitPort" else "$scheme://$host" +} + +/** + * Whether [requiredOrigin] is the origin of [activeServerUrl]. + * + * BOTH sides are normalized. Comparing a caller-supplied origin verbatim made + * `https://h:443` a different server from `https://h`, so a valid link was + * refused. + */ +internal fun deviceLoginOriginMatchesServer( + requiredOrigin: String, + activeServerUrl: String?, +): Boolean { + val required = runCatching { URI(requiredOrigin) }.getOrNull()?.normalizedOrigin() + ?: return false + val active = activeServerUrl + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { URI(it) }.getOrNull() } + ?.normalizedOrigin() + ?: return false + return active == required } private fun URI.isDeviceLoginUri(): Boolean { @@ -45,20 +125,27 @@ private fun URI.queryParameters(): Map = .mapNotNull { pair -> val idx = pair.indexOf("=") if (idx < 0) return@mapNotNull null - val key = pair.substring(0, idx).urlDecode() - val value = pair.substring(idx + 1).urlDecode() - key to value + // Reachable from onNewIntent with URIs other apps craft; bad + // percent-encoding must parse to null, not throw. + runCatching { + pair.substring(0, idx).urlDecode() to pair.substring(idx + 1).urlDecode() + }.getOrNull() } .toMap() private fun String.urlDecode(): String = URLDecoder.decode(this, Charsets.UTF_8.name()) -private fun buildPairDeviceRoute(token: String?, code: String?): String = buildString { +private fun buildPairDeviceRoute( + token: String?, + code: String?, + serverOrigin: String?, +): String = buildString { append("pair_device") val params = listOfNotNull( token?.takeIf { it.isNotBlank() }?.let { "token=${it.routeEncode()}" }, code?.takeIf { it.isNotBlank() }?.let { "code=${it.routeEncode()}" }, + serverOrigin?.takeIf { it.isNotBlank() }?.let { "serverOrigin=${it.routeEncode()}" }, ) if (params.isNotEmpty()) { append("?") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt new file mode 100644 index 000000000..c40a1eaaa --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatch.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.android.ui.navigation + +import org.prairieserver.prairie.model.server.ServerEntry + +/** + * What to do with a pairing request that names the server which issued it. + * + * A pairing code is only meaningful on its own server. The origin used to be + * discarded and the code looked up against whichever server happened to be + * active, which normally reported a perfectly valid request as invalid or + * expired. Refusing that is right — but refusing it *silently* just moves the + * confusion, so the request is still delivered and the screen explains itself. + */ +sealed interface DeviceLoginServerMatch { + /** Proceed: the link names this server, or names none. */ + data object Active : DeviceLoginServerMatch + + /** The link belongs to [entry], which the user has but is not using. */ + data class SwitchRequired(val entry: ServerEntry) : DeviceLoginServerMatch + + /** The link names [origin], which is not a server the user has. */ + data class UnknownServer(val origin: String) : DeviceLoginServerMatch +} + +/** + * Resolves [requiredOrigin] against the known servers. + * + * [activeServerUrl] null means no server is configured yet — the user is on + * their way to adding one, so there is nothing to contradict and pairing + * proceeds through the normal setup gates. + */ +fun deviceLoginServerMatch( + requiredOrigin: String?, + activeServerUrl: String?, + entries: List, +): DeviceLoginServerMatch { + if (requiredOrigin == null) return DeviceLoginServerMatch.Active + if (activeServerUrl == null) return DeviceLoginServerMatch.Active + if (deviceLoginOriginMatchesServer(requiredOrigin, activeServerUrl)) { + return DeviceLoginServerMatch.Active + } + val known = entries.firstOrNull { deviceLoginOriginMatchesServer(requiredOrigin, it.url) } + return known + ?.let(DeviceLoginServerMatch::SwitchRequired) + ?: DeviceLoginServerMatch.UnknownServer(requiredOrigin) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt new file mode 100644 index 000000000..6264226de --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigation.kt @@ -0,0 +1,304 @@ +package org.prairieserver.prairie.android.ui.navigation + +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteIntent +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget +import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs + +/** + * The identity an external request is only meaningful against. + * + * External requests wait: a link can arrive before sign-in and sit through + * setup, login and profile selection, and the identity it was created for may + * not be the one active when it finally fires. Every route that means something + * different under a different server or profile has to say so and be re-checked + * at delivery, or it acts on whoever happens to be signed in by then. + */ +sealed interface ExternalRouteScope { + /** Meaningful under any identity — e.g. "open the Downloads tab". */ + data object Unscoped : ExternalRouteScope + + /** + * Valid only under this server and profile. Used by notifications (which + * are generated for one profile's inbox) and by content links (whose ids + * are server-local). Null components mean "was not signed in when this was + * created", which constrains nothing. + */ + data class Identity( + val serverId: String?, + val profileId: String?, + /** + * The identity generation this route was created under. + * + * Ids alone cannot tell "still the same session" from "signed out and + * back into the same account", nor A -> B -> A. Both re-authenticate, + * and a route authored for the earlier session should not act on the + * later one. Null means the generation was unknown at creation and + * constrains nothing, same as the ids. + * + * Set for routes captured in-process. NOT set for notifications: the + * counter restarts at zero in every process, so persisting it into a + * PendingIntent would refuse a legitimate notification tapped after the + * app was killed. Notifications therefore keep only server+profile + * pinning — see [notificationExternalRouteOrNull]. + */ + val identityGeneration: Long? = null, + ) : ExternalRouteScope { + /** + * Each component constrains only if it was known. A link that arrived + * with a server but no profile yet — configured server, nobody signed + * in — must still deliver once a profile IS chosen; requiring the + * profile to still be null would drop exactly the link the user was + * signing in to open. + * + * The generation is deliberately NOT credentialEpoch: that moves on + * ordinary token writes, and pinning to it would kill legitimate routes + * after a routine refresh. + */ + fun matches( + serverId: String?, + profileId: String?, + identityGeneration: Long?, + ): Boolean = + (this.serverId == null || this.serverId == serverId) && + (this.profileId == null || this.profileId == profileId) && + (this.identityGeneration == null || this.identityGeneration == identityGeneration) + } +} + +/** A single external-navigation delivery, distinct even when its route repeats. */ +class ExternalRouteRequest internal constructor( + val generation: Long, + val route: String, + val scope: ExternalRouteScope = ExternalRouteScope.Unscoped, +) + +internal class ExternalRouteRequestFactory { + private var latestGeneration = 0L + + fun create( + route: String, + scope: ExternalRouteScope = ExternalRouteScope.Unscoped, + ): ExternalRouteRequest = + ExternalRouteRequest( + generation = ++latestGeneration, + route = route, + scope = scope, + ) +} + +internal fun clearConsumedExternalRouteRequest( + pendingRequest: ExternalRouteRequest?, + consumedRequest: ExternalRouteRequest, +): ExternalRouteRequest? = + if (pendingRequest?.generation == consumedRequest.generation) null else pendingRequest + +/** + * True when [targetRoute] is the item detail already on top. + * + * launchSingleTop matches the destination NODE, not its arguments, so an + * external link to item B while item A's detail is showing reuses A's entry — + * and its ViewModelStore — leaving Back to skip A entirely. Single-top is only + * correct here when the arguments say it really is the same screen. + */ +internal fun isSameItemDetail( + currentDestinationRoute: String?, + currentContentId: String?, + targetRoute: String, +): Boolean { + if (currentDestinationRoute != Route.ItemDetail.ROUTE) return false + if (!targetRoute.startsWith("item/")) return false + val targetContentId = targetRoute + .substringAfter("item/") + .substringBefore('?') + .takeIf { it.isNotBlank() } + // Same decode as the player intent: the route percent-encodes the id, + // and an encoded id never equals the decoded one held by the entry. + ?.let { + runCatching { + URLDecoder.decode(it.replace("+", "%2B"), StandardCharsets.UTF_8.name()) + }.getOrNull() + } + ?.takeIf { it.isNotBlank() } + ?: return false + return targetContentId == currentContentId +} + +internal fun shouldReplaceCurrentPlayer( + currentDestinationRoute: String?, + targetRoute: String, +): Boolean = + currentDestinationRoute == Route.Player.ROUTE && targetRoute.startsWith("player/") + +/** + * Whether an external tab switch may remove the player without also removing + * newer history above it. An inclusive route pop removes the target and every + * entry above it, so only the current player is a safe target. + */ +internal fun shouldPopPlayerBeforeExternalTab(currentDestinationRoute: String?): Boolean = + currentDestinationRoute == Route.Player.ROUTE + +/** + * Whether AndroidX may reuse the current destination node for [targetRoute]. + * + * External requests currently produce only Inbox, item detail, player, + * pairing, invitation, and the Downloads tab (handled before this function). + * Inbox has no arguments. Item detail is reusable only for the same decoded + * content id. A player target is replaced explicitly when a player is on top. + * Pairing and invitation routes carry one-shot arguments, so each delivery + * must retain its own entry. + */ +internal fun shouldLaunchExternalRouteSingleTop( + currentDestinationRoute: String?, + currentContentId: String?, + targetRoute: String, +): Boolean = + shouldReplaceCurrentPlayer(currentDestinationRoute, targetRoute) || + isSameItemDetail(currentDestinationRoute, currentContentId, targetRoute) || + (currentDestinationRoute == Route.Inbox.route && targetRoute == Route.Inbox.route) + +/** Parses the canonical in-app player route carried by an external request. */ +internal fun playerRouteIntentOrNull(route: String): MobilePlayerRouteIntent? { + if (!route.startsWith("player/")) return null + // Route.Player percent-encodes the content id, so decode it back here — + // this value is compared against the live player's target, and an encoded + // id would never match a decoded one, making an already-showing player look + // like a different request and restart it. + val contentId = route + .substringAfter("player/") + .substringBefore('?') + .takeIf { it.isNotBlank() } + ?.let { runCatching { URLDecoder.decode(it.replace("+", "%2B"), StandardCharsets.UTF_8.name()) }.getOrNull() } + ?.takeIf { it.isNotBlank() } + ?: return null + val query = route + .substringAfter('?', "") + .split('&') + .filter(String::isNotBlank) + .associate { part -> part.substringBefore('=') to part.substringAfter('=', "") } + // This provider describes solo playback only. A room-scoped target must be + // handled by Watch Together even if every media choice happens to match. + if ("roomId" in query) return null + + val fileId = query["fileId"]?.toIntOrNull() + if ("fileId" in query && (fileId == null || fileId <= 0)) return null + val quality = query[VideoPlayerRouteArgs.QUALITY] + ?.let(VideoPlayerRouteArgs::normalizeQuality) + if (VideoPlayerRouteArgs.QUALITY in query && quality == null) return null + val audioTrackIndex = query["audioTrackIndex"]?.toIntOrNull() + if ("audioTrackIndex" in query && (audioTrackIndex == null || audioTrackIndex < 0)) return null + val subtitleTrackIndex = query["subtitleTrackIndex"]?.toIntOrNull() + if ("subtitleTrackIndex" in query && (subtitleTrackIndex == null || subtitleTrackIndex < -1)) return null + val resumePositionSeconds = query[VideoPlayerRouteArgs.RESUME_POSITION] + ?.let(VideoPlayerRouteArgs::parseResumePosition) + if (VideoPlayerRouteArgs.RESUME_POSITION in query && resumePositionSeconds == null) return null + + return MobilePlayerRouteIntent( + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + fileIsExplicit = "fileId" in query, + qualityIsExplicit = VideoPlayerRouteArgs.QUALITY in query, + audioTrackIsExplicit = "audioTrackIndex" in query, + subtitleTrackIsExplicit = "subtitleTrackIndex" in query, + ) +} + +/** + * Tests an incoming route against both the current route intent and live + * playback values. An omitted parameter remains exact only while the player is + * still using automatic selection; an explicit parameter compares to the live + * resolved value. + */ +internal fun MobilePlayerRouteIntent.matches(target: MobilePlayerRouteTarget): Boolean = + contentId == target.contentId && + resumePositionSeconds == target.resumePositionSeconds && + optionalTargetMatches( + requestedIsExplicit = fileIsExplicit, + requestedValue = fileId, + currentIsExplicit = target.intent.fileIsExplicit, + liveValue = target.fileId, + ) && + optionalTargetMatches( + requestedIsExplicit = qualityIsExplicit, + requestedValue = quality, + currentIsExplicit = target.intent.qualityIsExplicit, + liveValue = target.quality, + ) && + optionalTargetMatches( + requestedIsExplicit = audioTrackIsExplicit, + requestedValue = audioTrackIndex, + currentIsExplicit = target.intent.audioTrackIsExplicit, + liveValue = target.audioTrackIndex, + ) && + optionalTargetMatches( + requestedIsExplicit = subtitleTrackIsExplicit, + requestedValue = subtitleTrackIndex, + currentIsExplicit = target.intent.subtitleTrackIsExplicit, + liveValue = target.subtitleTrackIndex, + ) + +private fun optionalTargetMatches( + requestedIsExplicit: Boolean, + requestedValue: T?, + currentIsExplicit: Boolean, + liveValue: T?, +): Boolean = if (requestedIsExplicit) { + requestedValue != null && requestedValue == liveValue +} else { + !currentIsExplicit +} + +private val preAuthenticationDestinationRoutes = setOf( + Route.Login.route, + Route.ServerSetup.route, + Route.ServerList.route, + Route.Setup.route, + Route.Signup.route, + Route.ProfileSelection.route, + Route.CreateProfile.route, + Route.EditProfile.ROUTE, + Route.PairDevice.ROUTE, + Route.InviteClaim.ROUTE, + Route.OnboardingTour.route, +) + +/** + * Waits until [pendingExternalRoute] is allowed from the current graph, then + * delivers it exactly once. [first] ends the back-stack subscription before + * [navigate] can emit the destination it just added; navigating from inside a + * long-lived collector otherwise feeds that new entry back into the same route. + */ +internal suspend fun consumeExternalRouteOnce( + pendingExternalRoute: ExternalRouteRequest?, + currentDestinationRoutes: Flow, + isAlreadyAtRoute: (String) -> Boolean = { false }, + /** + * Whether the request's [ExternalRouteScope] still matches the live + * identity. Evaluated AFTER the wait, not before it. + */ + isStillValidForScope: suspend (ExternalRouteScope) -> Boolean = { true }, + navigate: (String) -> Unit, + onConsumed: (ExternalRouteRequest) -> Unit, +) { + val request = pendingExternalRoute ?: return + val route = request.route.takeIf { it.isNotBlank() } ?: return + val isPreAuthenticationTarget = route.startsWith("invite_claim") + + currentDestinationRoutes.first { currentRoute -> + isPreAuthenticationTarget || currentRoute !in preAuthenticationDestinationRoutes + } + val scopeStillValid = isStillValidForScope(request.scope) + if (scopeStillValid && !isAlreadyAtRoute(route)) { + navigate(route) + } + // Consumed either way: a request whose identity no longer matches must not + // sit in the queue waiting to fire at some later, equally wrong moment. + onConsumed(request) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt new file mode 100644 index 000000000..90ca35379 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/InviteClaimRouteParser.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.android.ui.navigation + +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * Maps an emailed-invitation link (`prairie://invite?server=...&token=...`) + * into the in-app claim route. + * + * Cold starts match through the composable's navDeepLink; this exists for + * the warm path — an intent delivered to a live activity via onNewIntent + * never reaches navDeepLink matching, and without this the tap would be + * silently dropped. + */ +internal fun inviteClaimRouteOrNull(rawUri: String?): String? { + val uri = rawUri + ?.takeIf { it.isNotBlank() } + ?.let { runCatching { URI(it) }.getOrNull() } + ?: return null + + if (!uri.scheme.equals("prairie", ignoreCase = true)) return null + if (!uri.host.equals("invite", ignoreCase = true)) return null + + // Any other app can hand the exported activity a malformed URI via + // onNewIntent; bad percent-encoding must parse to null, not throw. + val params = uri.rawQuery + .orEmpty() + .split("&") + .filter { it.isNotBlank() } + .mapNotNull { pair -> + val idx = pair.indexOf("=") + if (idx < 0) return@mapNotNull null + runCatching { + val key = URLDecoder.decode(pair.substring(0, idx), Charsets.UTF_8.name()) + val value = URLDecoder.decode(pair.substring(idx + 1), Charsets.UTF_8.name()) + key to value + }.getOrNull() + } + .toMap() + + val server = params["server"]?.takeIf { it.isNotBlank() } ?: return null + val token = params["token"]?.takeIf { it.isNotBlank() } ?: return null + + return "invite_claim?server=${server.routeEncode()}&token=${token.routeEncode()}" +} + +private fun String.routeEncode(): String = + URLEncoder.encode(this, Charsets.UTF_8.name()).replace("+", "%20") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRoute.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRoute.kt new file mode 100644 index 000000000..841735c1a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRoute.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.android.ui.navigation + +/** + * A notification's navigation request, accepted only when it says whose it is. + * + * The identity is validated HERE as well as stamped at post time, because the + * delivery side must not trust an Intent it merely received. Two ways an + * unattributed one can arrive: a notification posted by a build before the + * extras existed, and an explicit Intent crafted against the exported Activity. + * Both used to produce `Identity(null, null)`, which matches every identity — + * so the route ran against whoever happened to be signed in. + * + * The identity GENERATION is deliberately not carried, unlike an in-process + * route captured while the app is running. That counter restarts at zero in + * every process, so a notification tapped after the app has been killed would + * be compared against a generation that means something different — and a + * perfectly legitimate notification would be refused. Notifications are + * therefore pinned by server and profile only, and remain deliverable across a + * sign-out and back in to the same account. Closing that would need a durable + * identity epoch rather than a process-local counter. + */ +fun notificationExternalRouteOrNull( + route: String?, + serverId: String?, + profileId: String?, +): Pair? { + val usableRoute = route?.takeIf { it.isNotBlank() } ?: return null + val usableServerId = serverId?.takeIf { it.isNotBlank() } ?: return null + val usableProfileId = profileId?.takeIf { it.isNotBlank() } ?: return null + return usableRoute to ExternalRouteScope.Identity( + serverId = usableServerId, + profileId = usableProfileId, + // Explicit: see the note above on why a process-local generation must + // not be persisted into a PendingIntent. + identityGeneration = null, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt index c902ccb83..b408ed077 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt @@ -1,10 +1,12 @@ package org.prairieserver.prairie.android.ui.navigation import android.net.Uri +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs /** - * All navigation routes for the Prairie app. + * All navigation routes for the Silo app. * * Screens that take parameters use companion objects with a ROUTE constant * containing the placeholder (e.g. "item/{contentId}") for use with NavHost, @@ -14,36 +16,43 @@ sealed class Route(val route: String) { // --- Auth flow (no bottom nav) --- data object ServerSetup : Route("server_setup") + data object ServerList : Route("server_list") + data object Login : Route("login") + data object Setup : Route("setup") + data object Signup : Route("signup") /** - * Multi-server picker / first-run LAN discovery. - * - * Navigate with [autoScanRoute] when landing from cold start or - * "Change server" so the list kicks off a LAN health scan. Management - * from settings can use the bare [route] (autoScan defaults false). + * Emailed-invitation claim. The deep link carries the server URL and the + * single-use token, so the app skips its "which server?" step entirely. */ - data object ServerList : Route("server_list") { - const val ROUTE = "server_list?autoScan={autoScan}" - const val ARG_AUTO_SCAN = "autoScan" - /** Cold-start destination: server list with auto LAN scan. */ - const val START = "server_list?autoScan=true" - - fun autoScanRoute(autoScan: Boolean = true): String = - "server_list?autoScan=$autoScan" + data class InviteClaim(val server: String, val token: String) : Route( + "invite_claim?server=${Uri.encode(server)}&token=${Uri.encode(token)}", + ) { + companion object { + const val ROUTE = "invite_claim?server={server}&token={token}" + } } - data object Login : Route("login") - data object Setup : Route("setup") - data object Signup : Route("signup") + + /** Server-driven first-run feature tour, shown after profile selection. */ + data object OnboardingTour : Route("onboarding_tour") data class PairDevice( val token: String? = null, val code: String? = null, + /** + * Origin of the server that issued this pairing request, when the link + * named one. Carried so the screen can refuse — and explain — rather + * than looking the code up against whichever server is active. + */ + val serverOrigin: String? = null, ) : Route( buildString { append("pair_device") val params = listOfNotNull( token?.takeIf { it.isNotBlank() }?.let { "token=${Uri.encode(it)}" }, code?.takeIf { it.isNotBlank() }?.let { "code=${Uri.encode(it)}" }, + serverOrigin?.takeIf { it.isNotBlank() } + ?.let { "serverOrigin=${Uri.encode(it)}" }, ) if (params.isNotEmpty()) { append("?") @@ -52,7 +61,7 @@ sealed class Route(val route: String) { }, ) { companion object { - const val ROUTE = "pair_device?token={token}&code={code}" + const val ROUTE = "pair_device?token={token}&code={code}&serverOrigin={serverOrigin}" } } @@ -80,7 +89,6 @@ sealed class Route(val route: String) { } } data object Settings : Route("settings") - data object CardOverlays : Route("settings/card_overlays") data object Diagnostics : Route("settings/diagnostics") data class DiagnosticsReport(val reportId: String) : Route("settings/diagnostics/report/${Uri.encode(reportId)}") { @@ -89,8 +97,11 @@ sealed class Route(val route: String) { } } - // Canonical tab routes — Home is the start destination and the bottom-nav / - // popUpTo anchor; Libraries and Recommendations back the other media tabs. + // Canonical tab routes. Home is the USUAL start destination, but not + // always: an offline launch with downloads starts on Downloads instead, so + // the bottom-nav popUpTo anchor is read from the live back stack + // ([bottomMostTabRoute]) rather than assumed to be Home. Libraries and + // Recommendations back the other media tabs. data object Home : Route("home") data object Libraries : Route("libraries") data object Recommendations : Route("recommendations") @@ -108,32 +119,16 @@ sealed class Route(val route: String) { } } - // --- Live TV (profile-menu entry, not a bottom-nav tab) --- - data object LiveTv : Route("livetv") - data class LiveTvPlayer( - val channelId: String, - val channelName: String = "", - ) : Route( - buildString { - append("livetv/player/${Uri.encode(channelId)}") - if (channelName.isNotBlank()) { - append("?name=${Uri.encode(channelName)}") - } - }, - ) { - companion object { - const val ROUTE = "livetv/player/{channelId}?name={name}" - const val ARG_CHANNEL_ID = "channelId" - const val ARG_NAME = "name" - } - } - // --- Detail screens (back navigation, no bottom nav) --- data class ItemDetail( val contentId: String, val seasonNumber: Int? = null, ) : Route( - if (seasonNumber != null) "item/$contentId?seasonNumber=$seasonNumber" else "item/$contentId" + if (seasonNumber != null) { + "item/${contentId.routeEncode()}?seasonNumber=$seasonNumber" + } else { + "item/${contentId.routeEncode()}" + } ) { companion object { const val ROUTE = "item/{contentId}?seasonNumber={seasonNumber}" @@ -159,7 +154,11 @@ sealed class Route(val route: String) { val collectionId: String, val libraryId: Int? = null, ) : Route( - if (libraryId != null) "collection/$collectionId?libraryId=$libraryId" else "collection/$collectionId" + if (libraryId != null) { + "collection/${collectionId.routeEncode()}?libraryId=$libraryId" + } else { + "collection/${collectionId.routeEncode()}" + } ) { companion object { const val ROUTE = "collection/{collectionId}?libraryId={libraryId}" @@ -167,7 +166,7 @@ sealed class Route(val route: String) { } // --- Player / casting (fullscreen or hidden shell routes, no menu entries) --- - data object PrairieCastRemote : Route("prairiecast/remote") + data object PrairieCastRemote : Route("silocast/remote") data class Player( val contentId: String, @@ -179,7 +178,7 @@ sealed class Route(val route: String) { val roomId: String? = null, ) : Route( buildString { - append("player/$contentId") + append("player/${contentId.routeEncode()}") val queryParams = listOfNotNull( fileId?.let { "fileId=$it" }, // normalizeQuality is a closed wire-value set, so no URI @@ -220,7 +219,7 @@ sealed class Route(val route: String) { // resolves which part contains it; null resumes from the stored position. val startPosition: Double? = null, ) : Route( - "audiobook/$contentId" + + "audiobook/${contentId.routeEncode()}" + listOfNotNull( fileId?.let { "fileId=$it" }, if (fromStart) "fromStart=true" else null, @@ -239,7 +238,7 @@ sealed class Route(val route: String) { // --- Book reader (fullscreen, dispatches by BookFormat) --- data class BookReader(val contentId: String, val fileId: Int? = null) : Route( - "reader/$contentId" + fileId?.let { "?fileId=$it" }.orEmpty(), + "reader/${contentId.routeEncode()}" + fileId?.let { "?fileId=$it" }.orEmpty(), ) { companion object { const val ROUTE = "reader/{contentId}?fileId={fileId}" @@ -254,9 +253,6 @@ sealed class Route(val route: String) { // --- Personal data --- data object Favorites : Route("favorites") data object Watchlist : Route("watchlist") - - /** Admin stats dashboard (Apple-parity surface; role-gated entry in Settings). */ - data object Admin : Route("admin") data object History : Route("history") data object PersonalLists : Route("personal_lists") data class Collections(val libraryId: Int? = null) : Route( @@ -268,3 +264,17 @@ sealed class Route(val route: String) { } } + +/** + * Percent-encode a value for use as a route path segment. + * + * Deliberately `java.net.URLEncoder` rather than `android.net.Uri.encode`: + * routes are built in plain JVM unit tests, where `android.net.Uri` is stubbed + * and silently returns null — a route would become "item/null" and the test + * would assert against nonsense. Mirrors the TV app's `routeEncode`. + * + * `URLEncoder` is form encoding, where a space becomes `+`; a path segment + * needs `%20`, hence the fixup. + */ +private fun String.routeEncode(): String = + URLEncoder.encode(this, StandardCharsets.UTF_8.toString()).replace("+", "%20") diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt index 64563f0cc..1f81eff18 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt @@ -36,10 +36,16 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import org.prairieserver.prairie.android.ui.components.MainAppHeaderBodyHeight import org.prairieserver.prairie.android.ui.components.MainAppTopBar +import org.prairieserver.prairie.android.ui.components.TabTopBarActions import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.prairieserver.prairie.android.ui.navigation.PrairieBottomNavBar +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import org.prairieserver.prairie.android.ui.navigation.Route import org.prairieserver.prairie.android.ui.navigation.Tab +import org.prairieserver.prairie.android.ui.navigation.tabForRoute +import org.prairieserver.prairie.android.ui.navigation.tabSwitchNavOptions +import org.prairieserver.prairie.android.ui.navigation.bottomMostTabRoute import org.prairieserver.prairie.android.ui.navigation.fallbackMobileTab import org.prairieserver.prairie.android.ui.navigation.scopedLocalDownloadBytes import org.prairieserver.prairie.android.ui.navigation.shouldShowDownloadsTab @@ -52,12 +58,15 @@ import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastTargetPicker import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesScreen import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesSelectorSheet import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModel +import org.prairieserver.prairie.android.ui.screens.recommendations.ForYouList import org.prairieserver.prairie.android.ui.screens.recommendations.RecommendationsScreen +import org.prairieserver.prairie.android.ui.screens.recommendations.headerTitle +import org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySheet import org.prairieserver.prairie.cast.PrairieCastPlaybackRequest +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.MediaModeCapabilities import org.prairieserver.prairie.model.navigation.mobileMediaModeCapabilities -import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.common.network.ServerReachabilityMonitor @@ -83,12 +92,13 @@ fun MainScreen( ) { val headerViewModel = koinViewModel() val headerState by headerViewModel.uiState.collectAsState() - val prairieCastController: PrairieCastController = koinInject() - val prairieCastState by prairieCastController.state.collectAsState() + val siloCastController: PrairieCastController = koinInject() + val siloCastState by siloCastController.state.collectAsState() var showPrairieCastTargetPicker by rememberSaveable { mutableStateOf(false) } + var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } fun playVideo(contentId: String, fileId: Int? = null, resumePositionSeconds: Double? = null) { - val launchedRemotely = prairieCastController.launchOnConnectedTarget( + val launchedRemotely = siloCastController.launchOnConnectedTarget( PrairieCastPlaybackRequest( contentId = contentId, fileId = fileId, @@ -135,11 +145,9 @@ fun MainScreen( val authRepository: AuthRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() - val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() - val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() val activeEntry by serverRegistry.activeEntry.collectAsState() val mediaCapabilities by produceState( @@ -190,22 +198,61 @@ fun MainScreen( // If the user is on a tab no longer supported by their libraries (or // Downloads disappears), move them to the nearest visible media tab. + // A tab that can no longer be shown must not be left on the stack: no entry + // at the bottom for Back to reveal (its own effect would bounce straight + // back, trapping the user), and no saved subtree for a later reappearance to + // restore into. This can only act while a tab is composed — with a detail + // page covering it, cleanup waits until Back returns here. + // + // Deliberately no saveState/restoreState on this path. Saving the vanishing + // tab and then restoring on the way to the replacement is self-defeating: + // restoreState is evaluated before launchSingleTop, so navigating to Home + // immediately restored the Downloads subtree that had just been popped. LaunchedEffect(currentTab, visibleTabs) { - if (currentTab !in visibleTabs) { - val fallback = fallbackMobileTab(visibleTabs, currentTab) ?: Tab.Home - navController.navigate(fallback.route) { - popUpTo(Route.Home.route) { saveState = true } - launchSingleTop = true - restoreState = true + val anchorRoute = navController.bottomMostTabRoute() + val anchorTab = anchorRoute?.let(::tabForRoute) + val vanished = when { + currentTab !in visibleTabs -> currentTab + // The ANCHOR can vanish while the user is on some other tab. Nothing + // above it changed, so this is the only chance to notice. + anchorTab != null && anchorTab !in visibleTabs -> anchorTab + else -> null + } ?: return@LaunchedEffect + + val target = if (vanished == currentTab) { + fallbackMobileTab(visibleTabs, currentTab) ?: Tab.Home + } else { + // Re-rooting onto the tab in use also destroys its entry, losing + // scroll position. Accepted: the alternative leaves an unreachable + // root that Back can surface. + currentTab + } + + navController.navigate(target.route) { + // Pop to the ANCHOR, not merely to the vanished tab. Popping just + // the vanished one leaves any other tab entries below it in place, + // and pushing the target then adds a SECOND copy of a tab already + // down there — the duplicate that makes the anchor ambiguous. + // Collapsing to the anchor first keeps at most one entry per tab, + // and launchSingleTop absorbs the case where the target IS the + // anchor. + if (vanished == anchorTab) { + popUpTo(vanished.route) { inclusive = true } + } else { + anchorRoute?.let { popUpTo(it) { inclusive = false } } } + launchSingleTop = true } + // Drop any subtree saved for it by an earlier ordinary tab switch — + // popping without saveState does not clear existing mappings, and a + // reappearing Downloads would otherwise restore a stale stack and land + // the user on a different tab entirely. + navController.clearBackStack(vanished.route) } LaunchedEffect(activeEntry?.id, activeEntry?.profileId, headerState.activeProfile?.id) { requestsFeatureStore.reset() requestsFeatureStore.refresh() - liveTvFeatureStore.reset() - liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -214,7 +261,6 @@ fun MainScreen( reachabilityScope.launch { authRepository.logout() requestsFeatureStore.reset() - liveTvFeatureStore.reset() metadataAiFeatureStore.reset() navController.navigate(Route.Login.route) { popUpTo(0) { inclusive = true } @@ -227,19 +273,29 @@ fun MainScreen( } else { null } - val liveTvMenuAction: (() -> Unit)? = if (liveTvEnabled) { - { navController.navigate(Route.LiveTv.route) } - } else { - null - } + val watchTogetherMenuAction: (() -> Unit)? = + if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { + { showWatchTogetherEntry = true } + } else { + null + } + // Tab content registers as the blur source for the floating tab bar's + // glass; the pill blurs whatever scrolls beneath it. + val hazeState = rememberHazeState() + // For You's Watchlist / Favorites toggle lives here so the shared header + // can title itself after what the tab is showing. + var forYouList by rememberSaveable { mutableStateOf(null) } + // What For You is actually showing (the empty-feed fallback shows the + // Watchlist without making it an explicit selection); drives the title. + var forYouDisplayed by remember { mutableStateOf(null) } Scaffold( bottomBar = { // The cast bar rests above the nav menu (iOS tabViewBottomAccessory // placement); the Scaffold then pads tab content past both. Column { PrairieCastMiniBar( - controller = prairieCastController, + controller = siloCastController, onOpenRemote = { navController.navigate(Route.PrairieCastRemote.route) { launchSingleTop = true } }, @@ -253,13 +309,19 @@ fun MainScreen( homeScrollToTopTick += 1 } else { navController.navigate(tab.route) { - popUpTo(Route.Home.route) { saveState = true } - launchSingleTop = true - restoreState = true + // Pop to the tab stack's live anchor, not a + // hard-coded Home and not the graph's declared + // start (which can name a tab that has since + // been removed). Popping to a route that is not + // on the stack pops nothing — every tab then + // stacked, so Back walked back through + // previously visited tabs instead of leaving. + tabSwitchNavOptions(navController.bottomMostTabRoute()) } } }, tabs = visibleTabs, + hazeState = hazeState, ) } }, @@ -277,7 +339,17 @@ fun MainScreen( CompositionLocalProvider( LocalBottomChromeInset provides padding.calculateBottomPadding(), ) { - Box(modifier = Modifier.fillMaxSize()) { + // The tab content is the blur source for both the floating pill and + // the shared top bar. Both effects sit outside this Box (bottomBar, + // and the sibling MainAppTopBar below) — an effect must never live + // inside the source it reads. The background is painted inside the + // source so the capture is opaque. + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + .background(MaterialTheme.colorScheme.background), + ) { when (currentTab) { Tab.Home -> { val homeViewModel = koinViewModel() @@ -293,23 +365,23 @@ fun MainScreen( activeProfile = headerState.activeProfile, onSearchClick = { navController.navigate(Route.Search().route) }, onRemoteControlClick = { - if (prairieCastState.hasActiveSession) { + if (siloCastState.hasActiveSession) { navController.navigate(Route.PrairieCastRemote.route) } else { showPrairieCastTargetPicker = true } }, onRemoteChooseTvClick = { showPrairieCastTargetPicker = true }, - onRemoteDisconnectClick = { prairieCastController.disconnect() }, - isRemoteControlActive = prairieCastState.hasActiveSession, + onRemoteDisconnectClick = { siloCastController.disconnect() }, + isRemoteControlActive = siloCastState.hasActiveSession, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, ) @@ -319,9 +391,6 @@ fun MainScreen( onItemClick = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) }, - onPlayClick = { contentId, resumePositionSeconds -> - playVideo(contentId, resumePositionSeconds = resumePositionSeconds) - }, onCollectionClick = { collectionId, libraryId -> navController.navigate(Route.CollectionDetail(collectionId, libraryId).route) }, @@ -330,13 +399,13 @@ fun MainScreen( onLibrarySelectorClick = { showLibrarySelector = true }, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, ) @@ -346,17 +415,35 @@ fun MainScreen( onItemClick = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) }, + savedListSelection = forYouList, + onSavedListSelectionChange = { forYouList = it }, + onDisplayedListChange = { forYouDisplayed = it }, contentTopPadding = headerContentTop, ) } Tab.Calendar -> { + // Calendar's floating week card is its own header (iOS): + // the shared actions ride inside the card, no title row. CalendarScreen( - onBackClick = { navController.popBackStack() }, onItemClick = { contentId -> navController.navigate(Route.ItemDetail(contentId).route) }, - showTopBar = false, - contentTopPadding = headerContentTop, + headerActions = { + TabTopBarActions( + activeProfile = headerState.activeProfile, + onSearchClick = { navController.navigate(Route.Search().route) }, + onRequestsClick = requestsMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, + onSettingsClick = { navController.navigate(Route.Settings.route) }, + onSwitchProfileClick = { + navController.navigate(Route.ProfileSelection.route) + }, + onSwitchServerClick = { + navController.navigate(Route.ServerList.route) + }, + onSignOutClick = ::signOutFromProfileMenu, + ) + }, ) } Tab.Downloads -> { @@ -392,27 +479,28 @@ fun MainScreen( } } - // Home and Libraries paint their own floating chrome. Calendar, - // Downloads, and For You use the shared iOS-style top chrome. - if (currentTab == Tab.Downloads || currentTab == Tab.ForYou || currentTab == Tab.Calendar) { + // Home, Libraries and Calendar paint their own floating chrome. + // Downloads and For You use the shared iOS-style top chrome. + if (currentTab == Tab.Downloads || currentTab == Tab.ForYou) { val title = when (currentTab) { - Tab.Calendar -> "Calendar" Tab.Downloads -> "Downloads" - Tab.ForYou -> "For You" + // Names what For You is showing: the feed, or a saved list. + Tab.ForYou -> forYouDisplayed.headerTitle() else -> null } MainAppTopBar( activeProfile = headerState.activeProfile, isProfileLoading = headerState.isLoading, + hazeState = hazeState, onSearchClick = { navController.navigate(Route.Search().route) }, onRequestsClick = requestsMenuAction, - onLiveTvClick = liveTvMenuAction, + onWatchTogetherClick = watchTogetherMenuAction, onSettingsClick = { navController.navigate(Route.Settings.route) }, onSwitchProfileClick = { navController.navigate(Route.ProfileSelection.route) }, onSwitchServerClick = { - navController.navigate(Route.ServerList.autoScanRoute(autoScan = false)) + navController.navigate(Route.ServerList.route) }, onSignOutClick = ::signOutFromProfileMenu, leadingContent = { @@ -458,7 +546,18 @@ fun MainScreen( if (showPrairieCastTargetPicker) { PrairieCastTargetPickerSheet( onDismiss = { showPrairieCastTargetPicker = false }, - controller = prairieCastController, + controller = siloCastController, + ) + } + + if (showWatchTogetherEntry) { + WatchTogetherMenuEntrySheet( + onNavigate = { route -> + navController.navigate(route) { + launchSingleTop = true + } + }, + onDismiss = { showWatchTogetherEntry = false }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModel.kt deleted file mode 100644 index f060c9966..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface -import org.prairieserver.prairie.model.auth.isActingAdmin -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.repository.AuthRepository -import org.prairieserver.prairie.repository.ProfileRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * Resolves whether the acting user may see admin surfaces. Client admin is - * disabled for now, so this folds the server-side acting-admin result through - * the shared client policy before exposing [AdminUiState.isAdminVisible]. - * - * The acting-admin decision itself lives in the shared, separately-tested - * [isActingAdmin]; this view model only folds the current user + active - * profile into UI state. The [gateProvider] constructor is the seam the unit - * test drives so the folding can be verified without standing up the (final) - * repositories — production always uses the repo-backed primary constructor. - */ -class AdminEntryViewModel( - private val gateProvider: suspend () -> Boolean, -) : ViewModel() { - - constructor( - authRepository: AuthRepository, - profileRepository: ProfileRepository, - ) : this( - gateProvider = { - val user = (authRepository.getCurrentUser() as? ApiResult.Success)?.data - val profile = profileRepository.getActiveProfile() - isActingAdmin(user, profile) - }, - ) - - data class AdminUiState( - val isLoading: Boolean = true, - val isAdminVisible: Boolean = false, - ) - - private val _uiState = MutableStateFlow(AdminUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { refresh() } - - fun refresh() { - viewModelScope.launch { - val visible = shouldShowClientAdminSurface(gateProvider()) - _uiState.update { it.copy(isLoading = false, isAdminVisible = visible) } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminHubScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminHubScreen.kt deleted file mode 100644 index 48707d824..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminHubScreen.kt +++ /dev/null @@ -1,130 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionCard -import org.koin.compose.viewmodel.koinViewModel - -/** - * Admin hub. Lists the admin sub-sections and routes into each. The acting-admin - * gate is re-evaluated here as defense in depth, so a deep link to this route by - * a non-admin lands on a "not authorized" message rather than the section list. - */ -@Composable -fun AdminHubScreen( - onBackClick: () -> Unit, - onOpenDashboard: () -> Unit, - onOpenUsers: () -> Unit, - onOpenSessions: () -> Unit, - onOpenLogs: () -> Unit, - onOpenScans: () -> Unit, - viewModel: AdminEntryViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - - Scaffold( - topBar = { PrairieTopBar(title = "Admin", onBackClick = onBackClick) }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading -> LoadingIndicator(modifier = Modifier.padding(padding)) - !state.isAdminVisible -> NotAuthorized(modifier = Modifier.padding(padding)) - else -> LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - SettingsSectionCard { - HubRow(Icons.Default.Dashboard, "Dashboard", "Server stats & activity", onOpenDashboard) - HubRow(Icons.Default.People, "Users", "Manage accounts & access", onOpenUsers) - HubRow(Icons.Default.PlayCircle, "Sessions", "Now playing & controls", onOpenSessions) - HubRow(Icons.AutoMirrored.Filled.Article, "Logs", "App & audit logs", onOpenLogs) - HubRow(Icons.Default.Sync, "Scans", "Library scans", onOpenScans) - } - } - } - } - } -} - -@Composable -private fun HubRow( - icon: ImageVector, - title: String, - subtitle: String, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun NotAuthorized(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - "You are not authorized to view this page.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQuery.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQuery.kt deleted file mode 100644 index 901aee529..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQuery.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -/** - * Pure helpers for the admin Logs screen: normalising the filter inputs into a - * stable query map, ranking app-log severity for badge colouring, and rendering - * the audit one-liner. Kept side-effect free so they can be unit tested without - * the Android/Compose toolchain. - * - * NOTE: the landed [org.prairieserver.prairie.repository.AdminRepository.getAppLogs] / - * [org.prairieserver.prairie.repository.AdminRepository.getAuditLogs] take individual - * named parameters rather than a filter map. [buildLogQuery] still produces the - * normalised string map (the shape the spec's tests assert on); the ViewModel - * reads `level`/`q`/`component`/`limit` back out of it when calling the repo, so - * the trim/clamp/sentinel logic lives in exactly one tested place. - */ - -internal const val LOG_LEVEL_ALL = "All" -internal val LOG_LEVELS = listOf(LOG_LEVEL_ALL, "debug", "info", "warn", "error") -internal const val LOG_PAGE_LIMIT = 100 -private const val LOG_SERVER_MAX = 200 - -internal fun buildLogQuery( - level: String?, - query: String?, - component: String?, - limit: Int = LOG_PAGE_LIMIT, -): Map = buildMap { - level?.trim()?.takeIf { it.isNotEmpty() && it != LOG_LEVEL_ALL }?.let { put("level", it) } - query?.trim()?.takeIf { it.isNotEmpty() }?.let { put("q", it) } - component?.trim()?.takeIf { it.isNotEmpty() }?.let { put("component", it) } - put("limit", limit.coerceIn(1, LOG_SERVER_MAX).toString()) -} - -internal fun logLevelRank(level: String): Int = when (level.lowercase()) { - "error", "fatal" -> 4 - "warn", "warning" -> 3 - "info" -> 2 - "debug" -> 1 - else -> 0 -} - -internal fun auditSummaryLine(method: String, path: String, statusCode: Int): String = - "${method.uppercase()} $path → $statusCode" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogsScreen.kt deleted file mode 100644 index c5f9f4eef..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogsScreen.kt +++ /dev/null @@ -1,570 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Article -import androidx.compose.material.icons.outlined.Search -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.model.admin.AdminAuditEntry -import org.prairieserver.prairie.model.admin.AdminLogEntry -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel - -// --------------------------------------------------------------------------- -// ViewModel (co-located, LibrariesViewModel idiom; registered in AndroidModule) -// --------------------------------------------------------------------------- - -enum class AdminLogTab { App, Audit } - -data class AdminLogsUiState( - val tab: AdminLogTab = AdminLogTab.App, - val level: String = LOG_LEVEL_ALL, - val query: String = "", - val component: String = "", - val isLoading: Boolean = true, - val isLoadingMore: Boolean = false, - val appEntries: List = emptyList(), - val auditEntries: List = emptyList(), - val nextCursor: String? = null, - val error: String? = null, -) - -/** - * Owns the admin logs list for both the App and Audit tabs. A first page is a - * replace (cursor = null); near-end scroll appends the next page using the - * server cursor. Filter inputs (level/query/component) are held as draft state - * and only applied on [applyFilters] / [selectTab], which resets the list and - * cursor before refetching. Generation-gated so an applied-filter refetch that - * overlaps an in-flight load can't clobber newer results. - * - * The landed [AdminRepository] takes individual named log parameters rather - * than a filter map, so [fetch] reads the normalised values back out of - * [buildLogQuery] — keeping the trim/clamp/sentinel rules in one tested place. - */ -class AdminLogsViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(AdminLogsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun selectTab(tab: AdminLogTab) { - if (tab == _uiState.value.tab) return - _uiState.update { - it.copy( - tab = tab, - appEntries = emptyList(), - auditEntries = emptyList(), - nextCursor = null, - ) - } - load() - } - - fun onLevelChange(level: String) = _uiState.update { it.copy(level = level) } - fun onQueryChange(query: String) = _uiState.update { it.copy(query = query) } - fun onComponentChange(component: String) = _uiState.update { it.copy(component = component) } - - fun applyFilters() { - _uiState.update { - it.copy(appEntries = emptyList(), auditEntries = emptyList(), nextCursor = null) - } - load() - } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation, cursor = null) - } - } - - fun loadMore() { - val state = _uiState.value - val cursor = state.nextCursor ?: return - if (state.isLoading || state.isLoadingMore) return - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoadingMore = true) } - fetch(generation, cursor = cursor) - } - } - - private suspend fun fetch(generation: Int, cursor: String?) { - val s = _uiState.value - val q = buildLogQuery(level = s.level, query = s.query, component = s.component) - val level = q["level"] - val query = q["q"] - val component = q["component"] - val limit = q["limit"]?.toIntOrNull() ?: LOG_PAGE_LIMIT - - when (s.tab) { - AdminLogTab.App -> { - val result = repository.getAppLogs( - level = level, - component = component, - query = query, - cursor = cursor, - limit = limit, - ) - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - error = null, - appEntries = if (cursor == null) { - result.data.entries - } else { - it.appEntries + result.data.entries - }, - nextCursor = result.data.nextCursor, - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - error = result.errorMessage("Failed to load logs"), - ) - } - } - } - AdminLogTab.Audit -> { - // Audit endpoint has no free-text/level/component filter; only - // the cursor + limit carry over from the shared query builder. - val result = repository.getAuditLogs( - cursor = cursor, - limit = limit, - ) - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - error = null, - auditEntries = if (cursor == null) { - result.data.entries - } else { - it.auditEntries + result.data.entries - }, - nextCursor = result.data.nextCursor, - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - error = result.errorMessage("Failed to load audit logs"), - ) - } - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Screen -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdminLogsScreen( - onBackClick: () -> Unit, - viewModel: AdminLogsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - - val listState = rememberLazyListState() - val itemCount = when (state.tab) { - AdminLogTab.App -> state.appEntries.size - AdminLogTab.Audit -> state.auditEntries.size - } - val shouldLoadMore by remember { - derivedStateOf { - val layout = listState.layoutInfo - val lastVisible = layout.visibleItemsInfo.lastOrNull()?.index ?: -1 - val total = layout.totalItemsCount - total > 0 && lastVisible >= total - 4 - } - } - LaunchedEffect(shouldLoadMore, state.nextCursor, itemCount) { - if (shouldLoadMore && state.nextCursor != null && !state.isLoadingMore && !state.isLoading) { - viewModel.loadMore() - } - } - - Scaffold( - topBar = { PrairieTopBar(title = "Logs", onBackClick = onBackClick) }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - PrimaryTabRow(selectedTabIndex = state.tab.ordinal) { - Tab( - selected = state.tab == AdminLogTab.App, - onClick = { viewModel.selectTab(AdminLogTab.App) }, - text = { Text("App") }, - ) - Tab( - selected = state.tab == AdminLogTab.Audit, - onClick = { viewModel.selectTab(AdminLogTab.Audit) }, - text = { Text("Audit") }, - ) - } - - if (state.tab == AdminLogTab.App) { - AppLogFilters( - level = state.level, - query = state.query, - component = state.component, - onLevelChange = viewModel::onLevelChange, - onQueryChange = viewModel::onQueryChange, - onComponentChange = viewModel::onComponentChange, - onApply = viewModel::applyFilters, - ) - } - - Box(modifier = Modifier.fillMaxSize()) { - when { - state.isLoading && itemCount == 0 -> LoadingIndicator() - - state.error != null && itemCount == 0 -> - ErrorView(state.error!!, onRetry = viewModel::load) - - itemCount == 0 -> EmptyStateView( - title = "No log entries", - subtitle = "Nothing matches the current filters.", - icon = Icons.Outlined.Article, - ) - - else -> LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - when (state.tab) { - AdminLogTab.App -> items( - state.appEntries, - key = { it.id }, - ) { entry -> AppLogRow(entry) } - - AdminLogTab.Audit -> items( - state.auditEntries, - key = { it.id }, - ) { entry -> AuditLogRow(entry) } - } - if (state.isLoadingMore) { - item(key = "loading-more") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - item { Spacer(Modifier.height(16.dp)) } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AppLogFilters( - level: String, - query: String, - component: String, - onLevelChange: (String) -> Unit, - onQueryChange: (String) -> Unit, - onComponentChange: (String) -> Unit, - onApply: () -> Unit, -) { - var levelMenuExpanded by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box { - TextButton(onClick = { levelMenuExpanded = true }) { - Text("Level: $level") - } - DropdownMenu( - expanded = levelMenuExpanded, - onDismissRequest = { levelMenuExpanded = false }, - ) { - LOG_LEVELS.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - levelMenuExpanded = false - onLevelChange(option) - onApply() - }, - ) - } - } - } - OutlinedTextField( - value = component, - onValueChange = onComponentChange, - label = { Text("Component") }, - singleLine = true, - modifier = Modifier.weight(1f), - keyboardActions = androidx.compose.foundation.text.KeyboardActions( - onSearch = { onApply() }, - ), - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( - imeAction = ImeAction.Search, - ), - ) - } - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - label = { Text("Search") }, - singleLine = true, - leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, - modifier = Modifier.fillMaxWidth(), - keyboardActions = androidx.compose.foundation.text.KeyboardActions( - onSearch = { onApply() }, - ), - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( - imeAction = ImeAction.Search, - ), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AppLogRow(entry: AdminLogEntry) { - var expanded by remember { mutableStateOf(false) } - - Surface( - onClick = { expanded = !expanded }, - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - LevelBadge(entry.level) - Text( - text = entry.component, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = entry.timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = entry.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (expanded) Int.MAX_VALUE else 2, - overflow = TextOverflow.Ellipsis, - ) - AnimatedVisibility(visible = expanded) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - entry.requestId?.let { DetailLine("request", it) } - entry.clientIp?.let { DetailLine("ip", it) } - entry.userId?.let { DetailLine("user", it.toString()) } - entry.sessionId?.let { DetailLine("session", it) } - entry.nodeId?.let { DetailLine("node", it) } - // attrs values are kotlinx JsonElement, whose type isn't on - // the androidApp classpath; surface the present keys only. - entry.attrs?.takeIf { it.isNotEmpty() }?.let { attrs -> - DetailLine("attrs", attrs.keys.joinToString(", ")) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AuditLogRow(entry: AdminAuditEntry) { - var expanded by remember { mutableStateOf(false) } - - Surface( - onClick = { expanded = !expanded }, - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = auditSummaryLine(entry.method, entry.path, entry.statusCode), - style = MaterialTheme.typography.bodyMedium, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (expanded) Int.MAX_VALUE else 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Text( - text = "${entry.durationMs}ms", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = entry.timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - AnimatedVisibility(visible = expanded) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - entry.requestId?.let { DetailLine("request", it) } - DetailLine("ip", entry.clientIp) - entry.userId?.let { DetailLine("user", it.toString()) } - entry.impersonatorUserId?.let { DetailLine("impersonator", it.toString()) } - entry.sessionId?.let { DetailLine("session", it) } - entry.userAgent?.let { DetailLine("agent", it) } - } - } - } - } -} - -@Composable -private fun LevelBadge(level: String) { - val color = when (logLevelRank(level)) { - 4 -> MaterialTheme.colorScheme.error - 3 -> MaterialTheme.colorScheme.tertiary - 2 -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - Surface( - shape = RoundedCornerShape(4.dp), - color = color.copy(alpha = 0.18f), - ) { - Text( - text = level.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = color, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - ) - } -} - -@Composable -private fun DetailLine(label: String, value: String) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = "$label:", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminScansScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminScansScreen.kt deleted file mode 100644 index 9008a7855..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminScansScreen.kt +++ /dev/null @@ -1,353 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.personal.UserLibrary -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import org.prairieserver.prairie.repository.PersonalDataRepository -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Radar - -// --------------------------------------------------------------------------- -// ViewModel (co-located, LibrariesViewModel idiom; registered in AndroidModule) -// --------------------------------------------------------------------------- - -data class AdminScansUiState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val libraries: List = emptyList(), - /** Library IDs with an in-flight scan or cancel request. */ - val busyLibraryIds: Set = emptySet(), - /** True while a scan-all request is in flight. */ - val scanningAll: Boolean = false, - val error: String? = null, -) - -/** - * Owns the library list and per-library scan/cancel actions. Busy-set tracking - * disables individual row buttons while a request is in flight. Scan-all - * disables the top-bar action while running. One-shot results surface via - * [toasts] then trigger a refresh. - */ -class AdminScansViewModel( - private val adminRepository: AdminRepository, - private val personalDataRepository: PersonalDataRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(AdminScansUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _toasts = MutableSharedFlow(extraBufferCapacity = 4) - val toasts: SharedFlow = _toasts.asSharedFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation) - } - } - - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetch(generation) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } - } - - fun scanLibrary(id: Int) { - viewModelScope.launch { - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds + id) } - when (val result = adminRepository.triggerScan(ScanRequest(libraryId = id))) { - is ApiResult.Success -> { - _toasts.emit("Scan started") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to start scan")) - } - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds - id) } - } - } - - fun cancelLibrary(id: Int) { - viewModelScope.launch { - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds + id) } - when (val result = adminRepository.cancelScan(ScanCancelRequest(libraryId = id))) { - is ApiResult.Success -> { - _toasts.emit("Scan cancelled") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to cancel scan")) - } - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds - id) } - } - } - - fun scanAll() { - viewModelScope.launch { - _uiState.update { it.copy(scanningAll = true) } - when (val result = adminRepository.triggerScan(ScanRequest())) { - is ApiResult.Success -> { - _toasts.emit("Scanning…") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to start scan")) - } - _uiState.update { it.copy(scanningAll = false) } - } - } - - private suspend fun fetch(generation: Int) { - val result = personalDataRepository.listUserLibraries() - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoading = false, - libraries = result.data.sortedBy { lib -> lib.sortOrder }, - error = null, - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load libraries")) - } - } - } -} - -// --------------------------------------------------------------------------- -// Screen -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdminScansScreen( - onBackClick: () -> Unit, - viewModel: AdminScansViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(Unit) { - viewModel.toasts.collect { message -> - snackbarHostState.showSnackbar(message) - } - } - - Scaffold( - topBar = { - PrairieTopBar( - title = "Scans", - onBackClick = onBackClick, - actions = { - IconButton( - onClick = viewModel::scanAll, - enabled = !state.scanningAll, - ) { - if (state.scanningAll) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Scan all libraries", - ) - } - } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.libraries.isEmpty() -> - LoadingIndicator(modifier = Modifier.padding(padding)) - - state.error != null && state.libraries.isEmpty() -> - ErrorView( - message = state.error!!, - onRetry = viewModel::load, - modifier = Modifier.padding(padding), - ) - - else -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - if (state.libraries.isEmpty()) { - EmptyStateView( - title = "No libraries", - modifier = Modifier.fillMaxSize(), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.libraries, key = { it.id }) { library -> - LibraryScanRow( - library = library, - isBusy = library.id in state.busyLibraryIds, - onScan = { viewModel.scanLibrary(library.id) }, - onCancel = { viewModel.cancelLibrary(library.id) }, - ) - } - item { Spacer(Modifier.height(24.dp)) } - } - } - } - } - } -} - -@Composable -private fun LibraryScanRow( - library: UserLibrary, - isBusy: Boolean, - onScan: () -> Unit, - onCancel: () -> Unit, -) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - ), - shape = MaterialTheme.shapes.medium, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Outlined.Folder, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = library.name, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = library.type.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (isBusy) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = onScan, - enabled = !isBusy, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Radar, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Scan") - } - OutlinedButton( - onClick = onCancel, - enabled = !isBusy, - modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") - } - } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormatters.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormatters.kt deleted file mode 100644 index 47d220dcd..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormatters.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import org.prairieserver.prairie.android.ui.util.formatClockTime -import org.prairieserver.prairie.model.admin.AdminSession - -/** - * Pure, UI-independent formatters for the admin sessions surface. Kept separate - * from the composable so they can be unit-tested without Robolectric/Compose. - * - * The summary/resolution/bitrate helpers take primitive inputs (not an - * [AdminSession]) so the contract is stable regardless of how the landed model - * exposes its transcode detail; the [SessionRow] composable maps the real - * fields (Kbps bitrates, "WxH" resolution strings, source/target codecs) onto - * these helpers. - */ - -internal fun playMethodLabel(playMethod: String): String = when (playMethod.lowercase()) { - "directplay", "direct_play", "direct play" -> "Direct Play" - "directstream", "direct_stream", "direct stream" -> "Direct Stream" - "transcode" -> "Transcode" - else -> playMethod.ifBlank { "Playing" } -} - -internal fun bitrateLabel(bitrateBps: Long?): String? { - val bps = bitrateBps ?: return null - if (bps <= 0) return null - return if (bps >= 1_000_000) "%.1f Mbps".format(bps / 1_000_000.0) else "${bps / 1000} Kbps" -} - -internal fun resolutionLabel(width: Int?, height: Int?): String? { - val h = height ?: return null - if (h <= 0) return null - return when { - h >= 2000 -> "4K" - h >= 1080 -> "1080p" - h >= 720 -> "720p" - h <= 480 -> "480p" - else -> "${h}p" - } -} - -internal fun sessionSummaryLine( - isTranscoding: Boolean, - playMethod: String, - bitrateBps: Long?, - widthTarget: Int?, - heightTarget: Int?, - videoCodecSource: String?, - videoCodecTarget: String?, -): String { - val head = if (isTranscoding) { - val src = videoCodecSource?.takeIf { it.isNotBlank() } - val dst = videoCodecTarget?.takeIf { it.isNotBlank() } - if (src != null && dst != null && src != dst) "${playMethodLabel(playMethod)} $src→$dst" - else playMethodLabel(playMethod) - } else { - playMethodLabel(playMethod) - } - val parts = listOfNotNull(head, bitrateLabel(bitrateBps), resolutionLabel(widthTarget, heightTarget)) - return parts.joinToString(" • ") -} - -internal fun sessionProgressLabel(positionSeconds: Double, durationSeconds: Double): String { - val pos = formatClockTime(positionSeconds) - if (durationSeconds <= 0.0 || durationSeconds.isNaN()) return pos - return "$pos / ${formatClockTime(durationSeconds)}" -} - -internal fun seasonEpisodeLabel(season: Int?, episode: Int?): String? = - if (season != null && episode != null) "S${season}E$episode" else null - -// --------------------------------------------------------------------------- -// AdminSession adapters — bridge the landed model onto the pure helpers above. -// --------------------------------------------------------------------------- - -/** - * Whether the session is transcoding video and/or audio. The landed model has - * no boolean flag; it carries per-stream decisions and a transcode-audio flag. - */ -internal fun AdminSession.isTranscoding(): Boolean = - videoDecision.equals("transcode", ignoreCase = true) || - audioDecision.equals("transcode", ignoreCase = true) || - transcodeAudio || - playMethod.replace("_", "").replace(" ", "").equals("transcode", ignoreCase = true) - -/** Best-available stream bitrate in bps: target > stream > source (model is Kbps). */ -internal fun AdminSession.effectiveBitrateBps(): Long? = - (targetBitrateKbps ?: streamBitrateKbps ?: sourceBitrateKbps) - ?.takeIf { it > 0 } - ?.toLong() - ?.times(1000L) - -/** Parses a "WIDTHxHEIGHT" resolution string into a height in pixels, if present. */ -internal fun parseResolutionHeight(resolution: String): Int? { - if (resolution.isBlank()) return null - val parts = resolution.lowercase().split("x") - if (parts.size != 2) return null - return parts[1].trim().toIntOrNull()?.takeIf { it > 0 } -} - -internal fun parseResolutionWidth(resolution: String): Int? { - if (resolution.isBlank()) return null - val parts = resolution.lowercase().split("x") - if (parts.size != 2) return null - return parts[0].trim().toIntOrNull()?.takeIf { it > 0 } -} - -/** Builds the one-line transcode/play summary from the landed [AdminSession]. */ -internal fun AdminSession.summaryLine(): String { - val transcoding = isTranscoding() - val resolution = targetResolution.ifBlank { sourceVideoResolution } - return sessionSummaryLine( - isTranscoding = transcoding, - playMethod = playMethod, - bitrateBps = effectiveBitrateBps(), - widthTarget = parseResolutionWidth(resolution), - heightTarget = parseResolutionHeight(resolution), - videoCodecSource = sourceVideoCodec, - videoCodecTarget = targetVideoCodec.ifBlank { sourceVideoCodec }, - ) -} - -/** Position-of-duration label (duration from the landed `file_duration` seconds). */ -internal fun AdminSession.progressLabel(): String = - sessionProgressLabel(positionSeconds, (fileDuration ?: 0).toDouble()) - -/** "SxEy" line for episodes; null for movies / missing numbering. */ -internal fun AdminSession.seasonEpisode(): String? = - seasonEpisodeLabel(seasonNumber, episodeNumber) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionsScreen.kt deleted file mode 100644 index 28b833ad0..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionsScreen.kt +++ /dev/null @@ -1,450 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.Cast -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Send - -// --------------------------------------------------------------------------- -// ViewModel (co-located, LibrariesViewModel idiom; registered in AndroidModule) -// --------------------------------------------------------------------------- - -data class AdminSessionsUiState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val sessions: List = emptyList(), - val error: String? = null, -) - -/** - * Owns the live admin sessions list and the per-session playback-control - * actions (pause/resume/stop/terminate/message). Generation-gated fetches so a - * pull-to-refresh that overlaps an in-flight load can't clobber newer data; - * one-shot control results surface via [toasts] then trigger a refresh. - */ -class AdminSessionsViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(AdminSessionsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _toasts = MutableSharedFlow(extraBufferCapacity = 4) - val toasts: SharedFlow = _toasts.asSharedFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation) - } - } - - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetch(generation) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } - } - - fun control( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest = SessionControlRequest(), - ) { - viewModelScope.launch { - when (val result = repository.sessionControl(sessionId, action, request)) { - is ApiResult.Success -> { - _toasts.emit(controlSuccessMessage(action)) - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to ${action.wire} session")) - } - } - } - - private suspend fun fetch(generation: Int) { - val result = repository.getSessions() - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoading = false, sessions = result.data, error = null) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load sessions")) - } - } - } - - private fun controlSuccessMessage(action: SessionControlAction): String = when (action) { - SessionControlAction.Pause -> "Session paused" - SessionControlAction.Resume -> "Session resumed" - SessionControlAction.Stop -> "Session stopped" - SessionControlAction.Terminate -> "Session terminated" - SessionControlAction.Message -> "Message sent" - } -} - -// --------------------------------------------------------------------------- -// Screen -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdminSessionsScreen( - onBackClick: () -> Unit, - viewModel: AdminSessionsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - var messageTarget by remember { mutableStateOf(null) } - var terminateTarget by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - viewModel.toasts.collect { snackbarHostState.showSnackbar(it) } - } - - Scaffold( - topBar = { PrairieTopBar(title = "Sessions", onBackClick = onBackClick) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.sessions.isEmpty() -> - LoadingIndicator(Modifier.padding(padding)) - - state.error != null && state.sessions.isEmpty() -> - ErrorView(state.error!!, onRetry = viewModel::load, modifier = Modifier.padding(padding)) - - state.sessions.isEmpty() -> EmptyStateView( - title = "No active sessions", - subtitle = "Streams in progress will appear here.", - icon = Icons.Outlined.Cast, - modifier = Modifier.padding(padding), - ) - - else -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(state.sessions, key = { it.sessionId }) { session -> - SessionRow( - session = session, - onPause = { - viewModel.control(session.sessionId, SessionControlAction.Pause) - }, - onResume = { - viewModel.control(session.sessionId, SessionControlAction.Resume) - }, - onStop = { - viewModel.control(session.sessionId, SessionControlAction.Stop) - }, - onMessage = { messageTarget = session }, - onTerminate = { terminateTarget = session }, - ) - } - item { Spacer(Modifier.height(24.dp)) } - } - } - } - } - - messageTarget?.let { session -> - MessageDialog( - session = session, - onDismiss = { messageTarget = null }, - onSend = { title, body -> - viewModel.control( - session.sessionId, - SessionControlAction.Message, - SessionControlRequest( - title = title.ifBlank { null }, - message = body, - ), - ) - messageTarget = null - }, - ) - } - - terminateTarget?.let { session -> - AlertDialog( - onDismissRequest = { terminateTarget = null }, - title = { Text("Terminate session?") }, - text = { - Text("This forcibly ends ${session.username}'s stream of ${session.mediaTitle}.") - }, - confirmButton = { - TextButton(onClick = { - viewModel.control(session.sessionId, SessionControlAction.Terminate) - terminateTarget = null - }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Terminate", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { terminateTarget = null }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SessionRow( - session: AdminSession, - onPause: () -> Unit, - onResume: () -> Unit, - onStop: () -> Unit, - onMessage: () -> Unit, - onTerminate: () -> Unit, -) { - var menuExpanded by remember { mutableStateOf(false) } - - Surface( - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.Top, - ) { - ThumbhashImage( - url = session.posterUrl.ifBlank { null }, - thumbhash = null, - contentDescription = session.mediaTitle, - modifier = Modifier - .size(width = 54.dp, height = 80.dp) - .clip(RoundedCornerShape(6.dp)), - ) - Spacer(Modifier.size(12.dp)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = session.mediaTitle, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - session.seasonEpisode()?.let { se -> - val episodeSuffix = session.episodeName.takeIf { it.isNotBlank() } - Text( - text = if (episodeSuffix != null) "$se · $episodeSuffix" else se, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = session.username, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = listOfNotNull( - if (session.isPaused) "Paused" else "Playing", - session.progressLabel(), - ).joinToString(" • "), - style = MaterialTheme.typography.labelMedium, - color = if (session.isPaused) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.primary - }, - ) - Text( - text = session.summaryLine(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (session.hasPlaybackControl) { - Box { - IconButton(onClick = { menuExpanded = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "Session actions") - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - if (session.isPaused) { - DropdownMenuItem( - text = { Text("Resume") }, - onClick = { menuExpanded = false; onResume() }, - ) - } else { - DropdownMenuItem( - text = { Text("Pause") }, - onClick = { menuExpanded = false; onPause() }, - ) - } - DropdownMenuItem( - text = { Text("Stop") }, - onClick = { menuExpanded = false; onStop() }, - ) - DropdownMenuItem( - text = { Text("Send message") }, - onClick = { menuExpanded = false; onMessage() }, - ) - DropdownMenuItem( - text = { Text("Terminate", color = MaterialTheme.colorScheme.error) }, - onClick = { menuExpanded = false; onTerminate() }, - ) - } - } - } - } - } -} - -@Composable -private fun MessageDialog( - session: AdminSession, - onDismiss: () -> Unit, - onSend: (title: String, body: String) -> Unit, -) { - var title by remember { mutableStateOf("") } - var body by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Message ${session.username}") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("Title (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = body, - onValueChange = { body = it }, - label = { Text("Message") }, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { onSend(title, body) }, - enabled = body.isNotBlank(), - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Send") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } - }, - ) -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminStatsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminStatsScreen.kt deleted file mode 100644 index 2ab9a1b6b..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminStatsScreen.kt +++ /dev/null @@ -1,179 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.Tv -import androidx.compose.material.icons.filled.VideoLibrary -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.android.ui.theme.PrairieError -import org.prairieserver.prairie.android.ui.theme.PrairiePrimary -import org.prairieserver.prairie.android.ui.theme.PrairieSecondaryText -import org.prairieserver.prairie.android.ui.theme.PrairieSuccess -import org.prairieserver.prairie.android.ui.theme.PrairieSurface -import org.prairieserver.prairie.android.ui.theme.PrairieWarning -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.viewmodel.AdminStatsViewModel -import org.koin.compose.viewmodel.koinViewModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdminStatsScreen( - onBackClick: () -> Unit, - viewModel: AdminStatsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - - // A refresh that fails while stats are already on screen otherwise just - // stops the spinner silently (ErrorView only shows for the empty state). - LaunchedEffect(state.error, state.stats) { - val message = state.error - if (message != null && state.stats != null) { - snackbarHostState.showSnackbar(message) - } - } - - Scaffold( - topBar = { PrairieTopBar(title = "Admin", onBackClick = onBackClick) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.stats == null -> LoadingIndicator(modifier = Modifier.padding(padding)) - state.error != null && state.stats == null -> - ErrorView( - message = state.error!!, - onRetry = viewModel::load, - modifier = Modifier.padding(padding), - ) - else -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - state.stats?.let { stats -> - StatsGrid(stats) - } - } - } - } -} - -private data class StatTile( - val title: String, - val value: String, - val icon: ImageVector, - val color: Color, -) - -@Composable -private fun StatsGrid(stats: AdminStats) { - // Mirrors iOS AdminDashboardView.statsContent: a 2-column LazyVGrid with 12pt - // spacing and 16pt content padding, six stat cards in this exact order. - val tiles = listOf( - StatTile("Total Items", stats.totalItems.toString(), Icons.Filled.VideoLibrary, PrairiePrimary), - StatTile("Users", stats.totalUsers.toString(), Icons.Filled.People, PrairieSuccess), - StatTile("Movies", stats.totalMovies.toString(), Icons.Filled.Movie, PrairieWarning), - StatTile("TV Shows", stats.totalShows.toString(), Icons.Filled.Tv, PrairiePrimary), - StatTile("Active Streams", stats.activeStreams.toString(), Icons.Filled.PlayCircle, PrairieError), - StatTile("Storage", formatStorageBytes(stats.totalStorageBytes), Icons.Filled.Storage, PrairieSecondaryText), - ) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(tiles) { tile -> - StatCard(tile) - } - } -} - -@Composable -private fun StatCard(tile: StatTile) { - // Mirrors iOS statCard: VStack(leading, spacing 12) with the icon top-left, - // the value in prairieTitle, the label in prairieCaption, 16pt padding, - // an 8pt rounded prairieSurface background. - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = PrairieSurface, - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(Modifier.fillMaxWidth()) { - Icon( - imageVector = tile.icon, - contentDescription = null, - tint = tile.color, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.weight(1f)) - } - Text( - text = tile.value, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = tile.title, - style = MaterialTheme.typography.bodySmall, - color = PrairieSecondaryText, - ) - } - } -} - -// Mirrors iOS AdminDashboardView.formatBytes exactly: 1.1f TB / 1.1f GB / .0f MB. -private fun formatStorageBytes(bytes: Long): String { - val tb = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0 * 1024.0) - if (tb >= 1.0) return "%.1f TB".format(tb) - val gb = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) - if (gb >= 1.0) return "%.1f GB".format(gb) - val mb = bytes.toDouble() / (1024.0 * 1024.0) - return "%.0f MB".format(mb) -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUserEditScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUserEditScreen.kt deleted file mode 100644 index f48d144af..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUserEditScreen.kt +++ /dev/null @@ -1,219 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.foundation.text.KeyboardOptions -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.viewmodel.ADMIN_USER_ROLES -import org.prairieserver.prairie.viewmodel.AdminUserEditViewModel -import org.prairieserver.prairie.viewmodel.roleDisplayName -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon - -/** - * Create (userId == null) / edit form for an admin user. On create, username / - * email / password are required; on edit they are read-only except for an - * optional password reset. Role, enabled, library access and playback quotas - * are editable in both modes. Submits via [AdminUserEditViewModel], which pops - * back through [onSaved] on success. - */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun AdminUserEditScreen( - userId: Int?, - onBackClick: () -> Unit, - onSaved: () -> Unit, - viewModel: AdminUserEditViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - - LaunchedEffect(userId) { viewModel.load(userId) } - - LaunchedEffect(state.saveSuccess) { - if (state.saveSuccess) onSaved() - } - - val isEdit = state.isEditMode - - Scaffold( - topBar = { - PrairieTopBar( - title = if (isEdit) "Edit user" else "Create user", - onBackClick = onBackClick, - ) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - if (state.isLoading) { - LoadingIndicator(Modifier.padding(padding)) - return@Scaffold - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .imePadding() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChange, - label = { Text("Username") }, - singleLine = true, - enabled = !isEdit, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChange, - label = { Text("Email") }, - singleLine = true, - enabled = !isEdit, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChange, - label = { Text(if (isEdit) "Reset password (optional)" else "Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - modifier = Modifier.fillMaxWidth(), - ) - - Text("Role", style = MaterialTheme.typography.labelLarge) - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ADMIN_USER_ROLES.forEach { role -> - FilterChip( - selected = state.role == role, - onClick = { viewModel.onRoleChange(role) }, - label = { Text(roleDisplayName(role)) }, - ) - } - } - - SwitchRow( - label = "Enabled", - checked = state.enabled, - onChange = viewModel::onEnabledChange, - ) - - OutlinedTextField( - value = state.libraryIdsText, - onValueChange = viewModel::onLibraryIdsChange, - label = { Text("Library access (comma-separated ids)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - QuotaField("Max streams", state.maxStreamsText, viewModel::onMaxStreamsChange) - QuotaField("Max transcodes", state.maxTranscodesText, viewModel::onMaxTranscodesChange) - QuotaField("Max profiles", state.maxProfilesText, viewModel::onMaxProfilesChange) - - SwitchRow( - label = "Downloads allowed", - checked = state.downloadAllowed, - onChange = viewModel::onDownloadAllowedChange, - ) - SwitchRow( - label = "Download transcode allowed", - checked = state.downloadTranscodeAllowed, - onChange = viewModel::onDownloadTranscodeAllowedChange, - ) - - state.error?.let { - Text( - it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - - Button( - onClick = viewModel::submit, - enabled = !state.isSaving, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isSaving) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } else { - Icon( - imageVector = if (isEdit) Icons.Default.Save else Icons.Default.PersonAdd, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (isEdit) "Save changes" else "Create user") - } - } - Spacer(Modifier.height(24.dp)) - } - } -} - -@Composable -private fun QuotaField(label: String, value: String, onChange: (String) -> Unit) { - OutlinedTextField( - value = value, - onValueChange = onChange, - label = { Text("$label (blank = unlimited)") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) -} - -@Composable -private fun SwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(label, style = MaterialTheme.typography.bodyLarge) - Switch(checked = checked, onCheckedChange = onChange) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUsersScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUsersScreen.kt deleted file mode 100644 index 85d83f6cd..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminUsersScreen.kt +++ /dev/null @@ -1,235 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.outlined.People -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.android.ui.components.EmptyStateView -import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.viewmodel.AdminUsersViewModel -import org.prairieserver.prairie.viewmodel.roleDisplayName -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Delete - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdminUsersScreen( - onBackClick: () -> Unit, - onCreateUser: () -> Unit, - onEditUser: (Int) -> Unit, - viewModel: AdminUsersViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - var pendingDelete by remember { mutableStateOf(null) } - - // Reload when re-entering after a create/edit so changes are reflected. - LaunchedEffect(Unit) { viewModel.refresh() } - - LaunchedEffect(state.message) { - state.message?.let { - snackbarHostState.showSnackbar(it) - viewModel.consumeMessage() - } - } - - Scaffold( - topBar = { PrairieTopBar(title = "Users", onBackClick = onBackClick) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - floatingActionButton = { - FloatingActionButton(onClick = onCreateUser) { - Icon(Icons.Default.Add, contentDescription = "Add user") - } - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when { - state.isLoading && state.users.isEmpty() -> LoadingIndicator(Modifier.padding(padding)) - - state.error != null && state.users.isEmpty() -> - ErrorView(state.error!!, onRetry = viewModel::load, modifier = Modifier.padding(padding)) - - state.users.isEmpty() -> EmptyStateView( - title = "No users", - subtitle = "Tap + to create one.", - icon = Icons.Outlined.People, - modifier = Modifier.padding(padding), - ) - - else -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = viewModel::refresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(state.users, key = { it.id }) { user -> - UserRow( - user = user, - onClick = { onEditUser(user.id) }, - onDelete = { pendingDelete = user }, - ) - } - item { Spacer(Modifier.height(72.dp)) } - } - } - } - } - - pendingDelete?.let { user -> - AlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text("Delete ${user.username}?") }, - text = { Text("This permanently removes the account. This cannot be undone.") }, - confirmButton = { - TextButton(onClick = { - viewModel.deleteUser(user.id) - pendingDelete = null - }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun UserRow( - user: AdminUser, - onClick: () -> Unit, - onDelete: () -> Unit, -) { - Surface( - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - user.username, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - user.email, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - user.lastActiveAt?.let { - Text( - "Last active $it", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - roleDisplayName(user.role), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = if (user.role == "admin") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - Text( - if (user.enabled) "Enabled" else "Disabled", - style = MaterialTheme.typography.labelSmall, - color = if (user.enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - TextButton( - onClick = onDelete, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - "Delete", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookPlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookPlayerScreen.kt index f242968c3..305dd73fa 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookPlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/audiobook/AudiobookPlayerScreen.kt @@ -169,6 +169,10 @@ fun AudiobookPlayerScreen( override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { viewModel.onPauseStateChanged(!playWhenReady) } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + viewModel.onPlayerError(error) + } } c.addListener(listener) onDispose { runCatching { c.removeListener(listener) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/AuthComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/AuthComponents.kt index ad73898ce..fd49f827b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/AuthComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/AuthComponents.kt @@ -15,14 +15,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults @@ -30,7 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,14 +35,13 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.components.siloKeyboardActions import org.prairieserver.prairie.android.R -/** Prairie brand colors used across auth screens. */ +/** Silo brand colors used across auth screens. */ object AuthColors { val Background = Color(0xFF000000) val Surface = Color(0xFF111214) @@ -147,7 +140,7 @@ fun PrairieTextField( error: String? = null, keyboardType: KeyboardType = KeyboardType.Text, imeAction: ImeAction = ImeAction.Next, - onImeAction: () -> Unit = {}, + onImeAction: (() -> Unit)? = null, singleLine: Boolean = true, ) { Column(modifier = modifier.fillMaxWidth()) { @@ -161,96 +154,7 @@ fun PrairieTextField( keyboardType = keyboardType, imeAction = imeAction, ), - keyboardActions = KeyboardActions( - onAny = { onImeAction() }, - ), - shape = RoundedCornerShape(18.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = AuthColors.OnSurface, - unfocusedTextColor = AuthColors.OnSurface, - focusedBorderColor = AuthColors.FieldBorderFocused, - unfocusedBorderColor = AuthColors.FieldBorder, - errorBorderColor = AuthColors.Error, - focusedLabelColor = AuthColors.Primary, - unfocusedLabelColor = AuthColors.OnSurfaceVariant, - errorLabelColor = AuthColors.Error, - cursorColor = AuthColors.Primary, - focusedContainerColor = AuthColors.Surface, - unfocusedContainerColor = AuthColors.Surface, - errorContainerColor = AuthColors.Surface, - focusedPlaceholderColor = AuthColors.OnSurfaceVariant, - unfocusedPlaceholderColor = AuthColors.OnSurfaceVariant, - ), - modifier = Modifier.fillMaxWidth(), - ) - if (error != null) { - Text( - text = error, - color = AuthColors.Error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 16.dp, top = 4.dp), - ) - } - } -} - -/** - * Password text field with a visibility toggle icon button. - * - * @param value Current password text. - * @param onValueChange Callback when text changes. - * @param label Label displayed above and inside the field. - * @param error Optional error message shown below the field. - * @param imeAction IME action button (default Done). - * @param onImeAction Callback invoked when the IME action fires. - * @param modifier Modifier applied to the outer Column. - */ -@Composable -fun PrairiePasswordField( - value: String, - onValueChange: (String) -> Unit, - label: String, - modifier: Modifier = Modifier, - error: String? = null, - imeAction: ImeAction = ImeAction.Done, - onImeAction: () -> Unit = {}, -) { - var passwordVisible by rememberSaveable { mutableStateOf(false) } - - Column(modifier = modifier.fillMaxWidth()) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - label = { Text(label) }, - isError = error != null, - singleLine = true, - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = imeAction, - ), - keyboardActions = KeyboardActions( - onAny = { onImeAction() }, - ), - trailingIcon = { - val icon = if (passwordVisible) { - Icons.Filled.VisibilityOff - } else { - Icons.Filled.Visibility - } - val description = if (passwordVisible) "Hide password" else "Show password" - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = icon, - contentDescription = description, - tint = AuthColors.OnSurfaceVariant, - ) - } - }, + keyboardActions = siloKeyboardActions(onImeAction), shape = RoundedCornerShape(18.dp), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = AuthColors.OnSurface, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt index 009c5dd23..7b4fda0ef 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt @@ -149,7 +149,7 @@ fun DevicePairingScreen( } OutlinedButton( onClick = viewModel::deny, - enabled = state.canSubmit, + enabled = state.canDecide, modifier = Modifier.weight(1f), ) { Icon(Icons.Default.Close, contentDescription = null) @@ -161,7 +161,7 @@ fun DevicePairingScreen( Button( onClick = viewModel::approve, - enabled = state.canSubmit, + enabled = state.canDecide, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), ) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt new file mode 100644 index 000000000..a59ab1264 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingWrongServerScreen.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Shown when a pairing link belongs to a server the user has, but is not + * currently using. + * + * The alternative — looking the code up against whichever server is active — + * reports a perfectly valid request as invalid or expired, which is a confusing + * dead end. So the request is still delivered, and the mismatch is stated with + * the one action that resolves it. + */ +@Composable +fun DevicePairingWrongServerScreen( + serverName: String, + onSwitch: () -> Unit, + onCancel: () -> Unit, +) { + DevicePairingNoticeStage( + title = "Different server", + body = "This pairing request is for $serverName. Switch to it to continue.", + primaryLabel = "Switch to $serverName", + onPrimary = onSwitch, + onCancel = onCancel, + ) +} + +/** Shown when a pairing link names a server the user has not added. */ +@Composable +fun DevicePairingUnknownServerScreen( + origin: String, + onAddServer: () -> Unit, + onCancel: () -> Unit, +) { + DevicePairingNoticeStage( + title = "Unknown server", + body = "This pairing request is for $origin, which isn't one of your servers. " + + "Add it to continue.", + primaryLabel = "Add server", + onPrimary = onAddServer, + onCancel = onCancel, + ) +} + +@Composable +private fun DevicePairingNoticeStage( + title: String, + body: String, + primaryLabel: String, + onPrimary: () -> Unit, + onCancel: () -> Unit, +) { + AuthStage { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PrairieLogo() + + Spacer(modifier = Modifier.height(18.dp)) + + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = AuthColors.OnBackground, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = AuthColors.OnSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(28.dp)) + + Button( + onClick = onPrimary, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = AuthColors.Primary), + ) { + Text(text = primaryLabel) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text(text = "Cancel") + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt new file mode 100644 index 000000000..29cfc93e0 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimScreen.kt @@ -0,0 +1,278 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraErrorLabel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraEyebrow +import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim +import org.prairieserver.prairie.android.ui.components.aurora.AuroraTextField +import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant +import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass + +/** + * Emailed-invitation claim: the deep link carried the server and token, so + * this screen asks for a password and nothing else. Everything visual + * mirrors SignupScreen's Aurora treatment. + */ +@Composable +fun InviteClaimScreen( + serverUrl: String, + token: String, + onNavigateToLogin: () -> Unit, + onClaimComplete: () -> Unit, + viewModel: InviteClaimViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + var showPassword by remember { mutableStateOf(false) } + + LaunchedEffect(serverUrl, token) { + viewModel.load(serverUrl, token) + } + + // One-way latch: the success navigation clears the whole back stack, so + // there is nothing to consume or reset. + LaunchedEffect(state.claimSuccess) { + if (state.claimSuccess) onClaimComplete() + } + + state.pendingCleartextOrigin?.let { origin -> + AlertDialog( + onDismissRequest = viewModel::onCancelCleartext, + title = { Text("Use unencrypted HTTP?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(origin, fontWeight = FontWeight.SemiBold) + Text( + "This connection is not encrypted. Anyone on the network may see or change " + + "traffic, including your new password. Continue only on a network you trust.", + ) + } + }, + confirmButton = { + AuroraPrimaryButton( + label = "Use HTTP", + onClick = viewModel::onConfirmCleartext, + ) + }, + dismissButton = { + AuroraGhostButton( + label = "Cancel", + onClick = viewModel::onCancelCleartext, + ) + }, + ) + } + + AuroraScreen(variant = AuroraVariant.SignIn, scrim = AuroraScrim.Soft) { + PrairieLogo() + Spacer(Modifier.height(30.dp)) + + when { + state.isLoadingInvitation -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(60.dp)) + CircularProgressIndicator(color = Color(0xFFF3EFE9)) + } + } + + state.lookupFailed -> { + AuroraEyebrow(text = "Invitation", centered = true) + Spacer(Modifier.height(12.dp)) + Text( + text = "Couldn't reach the server", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Your invite is probably still fine — we just couldn't check it. " + + "Make sure you're online and try again.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + AuroraPrimaryButton( + label = "Try again", + onClick = viewModel::onRetryLookup, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + AuroraGhostButton( + label = "Back to sign in", + onClick = onNavigateToLogin, + fillMaxWidth = true, + ) + } + + state.invitationInvalid -> { + AuroraEyebrow(text = "Invitation", centered = true) + Spacer(Modifier.height(12.dp)) + Text( + text = "This invite has expired", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "The link may have been used already, revoked, or simply expired. " + + "Ask whoever invited you to send a fresh one.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + AuroraGhostButton( + label = "Back to sign in", + onClick = onNavigateToLogin, + fillMaxWidth = true, + ) + } + + else -> { + val invitation = state.invitation ?: return@AuroraScreen + invitation.inviterName?.let { + AuroraEyebrow(text = "Invited by $it", centered = true) + Spacer(Modifier.height(12.dp)) + } + Text( + text = "Welcome to ${invitation.serverName}", + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFFF3EFE9), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Choose a password and you're in. You'll sign in with your email address.", + fontSize = 15.sp, + color = Color.White.copy(alpha = 0.65f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .auroraGlass(cornerRadius = 24.dp, emphasized = true) + .padding(22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // The address is fixed by the invitation; show it, don't edit it. + Column { + Text( + text = "EMAIL", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.55f), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = invitation.email, + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + ) + } + AuroraTextField( + label = "Password", + value = state.password, + onValueChange = viewModel::onPasswordChanged, + placeholder = "••••••", + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + visualTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailing = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + imageVector = if (showPassword) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = if (showPassword) "Hide password" else "Show password", + tint = Color.White.copy(alpha = 0.62f), + ) + } + }, + ) + AuroraTextField( + label = "Confirm password", + value = state.confirmPassword, + onValueChange = viewModel::onConfirmPasswordChanged, + placeholder = "••••••", + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + onImeAction = viewModel::onClaimClick, + visualTransformation = PasswordVisualTransformation(), + ) + + state.error?.let { AuroraErrorLabel(it) } + + AuroraPrimaryButton( + label = if (state.isSubmitting) "Creating…" else "Create account", + onClick = viewModel::onClaimClick, + isLoading = state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt new file mode 100644 index 000000000..af9d3105d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/InviteClaimViewModel.kt @@ -0,0 +1,200 @@ +package org.prairieserver.prairie.android.ui.screens.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.common.network.CleartextConsentStore +import org.prairieserver.prairie.model.auth.InvitationLookupResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage +import org.prairieserver.prairie.network.requiresApproval +import org.prairieserver.prairie.repository.AuthRepository + +data class InviteClaimUiState( + val isLoadingInvitation: Boolean = true, + val invitation: InvitationLookupResponse? = null, + /** The server said the token itself is dead — the invite really is gone. */ + val invitationInvalid: Boolean = false, + /** The lookup failed for reasons unrelated to the token; offer retry. */ + val lookupFailed: Boolean = false, + /** + * The invite points at a cleartext HTTP server the user hasn't approved. + * The claim POST carries credentials, so it needs the same explicit + * consent the manual server-setup flow collects. + */ + val pendingCleartextOrigin: String? = null, + val password: String = "", + val confirmPassword: String = "", + val isSubmitting: Boolean = false, + val error: String? = null, + val claimSuccess: Boolean = false, +) + +/** + * Claim flow for an emailed invitation deep link (prairie://invite or an + * https app link): server URL and token arrive in the link, the invitee + * chooses only a password. On success the account is created, tokens are + * stored, and the server is registered so the rest of the app works exactly + * as after a normal login. + */ +class InviteClaimViewModel( + private val authRepository: AuthRepository, + private val cleartextConsentStore: CleartextConsentStore, +) : ViewModel() { + + private val _uiState = MutableStateFlow(InviteClaimUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var serverUrl: String = "" + private var token: String = "" + + /** + * Bumped whenever the target invite changes; in-flight lookups compare + * against it before touching state, so a slow response for a superseded + * link can't paint another invite's email over the current one. + */ + private var lookupGeneration = 0 + + fun load(serverUrl: String, token: String) { + // An invite is identified by server *and* token: the same token can + // exist on another server, and matching on the token alone would keep + // the previous server, submitting the password to the wrong one. + if ( + this.serverUrl == serverUrl && + this.token == token && + _uiState.value.invitation != null + ) { + return + } + this.serverUrl = serverUrl + this.token = token + val generation = ++lookupGeneration + _uiState.update { InviteClaimUiState() } + viewModelScope.launch { + val result = authRepository.lookupInvitation(serverUrl, token) + if (generation != lookupGeneration) return@launch + when (result) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingInvitation = false, invitation = result.data) + } + is ApiResult.Error -> { + // Only statuses that speak about the token itself are + // terminal. A 429/5xx says nothing about the invite, and + // showing "expired" for it sends the user off to have a + // valid link revoked and reissued. + if (result.code in TERMINAL_LOOKUP_CODES) { + _uiState.update { + it.copy(isLoadingInvitation = false, invitationInvalid = true) + } + } else { + _uiState.update { + it.copy(isLoadingInvitation = false, lookupFailed = true) + } + } + } + // A failure to reach the server says nothing about the invite + // either. + is ApiResult.NetworkError -> _uiState.update { + it.copy(isLoadingInvitation = false, lookupFailed = true) + } + } + } + } + + fun onRetryLookup() { + val url = serverUrl + val tok = token + // Clear the loaded marker so load() runs the lookup again. + this.token = "" + load(url, tok) + } + + fun onPasswordChanged(value: String) { + _uiState.update { it.copy(password = value, error = null) } + } + + fun onConfirmPasswordChanged(value: String) { + _uiState.update { it.copy(confirmPassword = value, error = null) } + } + + fun onClaimClick() { + val current = _uiState.value + val validationError = when { + current.password.length < 8 -> "Password must be at least 8 characters" + current.password != current.confirmPassword -> "Passwords do not match" + else -> null + } + if (validationError != null) { + _uiState.update { it.copy(error = validationError) } + return + } + + viewModelScope.launch { + // The read-only lookup may have slipped past the cleartext gate, + // but the claim POST carries a password and will be rejected by + // the interceptor for an unapproved http:// origin — surfacing as + // an opaque network error. Ask for the same consent server setup + // does, then proceed. + if (cleartextConsentStore.requiresApproval(serverUrl)) { + _uiState.update { it.copy(pendingCleartextOrigin = serverUrl) } + return@launch + } + submitClaim() + } + } + + fun onConfirmCleartext() { + val origin = _uiState.value.pendingCleartextOrigin ?: return + viewModelScope.launch { + cleartextConsentStore.approve(origin) + _uiState.update { it.copy(pendingCleartextOrigin = null) } + submitClaim() + } + } + + fun onCancelCleartext() { + _uiState.update { it.copy(pendingCleartextOrigin = null) } + } + + private suspend fun submitClaim() { + val current = _uiState.value + // Pin the invite this submission is for. A second deep link can replace + // the route mid-POST; without this the first response would still drive + // the UI — navigating away from the invite now on screen, or reporting + // success for an account on a server the user is no longer claiming. + val generation = lookupGeneration + val claimServerUrl = serverUrl + val claimToken = token + _uiState.update { it.copy(isSubmitting = true, error = null) } + // acceptInvitation talks to the invite's server directly and only + // adopts it as the active server after the claim succeeds, so a + // failed claim leaves any existing session untouched. + val result = authRepository.acceptInvitation(claimServerUrl, claimToken, current.password) + if (generation != lookupGeneration) return + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSubmitting = false, claimSuccess = true) } + } + is ApiResult.Error -> { + val message = when (result.code) { + 404 -> "This invitation is invalid or has expired." + 409 -> "This invitation has already been used." + else -> result.errorMessage("Could not create your account") + } + _uiState.update { it.copy(isSubmitting = false, error = message) } + } + is ApiResult.NetworkError -> _uiState.update { + it.copy(isSubmitting = false, error = result.errorMessage("Could not create your account")) + } + } + } + + private companion object { + /** Statuses that mean the token itself is gone, used, or malformed. */ + private val TERMINAL_LOOKUP_CODES = setOf(400, 404, 409, 410) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt index 4f058964c..8c81304f7 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt @@ -1,22 +1,34 @@ package org.prairieserver.prairie.android.ui.screens.browse +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme @@ -25,14 +37,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.prairieserver.prairie.android.ui.components.MediaCard import org.prairieserver.prairie.android.ui.components.MediaGridDefaults import org.prairieserver.prairie.android.ui.components.rememberBrowseItemCardActions @@ -42,15 +69,15 @@ import org.prairieserver.prairie.overlays.OverlayDataExtractor /** * A vertical grid of media cards with infinite-scroll support. * - * Uses the shared iOS-style adaptive poster grid with automatic load-more - * triggering when the user scrolls near the bottom. + * Uses the shared iOS-style adaptive poster grid with automatic load-more + * triggering when the user scrolls near the bottom. [header] is a spanning + * row that scrolls with the grid (sort/filter controls); [topContentInset] + * lets the grid start below floating chrome and scroll under it. * - * @param items The catalog items to display. - * @param isLoadingMore Whether additional items are currently loading. - * @param hasMore Whether there are more items to load. - * @param onItemClick Callback with content ID when a card is tapped. - * @param onLoadMore Callback to trigger loading the next page. - * @param modifier Compose modifier. + * When [onNamePrefixSelected] is given, an A–Z name-prefix index lives on + * the trailing edge: hidden behind a small handle by default, press-and-hold + * (or tap) slides it in and a drag along it picks a letter, shown in a + * bubble; it slides away again once you let go. */ @Composable fun CatalogGrid( @@ -65,6 +92,9 @@ fun CatalogGrid( selectedNamePrefix: String? = null, onNamePrefixSelected: ((String?) -> Unit)? = null, viewDensity: CatalogViewDensity = CatalogViewDensity.Normal, + bottomContentInset: Dp = 0.dp, + topContentInset: Dp = 0.dp, + header: (@Composable () -> Unit)? = null, ) { val gridState = rememberLazyGridState() val cardWidth = viewDensity.minCardWidth @@ -92,14 +122,22 @@ fun CatalogGrid( state = gridState, contentPadding = PaddingValues( start = 16.dp, - top = 8.dp, - end = if (onNamePrefixSelected != null) 56.dp else 16.dp, - bottom = 8.dp, + top = 8.dp + topContentInset, + // A little extra on the trailing side keeps the index handle + // off the posters' edge. + end = if (onNamePrefixSelected != null) 24.dp else 16.dp, + bottom = 8.dp + bottomContentInset, ), horizontalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridHorizontalSpacing), verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), modifier = Modifier.fillMaxSize(), ) { + if (header != null) { + item(key = "grid-header", span = { GridItemSpan(maxLineSpan) }) { + header() + } + } + items( items = items, key = { it.contentId }, @@ -137,65 +175,280 @@ fun CatalogGrid( } onNamePrefixSelected?.let { onSelected -> - CatalogLetterRail( + CatalogLetterIndex( selectedNamePrefix = selectedNamePrefix, onNamePrefixSelected = onSelected, + revealWhileScrolling = gridState.isScrollInProgress, modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 6.dp), + .fillMaxHeight() + .padding(top = topContentInset + 8.dp, bottom = bottomContentInset + 8.dp), ) } } } +// MARK: - A–Z index + private val CatalogLetterOptions: List = listOf(null) + ('A'..'Z').map { it.toString() } +private const val IndexAutoHideMillis = 1_400L +private val IndexTabWidth = 22.dp +private val IndexTabHeight = 64.dp +private val IndexRailWidth = 26.dp +private val IndexBubbleSize = 64.dp +private val IndexPullThreshold = 24.dp +/** + * Trailing-edge name-prefix index. + * + * At rest a small "pull tab" sits half-docked on the edge (showing the + * active letter, or "A–Z"). Drag it leftward and it stretches like a drop + * as you pull; past [IndexPullThreshold] the rail springs open with a little + * overshoot and, without lifting, the same finger scrubs up and down the + * letters with a preview bubble — applied on release. A tap on the tab opens + * the rail for direct letter taps. The rail also fades in while the grid is + * scrolling ([revealWhileScrolling]) so it is easy to discover, and tucks + * away after a moment of no interaction. + */ @Composable -private fun CatalogLetterRail( +private fun CatalogLetterIndex( selectedNamePrefix: String?, onNamePrefixSelected: (String?) -> Unit, + revealWhileScrolling: Boolean, modifier: Modifier = Modifier, ) { - // The alphabet is intentionally scrollable so every entry can retain the - // 48dp minimum touch target on short phone screens. - LazyColumn( - modifier = modifier - .width(48.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.78f)) - .padding(vertical = 6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - items(CatalogLetterOptions, key = { it ?: "all" }) { prefix -> - val selected = selectedNamePrefix == prefix + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val pullThresholdPx = with(density) { IndexPullThreshold.toPx() } + val railWidthPx = with(density) { (IndexRailWidth + 8.dp).toPx() } + + // 0 = tucked away, 1 = fully open. Driven by the pull while dragging, + // then animated to a resting state. + val railProgress = remember { Animatable(0f) } + var open by remember { mutableStateOf(false) } + var scrubbing by remember { mutableStateOf(false) } + var pulling by remember { mutableStateOf(false) } + var previewPrefix by remember { mutableStateOf(null) } + var railHeightPx by remember { mutableIntStateOf(0) } + var interactionTick by remember { mutableIntStateOf(0) } + val currentOnSelected by rememberUpdatedState(onNamePrefixSelected) + + fun prefixAt(y: Float): String? { + if (railHeightPx <= 0) return null + val slot = railHeightPx.toFloat() / CatalogLetterOptions.size + val index = (y / slot).toInt().coerceIn(0, CatalogLetterOptions.lastIndex) + return CatalogLetterOptions[index] + } + + fun openRail() { + open = true + interactionTick++ + scope.launch { + railProgress.animateTo(1f, spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow)) + } + } + + fun closeRail() { + open = false + scope.launch { railProgress.animateTo(0f, tween(durationMillis = 220)) } + } + + // Reveal while the grid scrolls; tuck away once everything is quiet. + LaunchedEffect(revealWhileScrolling) { + if (revealWhileScrolling && !open) openRail() + } + LaunchedEffect(open, scrubbing, pulling, revealWhileScrolling, interactionTick) { + if (open && !scrubbing && !pulling && !revealWhileScrolling) { + delay(IndexAutoHideMillis) + closeRail() + } + } + + BoxWithConstraints(modifier = modifier, contentAlignment = Alignment.CenterEnd) { + val railHeight = maxHeight + + // Preview bubble while scrubbing, beside the rail. + AnimatedVisibility( + visible = scrubbing, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = IndexRailWidth + 20.dp), + ) { Box( modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(24.dp)) - .background( - if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surface.copy(alpha = 0f) - }, - ) - .clickable { onNamePrefixSelected(prefix) }, + .size(IndexBubbleSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface), contentAlignment = Alignment.Center, ) { Text( - text = prefix ?: "All", - color = if (selected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - lineHeight = 13.sp, - textAlign = TextAlign.Center, - maxLines = 1, + text = previewPrefix ?: "All", + color = MaterialTheme.colorScheme.background, + fontSize = if (previewPrefix == null) 16.sp else 28.sp, + fontWeight = FontWeight.Bold, ) } } + + // The rail: slides in from the edge as railProgress rises. + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 4.dp) + .width(IndexRailWidth) + .height(railHeight) + .graphicsLayer { + val p = railProgress.value.coerceIn(0f, 1.2f) + translationX = (1f - p) * railWidthPx + alpha = p.coerceIn(0f, 1f) + } + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .onSizeChanged { railHeightPx = it.height }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CatalogLetterOptions.forEach { prefix -> + val active = if (scrubbing) previewPrefix == prefix else selectedNamePrefix == prefix + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = prefix ?: "•", + color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp, + fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } + } + + // The pull tab + gesture surface. Drag left to pull the rail open + // (stretching like a drop), keep dragging vertically to scrub; tap + // to open. The tab's own patch of the edge is excluded from the + // system back gesture (small regions are allowed) so a touch that + // starts on the tab is ours; the rest of the edge stays the OS's. + var pullPx by remember { mutableStateOf(0f) } + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .width(IndexRailWidth + 12.dp) + .fillMaxHeight() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { + pulling = true + pullPx = 0f + interactionTick++ + }, + onDrag = { change, drag -> + change.consume() + if (!open) { + pullPx = (pullPx - drag.x).coerceAtLeast(0f) + val p = (pullPx / pullThresholdPx).coerceIn(0f, 1f) + scope.launch { railProgress.snapTo(p) } + // Open once pulled far enough — or as soon as the + // finger turns vertical with the rail mostly out, + // so nobody has to hunt for the exact distance. + val turnedVertical = p > 0.4f && kotlin.math.abs(drag.y) > kotlin.math.abs(drag.x) + if (pullPx >= pullThresholdPx || turnedVertical) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + openRail() + scrubbing = true + previewPrefix = prefixAt(change.position.y) + } + } else { + if (!scrubbing) { + scrubbing = true + previewPrefix = prefixAt(change.position.y) + } + val next = prefixAt(change.position.y) + if (next != previewPrefix) { + previewPrefix = next + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + }, + onDragEnd = { + pulling = false + if (scrubbing) { + currentOnSelected(previewPrefix) + scrubbing = false + } else if (!open) { + // A decent tug that stopped short still opens the + // rail for tapping; a nudge snaps the tab back. + if (railProgress.value > 0.4f) openRail() + else scope.launch { railProgress.animateTo(0f, spring(dampingRatio = 0.5f)) } + } + pullPx = 0f + interactionTick++ + }, + onDragCancel = { + pulling = false + scrubbing = false + pullPx = 0f + if (!open) scope.launch { railProgress.animateTo(0f) } + }, + ) + } + .pointerInput(Unit) { + // The zone sits over the rail, so it owns taps too: on the + // open rail a tap picks the letter under it (the rail and + // zone share the same height, so y maps directly); on the + // closed tab a tap opens the rail. + detectTapGestures( + onTap = { offset -> + if (open) { + currentOnSelected(prefixAt(offset.y)) + interactionTick++ + } else { + openRail() + } + }, + ) + }, + ) + + // The tab: half-docked pill on the edge that stretches as it is + // pulled and hides once the rail is open. + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .systemGestureExclusion() + .graphicsLayer { + val p = railProgress.value.coerceIn(0f, 1f) + val stretch = 1f + 0.35f * p + translationX = -pullPx * 0.6f + scaleY = stretch + scaleX = 1f - 0.2f * p + alpha = if (open) (1f - p) else 1f + transformOrigin = TransformOrigin(1f, 0.5f) + } + .width(IndexTabWidth) + .height(IndexTabHeight) + .clip(RoundedCornerShape(topStart = 11.dp, bottomStart = 11.dp)) + .background( + if (selectedNamePrefix != null) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.surface.copy(alpha = 0.92f), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = selectedNamePrefix ?: "A\nZ", + color = if (selectedNamePrefix != null) MaterialTheme.colorScheme.background + else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = if (selectedNamePrefix != null) 12.sp else 9.sp, + lineHeight = 10.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/FilterSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/FilterSheet.kt index 1a69c4d8d..7791e50fa 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/FilterSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/FilterSheet.kt @@ -66,15 +66,17 @@ import androidx.compose.material.icons.filled.RestartAlt @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun FilterSheet( - viewDensity: CatalogViewDensity, - onSelectDensity: (CatalogViewDensity) -> Unit, currentFilters: CatalogFilterState, availableFilters: CatalogFiltersResponse?, mediaType: BrowseFacetMediaType, - preserveFilters: Boolean, onCommit: (CatalogFilterState) -> Unit, - onSetPreserve: (Boolean) -> Unit, onDismiss: () -> Unit, + // Browse-only rows. Saved lists (Watchlist / Favorites) pass neither: they + // have one density and are session-scoped, so the rows are omitted. + viewDensity: CatalogViewDensity? = null, + onSelectDensity: ((CatalogViewDensity) -> Unit)? = null, + preserveFilters: Boolean? = null, + onSetPreserve: ((Boolean) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var draft by remember { mutableStateOf(currentFilters) } @@ -138,19 +140,21 @@ fun FilterSheet( // View density lives here rather than beside the sort controls, // where the layout options read as extra sort choices. - Text( - text = "View", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp, bottom = 6.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CatalogViewDensity.entries.forEach { density -> - FilterChip( - selected = viewDensity == density, - onClick = { onSelectDensity(density) }, - label = { Text(density.label) }, - ) + if (viewDensity != null && onSelectDensity != null) { + Text( + text = "View", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 6.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CatalogViewDensity.entries.forEach { density -> + FilterChip( + selected = viewDensity == density, + onClick = { onSelectDensity(density) }, + label = { Text(density.label) }, + ) + } } } @@ -260,17 +264,19 @@ fun FilterSheet( modifier = Modifier.padding(top = 4.dp), ) - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Preserve sort & filters", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Switch(checked = preserveFilters, onCheckedChange = onSetPreserve) + if (preserveFilters != null && onSetPreserve != null) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Preserve sort & filters", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Switch(checked = preserveFilters, onCheckedChange = onSetPreserve) + } } Spacer(modifier = Modifier.height(24.dp)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarPrefsStore.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarPrefsStore.kt new file mode 100644 index 000000000..5db15f427 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarPrefsStore.kt @@ -0,0 +1,22 @@ +package org.prairieserver.prairie.android.ui.screens.calendar + +import android.content.Context +import org.prairieserver.prairie.viewmodel.CalendarFilterStore + +/** + * SharedPreferences-backed [CalendarFilterStore]. Device-global, like the + * iOS `UserDefaults["calendar.filter"]` it mirrors. + */ +class CalendarPrefsStore(context: Context) : CalendarFilterStore { + private val prefs = context.applicationContext.getSharedPreferences("calendar_prefs", Context.MODE_PRIVATE) + + override fun read(): String? = prefs.getString(KEY_FILTER, null) + + override fun write(filter: String) { + prefs.edit().putString(KEY_FILTER, filter).apply() + } + + private companion object { + const val KEY_FILTER = "calendar.filter" + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt index e66c47211..fe564f7b0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/calendar/CalendarScreen.kt @@ -1,20 +1,27 @@ package org.prairieserver.prairie.android.ui.screens.calendar -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -26,56 +33,61 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Bedtime +import androidx.compose.material.icons.outlined.EventBusy +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.prairieserver.prairie.common.calendar.localDisplayAirTime +import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.calendar.CalendarBadge import org.prairieserver.prairie.model.calendar.CalendarFilter import org.prairieserver.prairie.model.calendar.CalendarItem -import org.prairieserver.prairie.model.personal.UserLibrary -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.viewmodel.CalendarUiState import org.prairieserver.prairie.viewmodel.CalendarViewModel -import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset -import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale -import androidx.compose.material.icons.filled.Refresh -// iOS phone token mirror (PrairieTheme.swift, !os(tvOS) branch): -// cornerRadius = 8, smallCornerRadius = 6 -// spacing = 12, padding = 16, smallPadding = 8, largePadding = 24, safePadding = 16 -// posterCardWidth = 120, posterCardHeight = 198 +// iOS PrairieTheme tokens (phone). private val CornerRadius = 8.dp private val Spacing = 12.dp private val Padding = 16.dp @@ -85,178 +97,261 @@ private val SafePadding = 16.dp private val PosterCardWidth = 120.dp private val PosterCardHeight = 198.dp +// Header card (iOS CalendarView.phoneWeekStrip). +private val CardCornerRadius = 26.dp +private val CardHorizontalPadding = 14.dp +private val CardVerticalPadding = 12.dp +private val CardInnerSpacing = 12.dp + +private val CalendarSpring = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow) + /** - * Calendar / upcoming screen. Phone-for-phone parity with the prairie-apple - * iOS CalendarView: a header row, a pinned filter + week-strip header, and - * a vertical list of per-day shelves (one row per day, even empty days), - * each a horizontal scroller of poster cards. Tapping a day chip selects it - * and scrolls its shelf to the top. + * Phone Calendar tab. Mirrors iOS `CalendarView` (phone): + * + * - One floating glass card is the only pinned element — month label, a + * "Today" pill when off the current week, the shared search/profile + * actions ([headerActions]), and the week strip. There is no separate + * title row; the card *is* the header. + * - Everything else scrolls under the card: the Following / Trending / All + * filter bar first, then one shelf per day of the week (empty days too). + * - Day taps and "Today" scroll that day's shelf up to the card; opening the + * tab does not auto-scroll. + * - Pull to refresh. */ -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CalendarScreen( - onBackClick: () -> Unit, onItemClick: (String) -> Unit, + headerActions: @Composable RowScope.() -> Unit = {}, viewModel: CalendarViewModel = koinViewModel(), - showTopBar: Boolean = true, - contentTopPadding: Dp = 0.dp, ) { val state by viewModel.uiState.collectAsState() - - // Library list for the dropdown — same source MainScreen uses for - // media-mode capabilities (PersonalDataRepository.listUserLibraries). - val personalDataRepository: PersonalDataRepository = koinInject() - val libraries by produceState(initialValue = emptyList()) { - value = when (val result = personalDataRepository.listUserLibraries()) { - is ApiResult.Success -> result.data - else -> emptyList() + val listState = rememberLazyListState() + val density = LocalDensity.current + + // The card floats over the agenda; its measured height is the top inset + // the list scrolls under. Local blur source so the card can be glass. + val haze = rememberHazeState() + var cardHeightPx by remember { mutableIntStateOf(0) } + val cardHeight = with(density) { cardHeightPx.toDp() } + + // Explicit scroll requests only (day tap / Today), never on first + // composition — iOS opens at the top of the week. Keyed on weekDates and + // on whether the shelves exist yet, so a request made while the week is + // still loading is honoured once its content arrives. + var scrollTarget by remember { mutableStateOf(null) } + LaunchedEffect(scrollTarget, state.weekDates, state.hasAnyItems) { + val target = scrollTarget ?: return@LaunchedEffect + val index = state.weekDates.indexOf(target) + if (index >= 0 && state.hasAnyItems) { + // Item 0 is the filter bar; the top content padding keeps the + // shelf below the card. + listState.animateScrollToItem(index + 1) + scrollTarget = null } } - Scaffold( - topBar = { - if (showTopBar) { - PrairieTopBar( - title = "Calendar", - onBackClick = onBackClick, - actions = { - if (libraries.size > 1) { - LibraryDropdown( - libraries = libraries, - selectedLibraryId = state.libraryId, - onSelect = viewModel::setLibrary, - ) - } - }, - ) - } - }, - containerColor = MaterialTheme.colorScheme.background, - // Embedded in the tab shell (showTopBar = false) the shared floating - // header already accounts for the status bar via contentTopPadding; - // letting Scaffold add its own status-bar inset doubles the top gap. - contentWindowInsets = if (showTopBar) ScaffoldDefaults.contentWindowInsets else WindowInsets(0), - ) { padding -> - val listState = rememberLazyListState() - - // iOS scrolls the selected day's shelf to the top via ScrollViewReader. - // Mirror with a keyed scroll-to-index against the day-header item keys. - LaunchedEffect(state.selectedDay, state.weekStart) { - if (state.selectedDay.isBlank()) return@LaunchedEffect - val index = state.weekDates.indexOf(state.selectedDay) - if (index >= 0) { - // Header item index: each day owns one header item plus its shelf. - listState.animateScrollToItem(if (index == 0) 0 else index + 1) - } - } - - Column( + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + val pullState = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = viewModel::refresh, + state = pullState, modifier = Modifier .fillMaxSize() - .padding(padding) - .padding(top = contentTopPadding), + .hazeSource(haze) + .background(MaterialTheme.colorScheme.background), + indicator = { + PullToRefreshDefaults.Indicator( + state = pullState, + isRefreshing = state.isRefreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = cardHeight), + ) + }, ) { - when { - state.isLoading -> { - PinnedHeader(state, viewModel) - Box(Modifier.fillMaxSize()) - } - - state.error != null -> { - PinnedHeader(state, viewModel) - ErrorRow( - message = state.error ?: "Something went wrong", - onRetry = viewModel::load, - ) - } - - else -> LazyColumn( + if (cardHeightPx > 0) { + LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = LargePadding + LocalBottomChromeInset.current), + contentPadding = PaddingValues( + top = cardHeight, + bottom = LargePadding + LocalBottomChromeInset.current, + ), ) { - // iOS pins this header (LazyVStack pinnedViews:[.sectionHeaders]). - stickyHeader(key = "calendar-header") { - PinnedHeader(state, viewModel) + // iOS: filter bar scrolls with the content, above the shelves. + item(key = "filter") { + CalendarFilterBar( + selected = state.filter, + onSelect = viewModel::setFilter, + modifier = Modifier.padding( + start = SafePadding, + end = SafePadding, + top = SmallPadding, + bottom = Padding, + ), + ) } - - if (!state.hasAnyItems) { - item(key = "empty") { - EmptyState(filter = state.filter) + when { + state.error != null && !state.hasAnyItems -> item(key = "error") { + ErrorView( + message = state.error ?: "Something went wrong", + onRetry = viewModel::load, + modifier = Modifier.fillMaxWidth().padding(vertical = LargePadding), + ) } - } else { - state.weekDates.forEach { date -> - val dayItems = state.itemsFor(date) - item(key = "header-$date") { - DayShelf( - heading = sectionHeading(date, today = state.today), - items = dayItems, - onItemClick = onItemClick, - ) - } + state.isLoading && !state.hasAnyItems -> item(key = "loading") { + // iOS: deliberately blank while loading, no spinner. + Spacer(modifier = Modifier.height(320.dp)) + } + !state.hasAnyItems -> item(key = "empty") { + EmptyState( + filter = state.filter, + onShowEverything = { viewModel.setFilter(CalendarFilter.Everything) }, + ) + } + else -> items(state.weekDates, key = { "day-$it" }) { date -> + DayShelf( + heading = sectionHeading(date, today = state.today), + items = state.itemsFor(date), + onItemClick = onItemClick, + ) } } } } } + + CalendarHeaderCard( + state = state, + hazeModifier = Modifier.hazeEffect(state = haze) { + blurRadius = 20.dp + noiseFactor = 0f + // iOS Glass.regular on a dark canvas reads as a lifted grey; + // a light wash over the blur gives the same lift here. + tints = listOf(HazeTint(Color.White.copy(alpha = 0.06f))) + fallbackTint = HazeTint(Color(0xFF161616).copy(alpha = 0.96f)) + }, + headerActions = headerActions, + onSelectDay = { day -> + viewModel.selectDay(day) + scrollTarget = day + }, + onPrevWeek = viewModel::prevWeek, + onNextWeek = viewModel::nextWeek, + onToday = { + viewModel.goToToday() + scrollTarget = state.today + }, + modifier = Modifier + .align(Alignment.TopCenter) + .onSizeChanged { cardHeightPx = it.height }, + ) } } -/** Pinned filter bar + week strip block. iOS: smallPadding vertical, background fill. */ +// MARK: - Header card + +/** + * The floating glass card: month label · Today · actions on the first row, + * the week strip on the second. iOS: `siloGlass(in: RoundedRectangle(26))` + * with a `white 0.08` hairline, h14/v12 inner padding, 12 spacing, and 16/8 + * outer margins under the status bar. + */ @Composable -private fun PinnedHeader( - state: org.prairieserver.prairie.viewmodel.CalendarUiState, - viewModel: CalendarViewModel, +private fun CalendarHeaderCard( + state: CalendarUiState, + hazeModifier: Modifier, + headerActions: @Composable RowScope.() -> Unit, + onSelectDay: (String) -> Unit, + onPrevWeek: () -> Unit, + onNextWeek: () -> Unit, + onToday: () -> Unit, + modifier: Modifier = Modifier, ) { - Column( - modifier = Modifier + Box( + modifier = modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - // Only bottom padding: the floating top bar already provides the top - // breathing room, so a top gap here just stacked whitespace above the - // filter capsule in portrait (Jim QA 2026-07-09). - .padding(bottom = SmallPadding), - verticalArrangement = Arrangement.spacedBy(SmallPadding), + .statusBarsPadding() + .padding(horizontal = SafePadding, vertical = SmallPadding), + ) { + val shape = RoundedCornerShape(CardCornerRadius) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .then(hazeModifier) + .border(1.dp, Color.White.copy(alpha = 0.08f), shape) + .padding(horizontal = CardHorizontalPadding, vertical = CardVerticalPadding), + verticalArrangement = Arrangement.spacedBy(CardInnerSpacing), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = monthLabel(state.weekDates), + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!state.isCurrentWeek) { + TodayPill(onClick = onToday) + } + Spacer(modifier = Modifier.weight(1f)) + headerActions() + } + CalendarWeekStrip( + weekDates = state.weekDates, + today = state.today, + selectedDay = state.selectedDay, + eventCount = { state.itemsFor(it).size }, + onSelectDay = onSelectDay, + onPrevWeek = onPrevWeek, + onNextWeek = onNextWeek, + ) + } + } +} + +/** iOS: 13 semibold, height 30, h-pad 12, glass capsule; a11y "Jump to today". */ +@Composable +private fun TodayPill(onClick: () -> Unit) { + Box( + modifier = Modifier + .height(30.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.10f)) + .border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, ) { - CalendarFilterBar( - selected = state.filter, - onSelect = viewModel::setFilter, - modifier = Modifier.padding(horizontal = SafePadding), - ) - // Left-aligned month/year header line above the day strip, matching iOS - // CalendarView (CalendarView.swift:105-113). Previously this lived inside - // the week-strip Row where portrait squeezed it to a vertical stack. Text( - text = monthLabel(state.weekDates), - fontSize = 17.sp, // iOS monthLabel = 17 semibold + text = "Today", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = SafePadding), - ) - CalendarWeekStrip( - weekDates = state.weekDates, - today = state.today, - selectedDay = state.selectedDay, - isCurrentWeek = state.isCurrentWeek, - hasEvents = { state.itemsFor(it).isNotEmpty() }, - onSelectDay = viewModel::selectDay, - onPrevWeek = viewModel::prevWeek, - onNextWeek = viewModel::nextWeek, - onToday = viewModel::goToToday, ) } } -// MARK: - Filter bar (segmented capsule control) +// MARK: - Filter bar /** - * Following / Trending / All capsule segmented control. Mirrors - * CalendarFilterBar.swift (iOS phone): a translucent capsule container with - * a hairline white stroke, each segment a capsule that fills near-opaque - * onSurface when selected with inverted (background-colored) text. + * Following / Trending / All contained segmented control. iOS + * `CalendarFilterBar` (phone): capsule container `white 0.07` + `white 0.10` + * stroke, padding 4, spacing 4; segments 13 semibold, height 30, h-pad 16; + * the selected capsule (`onSurface`, inverted text) slides between segments + * with a spring. */ @Composable private fun CalendarFilterBar( @@ -269,38 +364,69 @@ private fun CalendarFilterBar( CalendarFilter.Trending to "Trending", CalendarFilter.Everything to "All", ) - Row( + val selectedIndex = presets.indexOfFirst { (value, _) -> + value == selected || + (value == CalendarFilter.Everything && + (selected == CalendarFilter.All || selected == CalendarFilter.Everything)) + }.coerceAtLeast(0) + + // Segment geometry, measured so the pill can slide to the selected one. + val density = LocalDensity.current + val segmentX = remember { mutableStateOf(List(presets.size) { 0.dp }) } + val segmentW = remember { mutableStateOf(List(presets.size) { 0.dp }) } + val pillX by animateDpAsState(segmentX.value[selectedIndex], CalendarSpring, label = "filterPillX") + val pillW by animateDpAsState(segmentW.value[selectedIndex], CalendarSpring, label = "filterPillW") + + Box( modifier = modifier .clip(CircleShape) - .background(Color.White.copy(alpha = 0.06f)) + .background(Color.White.copy(alpha = 0.07f)) .border(1.dp, Color.White.copy(alpha = 0.10f), CircleShape) - .padding(3.dp), // iOS containerPadding (phone) = 3 - horizontalArrangement = Arrangement.spacedBy(2.dp), // iOS segmentSpacing = 2 + .padding(4.dp), ) { - presets.forEach { (value, label) -> - val isSelected = value == selected || - (value == CalendarFilter.Everything && - (selected == CalendarFilter.All || selected == CalendarFilter.Everything)) + if (pillW > 0.dp) { Box( modifier = Modifier - .height(28.dp) // iOS segmentHeight = 28 + .offset(x = pillX) + .width(pillW) + .height(30.dp) .clip(CircleShape) - .background( - if (isSelected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f) - else Color.Transparent, + .background(MaterialTheme.colorScheme.onSurface), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + presets.forEachIndexed { index, (value, label) -> + val isSelected = index == selectedIndex + Box( + modifier = Modifier + .height(30.dp) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onSelect(value) } + .onGloballyPositioned { coords -> + val x = with(density) { coords.positionInParent().x.toDp() } + val w = with(density) { coords.size.width.toDp() } + if (segmentX.value[index] != x) { + segmentX.value = segmentX.value.toMutableList().also { it[index] = x } + } + if (segmentW.value[index] != w) { + segmentW.value = segmentW.value.toMutableList().also { it[index] = w } + } + } + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + color = if (isSelected) MaterialTheme.colorScheme.background + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) - .clickable { onSelect(value) } - .padding(horizontal = 14.dp), // iOS segmentHorizontalPadding = 14 - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - fontSize = 13.sp, // iOS segmentFont = 13 semibold - fontWeight = FontWeight.SemiBold, - maxLines = 1, - color = if (isSelected) MaterialTheme.colorScheme.background - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) + } } } } @@ -309,77 +435,47 @@ private fun CalendarFilterBar( // MARK: - Week strip /** - * Prev/next chevrons around seven day buttons, a Today shortcut when off the - * current week, and a right-aligned month label. Mirrors CalendarWeekStrip.swift - * (iOS phone metrics). + * Prev/next chevrons around seven equal-width day cells that fill the card. + * iOS `CalendarWeekStrip` (phone): HStack spacing 6, 30pt bordered chevron + * discs, `CalendarRichDayCell`s with no background. */ @Composable private fun CalendarWeekStrip( weekDates: List, today: String, selectedDay: String, - isCurrentWeek: Boolean, - hasEvents: (String) -> Boolean, + eventCount: (String) -> Int, onSelectDay: (String) -> Unit, onPrevWeek: () -> Unit, onNextWeek: () -> Unit, - onToday: () -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = SafePadding), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), // iOS stripSpacing = 8 + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { ChevronButton( icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous week", onClick = onPrevWeek, ) - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { // iOS dayButtonSpacing = 4 + Row(modifier = Modifier.weight(1f)) { weekDates.forEach { date -> - DayButton( + DayCell( date = date, isSelected = date == selectedDay, isToday = date == today, - hasEvents = hasEvents(date), + eventCount = eventCount(date), onClick = { onSelectDay(date) }, + modifier = Modifier.weight(1f), ) } } - ChevronButton( icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next week", onClick = onNextWeek, ) - // Month label intentionally NOT here — it lives as a left-aligned - // header line above the strip (see PinnedHeader), matching iOS - // CalendarView. Kept in this Row it was squeezed to ~0 width in - // portrait (chevrons + 7 day cells overflow) and stacked vertically. - - if (!isCurrentWeek) { - Box( - modifier = Modifier - .height(32.dp) // iOS chevronHeight = 32 - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.08f)) - .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape) - .clickable(onClick = onToday) - .padding(horizontal = 12.dp), // iOS todayHorizontalPadding = 12 - contentAlignment = Alignment.Center, - ) { - Text( - text = "Today", - fontSize = 13.sp, // iOS todayFont = 13 semibold - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } } @@ -391,87 +487,106 @@ private fun ChevronButton( ) { Box( modifier = Modifier - .size(32.dp) // iOS chevronWidth/Height = 32 + .size(30.dp) .clip(CircleShape) - .background(Color.White.copy(alpha = 0.08f)) + .background(Color.White.copy(alpha = 0.07f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), CircleShape) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( imageVector = icon, contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), modifier = Modifier.size(18.dp), ) } } +/** + * iOS `CalendarRichDayCell`: weekday (11 semibold, secondary) over the day + * number (15 bold) in a 34pt radius-11 box — filled `onSurface` when + * selected, ringed `onSurface 0.45` @ 1.5 when today — over an event-count + * capsule (10 bold, `white 0.10`) or a matching blank so rows stay aligned. + */ @Composable -private fun DayButton( +private fun DayCell( date: String, isSelected: Boolean, isToday: Boolean, - hasEvents: Boolean, + eventCount: Int, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val localDate = remember(date) { LocalDate.parse(date) } - // iOS CalendarDayButton: inverted == selected (focus is tvOS-only). - val inverted = isSelected - val backgroundFill = when { - isSelected -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.88f) - else -> Color.White.copy(alpha = 0.05f) - } - val primaryColor = - if (inverted) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurface - val secondaryColor = - if (inverted) MaterialTheme.colorScheme.background.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.onSurfaceVariant - val dotColor = - if (inverted) MaterialTheme.colorScheme.background - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - val showTodayStroke = isToday && !isSelected - + val numberShape = RoundedCornerShape(11.dp) Column( - modifier = Modifier - .width(42.dp) // iOS buttonWidth = 42 - .height(56.dp) // iOS buttonHeight = 56 - .clip(RoundedCornerShape(CornerRadius)) // iOS cornerRadius = 8 - .background(backgroundFill) - .then( - if (showTodayStroke) { - Modifier.border( - 1.dp, - Color.White.copy(alpha = 0.35f), - RoundedCornerShape(CornerRadius), - ) - } else { - Modifier - }, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, ) - .clickable(onClick = onClick), + .padding(vertical = 2.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(5.dp), ) { Text( text = localDate.format(DateTimeFormatter.ofPattern("EEE", Locale.getDefault())), - fontSize = 12.sp, // iOS weekdayFont = 10 semibold; 12sp phone readability floor + fontSize = 11.sp, fontWeight = FontWeight.SemiBold, - color = secondaryColor, - ) - Spacer(Modifier.height(3.dp)) // iOS labelSpacing = 3 - Text( - text = localDate.dayOfMonth.toString(), - fontSize = 15.sp, // iOS numberFont = 15 bold - fontWeight = FontWeight.Bold, - color = primaryColor, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(Modifier.height(3.dp)) Box( modifier = Modifier - .size(4.dp) // iOS dotSize = 4 + .size(34.dp) + .clip(numberShape) + .background(if (isSelected) MaterialTheme.colorScheme.onSurface else Color.Transparent) + .then( + if (isToday && !isSelected) { + Modifier.border(1.5.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), numberShape) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = localDate.dayOfMonth.toString(), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = if (isSelected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurface, + ) + } + Box( + modifier = Modifier + .height(16.dp) .clip(CircleShape) - .background(if (hasEvents) dotColor else Color.Transparent), - ) + .background(if (eventCount > 0) Color.White.copy(alpha = 0.10f) else Color.Transparent) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, + ) { + if (eventCount > 0) { + Text( + text = eventCount.toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + maxLines = 1, + // Android's default font padding drops the glyph below the + // optical centre of a 16dp capsule; trim it so the digit + // sits centred like the iOS text. + style = LocalTextStyle.current.copy( + lineHeight = 10.sp, + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, + ), + ), + ) + } + } } } @@ -479,7 +594,7 @@ private fun DayButton( /** * One day's heading + horizontal poster shelf. Mirrors CalendarDayShelf.swift: - * heading uses prairieHeadline (16 semibold), dims to secondary text on empty + * heading uses siloHeadline (16 semibold), dims to secondary text on empty * days; the shelf scrolls horizontally with `spacing`-gap poster cards; empty * days show a "Nothing scheduled" stub with a moon icon. */ @@ -497,7 +612,7 @@ private fun DayShelf( ) { Text( text = heading, - fontSize = 16.sp, // iOS prairieHeadline = 16 semibold + fontSize = 16.sp, // iOS siloHeadline = 16 semibold fontWeight = FontWeight.SemiBold, color = if (items.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, @@ -512,14 +627,14 @@ private fun DayShelf( ) { // iOS uses SF Symbol "moon.stars"; nearest Material equivalent. Icon( - imageVector = androidx.compose.material.icons.Icons.Filled.Star, + imageVector = Icons.Outlined.Bedtime, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), modifier = Modifier.size(14.dp), ) Text( text = "Nothing scheduled", - fontSize = 12.sp, // iOS prairieCaption = 12 + fontSize = 12.sp, // iOS siloCaption = 12 color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -624,7 +739,7 @@ private fun CalendarEventCard( // Caption: two-line title reserved + context subtitle. Text( text = item.title, - fontSize = 14.sp, // iOS prairieSubheadline = 14 bold + fontSize = 14.sp, // iOS siloSubheadline = 14 bold fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), minLines = 2, // iOS lineLimit(2, reservesSpace: true) @@ -634,7 +749,7 @@ private fun CalendarEventCard( cardSubtitle(item)?.let { subtitle -> Text( text = subtitle, - fontSize = 12.sp, // iOS prairieCaption = 12 + fontSize = 12.sp, // iOS siloCaption = 12 color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -665,8 +780,14 @@ private fun BadgePill(label: String) { // MARK: - Empty state +/** + * iOS empty state: 44pt calendar glyph at `onSurface 0.3`, subheadline title, + * caption body, and a 220pt "Show Everything" primary button whenever the + * filter is narrower than Everything. + */ @Composable -private fun EmptyState(filter: String) { +private fun EmptyState(filter: String, onShowEverything: () -> Unit) { + val isEverything = filter == CalendarFilter.Everything || filter == CalendarFilter.All Column( modifier = Modifier .fillMaxWidth() @@ -676,7 +797,7 @@ private fun EmptyState(filter: String) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( - imageVector = androidx.compose.material.icons.Icons.Filled.DateRange, + imageVector = Icons.Outlined.EventBusy, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), modifier = Modifier.size(44.dp), @@ -687,84 +808,33 @@ private fun EmptyState(filter: String) { } else { "Nothing scheduled this week" }, - fontSize = 14.sp, // iOS prairieSubheadline + fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) Text( text = emptySubtitle(filter), - fontSize = 12.sp, // iOS prairieCaption + fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - ) - } -} - -@Composable -private fun ErrorRow(message: String, onRetry: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LargePadding), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = message, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) - TextButton(onClick = onRetry) { Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Retry") } - } -} - -// MARK: - Library dropdown - -@Composable -private fun LibraryDropdown( - libraries: List, - selectedLibraryId: Int?, - onSelect: (Int?) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box { - TextButton(onClick = { expanded = true }) { - Text(libraries.firstOrNull { it.id == selectedLibraryId }?.name ?: "All libraries") - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - DropdownMenuItem( - text = { Text("All libraries") }, - onClick = { - expanded = false - onSelect(null) - }, - ) - libraries.forEach { library -> - DropdownMenuItem( - text = { Text(library.name) }, - onClick = { - expanded = false - onSelect(library.id) - }, - ) + if (!isEverything) { + Button( + onClick = onShowEverything, + modifier = Modifier.width(220.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onSurface, + contentColor = MaterialTheme.colorScheme.background, + ), + ) { + Text("Show Everything", fontWeight = FontWeight.SemiBold) } } } } -// MARK: - Helpers - private fun badgeLabel(badge: String): String? = when (badge) { // iOS CalendarBadge labels (uppercased editorial). CalendarBadge.SeriesPremiere -> "SERIES PREMIERE" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastArtwork.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastArtwork.kt index 58ebc1924..12af57298 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastArtwork.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastArtwork.kt @@ -7,29 +7,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import org.koin.compose.koinInject -import org.prairieserver.prairie.model.catalog.ItemDetail -import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.android.cast.PrairieCastArtwork +import org.prairieserver.prairie.android.cast.resolveCastArtwork import org.prairieserver.prairie.repository.CatalogRepository /** * Poster/backdrop artwork for the cast remote, resolved from the `contentId` * already present in the control playback state — no wire-protocol field - * needed. Mirrors prairie-apple's `PrairieControlArtworkResolver`: cached item + * needed. Mirrors silo-apple's `PrairieControlArtworkResolver`: cached item * detail first, then the API, degrading silently to no artwork. * * Episodes use their series' portrait poster — an episode's own poster is a - * landscape still, wrong for the remote's 2:3 card. The episode's backdrop - * (the still) is kept for the blurred background, where it looks right. + * landscape still, wrong for the remote's 2:3 card. Resolution itself lives + * outside Compose so the Android media session can publish the same artwork. */ -data class PrairieCastArtwork( - val posterUrl: String? = null, - val posterThumbhash: String? = null, - val backdropUrl: String? = null, - val backdropThumbhash: String? = null, -) { - val isEmpty: Boolean get() = posterUrl == null && backdropUrl == null -} - @Composable fun rememberPrairieCastArtwork(contentId: String?): PrairieCastArtwork { val repository: CatalogRepository = koinInject() @@ -45,23 +36,3 @@ fun rememberPrairieCastArtwork(contentId: String?): PrairieCastArtwork { } return artwork } - -private suspend fun resolveCastArtwork( - repository: CatalogRepository, - contentId: String, -): PrairieCastArtwork { - val detail = repository.detailOrNull(contentId) ?: return PrairieCastArtwork() - val series = detail.seriesId - ?.takeIf { detail.type == "episode" } - ?.let { repository.detailOrNull(it) } - return PrairieCastArtwork( - posterUrl = series?.posterUrl ?: detail.posterUrl, - posterThumbhash = if (series?.posterUrl != null) series.posterThumbhash else detail.posterThumbhash, - backdropUrl = detail.backdropUrl ?: series?.backdropUrl, - backdropThumbhash = if (detail.backdropUrl != null) detail.backdropThumbhash else series?.backdropThumbhash, - ) -} - -private suspend fun CatalogRepository.detailOrNull(contentId: String): ItemDetail? = - getCachedItemDetail(contentId) - ?: (getItemDetail(contentId) as? ApiResult.Success)?.data diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt index f99b2da4d..064a94e4e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Tv import androidx.compose.material.icons.outlined.TvOff +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -55,6 +56,7 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -72,6 +74,8 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -79,13 +83,18 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import org.koin.compose.koinInject +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.android.cast.RemoteControlBatteryOptimization import org.prairieserver.prairie.android.cast.PrairieCastController import org.prairieserver.prairie.cast.PrairieCastPlaybackState import org.prairieserver.prairie.common.ui.components.ThumbhashImage // Fixed dark palette: the remote renders over OLED black + blurred artwork -// regardless of the app theme, matching prairie-apple's forced-dark remote. +// regardless of the app theme, matching silo-apple's forced-dark remote. private val RemoteOnSurface = Color(0xFFF2F2F5) private val RemoteSecondary = Color(0xFFB9BAC3) private val RemoteChipFill = Color(0x33FFFFFF) @@ -93,8 +102,8 @@ private val RemoteSurfaceElevated = Color(0xFF23252E) private val RemoteError = Color(0xFFB3261E) /** - * Native "now-playing" remote for controlling Prairie playback on a TV. - * Mirrors prairie-apple's `PrairieControlRemoteView`: blurred-artwork backdrop, + * Native "now-playing" remote for controlling Silo playback on a TV. + * Mirrors silo-apple's `PrairieControlRemoteView`: blurred-artwork backdrop, * poster, scrubber with optimistic clock, transport, volume, and * capability-gated secondary menus — with distinct connecting / * reconnecting / idle-connected / playing / error states. @@ -107,13 +116,38 @@ fun PrairieCastRemoteScreen( val state by controller.state.collectAsState() val playback = state.playbackState val artwork = rememberPrairieCastArtwork(playback?.contentId) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current var showTargetPicker by remember { mutableStateOf(false) } + var showBatteryPrompt by remember { mutableStateOf(false) } + var batteryExempt by remember { + mutableStateOf(RemoteControlBatteryOptimization.isExempt(context)) + } DisposableEffect(controller) { controller.setRemoteScreenVisible(true) onDispose { controller.setRemoteScreenVisible(false) } } + DisposableEffect(lifecycleOwner, context) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + batteryExempt = RemoteControlBatteryOptimization.isExempt(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + LaunchedEffect(state.hasActiveSession, batteryExempt) { + if (state.hasActiveSession && + !batteryExempt && + RemoteControlBatteryOptimization.shouldShowPrompt(context) + ) { + showBatteryPrompt = true + } + } + Box( modifier = Modifier .fillMaxSize() @@ -130,6 +164,10 @@ fun PrairieCastRemoteScreen( controller.disconnect() onBack() }, + showBatterySettings = !batteryExempt, + onBatterySettings = { + RemoteControlBatteryOptimization.openSettings(context) + }, ) Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { @@ -169,6 +207,38 @@ fun PrairieCastRemoteScreen( controller = controller, ) } + + if (showBatteryPrompt) { + AlertDialog( + onDismissRequest = { + RemoteControlBatteryOptimization.markPromptShown(context) + showBatteryPrompt = false + }, + title = { Text(stringResource(R.string.remote_battery_title)) }, + text = { Text(stringResource(R.string.remote_battery_message)) }, + confirmButton = { + TextButton( + onClick = { + RemoteControlBatteryOptimization.markPromptShown(context) + showBatteryPrompt = false + RemoteControlBatteryOptimization.openSettings(context) + }, + ) { + Text(stringResource(R.string.remote_battery_settings)) + } + }, + dismissButton = { + TextButton( + onClick = { + RemoteControlBatteryOptimization.markPromptShown(context) + showBatteryPrompt = false + }, + ) { + Text(stringResource(R.string.remote_battery_not_now)) + } + }, + ) + } } /** Full-bleed blurred-artwork backdrop, falling back to flat OLED black. */ @@ -199,6 +269,8 @@ private fun RemoteTopBar( onChooseTv: () -> Unit, onStopPlayback: () -> Unit, onDisconnect: () -> Unit, + showBatterySettings: Boolean, + onBatterySettings: () -> Unit, ) { var menuExpanded by remember { mutableStateOf(false) } Row( @@ -241,6 +313,18 @@ private fun RemoteTopBar( onStopPlayback() }, ) + if (showBatterySettings) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remote_battery_settings)) }, + leadingIcon = { + Icon(Icons.Outlined.SettingsRemote, contentDescription = null) + }, + onClick = { + menuExpanded = false + onBatterySettings() + }, + ) + } HorizontalDivider() DropdownMenuItem( text = { Text("Disconnect", color = MaterialTheme.colorScheme.error) }, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt index b799366e2..f4d4718aa 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,7 +32,6 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.Layers import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.outlined.KeyboardArrowDown @@ -44,6 +44,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -70,6 +71,7 @@ import org.prairieserver.prairie.android.ui.theme.PillShape import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay // ── Tokens ──────────────────────────────────────────────────── @@ -90,6 +92,276 @@ fun detailScreenBackgroundBrush(dominantColor: Color): Brush = 1.00f to Color.Transparent, ) +private val ExpandedDetailBreakpoint = 600.dp + +data class DetailPortraitArtwork( + val url: String?, + val thumbhash: String?, + val reserveSpace: Boolean = false, +) + +/** + * Switches movie and series details from the compact phone hero to a + * poster-led cinematic composition when an unfolded or otherwise large + * window has enough horizontal room. Keeping the decision inside the + * composable makes folding, unfolding, and freeform-window resizing update + * the layout without changing navigation or screen state. + */ +@Composable +fun AdaptiveDetailHero( + detail: ItemDetail, + eyebrow: String?, + sourceTokens: List, + factsLine: List, + portraitArtwork: DetailPortraitArtwork = DetailPortraitArtwork( + url = detail.posterUrl, + thumbhash = detail.posterThumbhash, + ), + modifier: Modifier = Modifier, + dominantColor: Color = PrairieBackground, + directorText: String? = null, + translation: (@Composable () -> Unit)? = null, + actions: @Composable () -> Unit, +) { + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + if (maxWidth >= ExpandedDetailBreakpoint) { + val horizontalPadding = if (maxWidth >= 840.dp) 48.dp else 32.dp + val posterWidth = (maxWidth * 0.25f).coerceIn(164.dp, 224.dp) + ExpandedDetailHero( + detail = detail, + portraitArtwork = portraitArtwork, + eyebrow = eyebrow, + sourceTokens = sourceTokens, + factsLine = factsLine, + horizontalPadding = horizontalPadding, + posterWidth = posterWidth, + directorText = directorText, + translation = translation, + actions = actions, + ) + } else { + DetailHero( + detail = detail, + eyebrow = eyebrow, + sourceTokens = sourceTokens, + factsLine = factsLine, + dominantColor = dominantColor, + directorText = directorText, + translation = translation, + actions = actions, + ) + } + } +} + +/** + * Expanded-window detail hero inspired by the reference foldable layout: + * a full-bleed backdrop carries the page while the poster and editorial + * metadata form a readable two-column foreground. + */ +@Composable +private fun ExpandedDetailHero( + detail: ItemDetail, + portraitArtwork: DetailPortraitArtwork, + eyebrow: String?, + sourceTokens: List, + factsLine: List, + horizontalPadding: Dp, + posterWidth: Dp, + directorText: String?, + translation: (@Composable () -> Unit)?, + actions: @Composable () -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth()) { + ThumbhashImage( + url = detail.backdropUrl, + thumbhash = detail.backdropThumbhash, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.horizontalGradient( + 0.00f to Color.Black.copy(alpha = 0.88f), + 0.48f to Color.Black.copy(alpha = 0.58f), + 1.00f to Color.Black.copy(alpha = 0.32f), + ), + ), + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0.00f to Color.Black.copy(alpha = 0.08f), + 0.68f to Color.Black.copy(alpha = 0.18f), + 1.00f to PrairieBackground, + ), + ), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(top = 88.dp, bottom = 40.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.Top, + ) { + if (portraitArtwork.reserveSpace || !portraitArtwork.url.isNullOrBlank()) { + Box( + modifier = Modifier + .width(posterWidth) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(12.dp), + ) + .heroTarget(), + ) { + if (!portraitArtwork.url.isNullOrBlank()) { + ThumbhashImage( + url = portraitArtwork.url, + thumbhash = portraitArtwork.thumbhash, + contentDescription = detail.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!eyebrow.isNullOrBlank()) { + EyebrowChip(text = eyebrow) + } + ExpandedHeroTitle(detail = detail) + if (sourceTokens.isNotEmpty() || detail.contentRating != null) { + SourceRow( + tokens = sourceTokens, + ratingChip = detail.contentRating, + horizontalAlignment = Alignment.Start, + ) + } + if (factsLine.isNotEmpty()) { + FactsRow( + tokens = factsLine, + horizontalAlignment = Alignment.Start, + ) + } + actions() + detail.overview?.takeIf { it.isNotBlank() }?.let { overview -> + OverviewBlock(text = overview) + } + translation?.invoke() + directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun ExpandedHeroTitle(detail: ItemDetail) { + val isEpisode = detail.type == "episode" + val seriesTitle = detail.seriesTitle?.takeIf { it.isNotBlank() } + if (isEpisode && seriesTitle != null) { + val (episodePrimary, episodeSubtitle) = splitHeroTitle(detail.title) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = seriesTitle, + fontSize = 34.sp, + lineHeight = 39.sp, + fontWeight = FontWeight.ExtraBold, + color = DetailPrimaryText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = episodePrimary, + fontSize = 22.sp, + lineHeight = 27.sp, + fontWeight = FontWeight.SemiBold, + color = DetailPrimaryText.copy(alpha = 0.9f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (episodeSubtitle != null) { + Text( + text = episodeSubtitle.uppercase(), + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.0.sp, + color = DetailPrimaryText.copy(alpha = 0.76f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + return + } + + val logoUrl = detail.logoUrl + if (!logoUrl.isNullOrBlank()) { + ThumbhashImage( + url = logoUrl, + thumbhash = null, + contentDescription = detail.title, + contentScale = ContentScale.Fit, + transparent = true, + modifier = Modifier + .fillMaxWidth(0.72f) + .height(112.dp), + ) + return + } + + val (primary, subtitle) = splitHeroTitle(detail.title) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = primary, + fontSize = 36.sp, + lineHeight = 41.sp, + fontWeight = FontWeight.ExtraBold, + color = DetailPrimaryText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle.uppercase(), + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 1.2.sp, + color = DetailPrimaryText.copy(alpha = 0.8f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + // ── Hero ────────────────────────────────────────────────────── /** @@ -107,6 +379,7 @@ fun DetailHero( factsLine: List, modifier: Modifier = Modifier, dominantColor: Color = PrairieBackground, + directorText: String? = null, // Optional viewer-facing description-translation affordance, rendered // directly under the overview (Apple parity: DescriptionTranslationView). translation: (@Composable () -> Unit)? = null, @@ -144,6 +417,18 @@ fun DetailHero( OverviewBlock(text = overview) } translation?.invoke() + directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.62f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } if (factsLine.isNotEmpty()) { FactsRow(tokens = factsLine) } @@ -338,12 +623,16 @@ private fun EyebrowChip(text: String) { } @Composable -private fun SourceRow(tokens: List, ratingChip: String?) { +private fun SourceRow( + tokens: List, + ratingChip: String?, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, +) { // iOS PhoneDetailHero.sourceRow: HStack spacing 8, tokens 14pt medium // (0.85 alpha), middle-dot separators 14pt semibold (0.4 alpha). Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(8.dp, horizontalAlignment), verticalAlignment = Alignment.CenterVertically, ) { tokens.forEachIndexed { index, token -> @@ -417,14 +706,17 @@ private fun OverviewBlock(text: String) { } @Composable -private fun FactsRow(tokens: List) { +private fun FactsRow( + tokens: List, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, +) { // iOS FlowingFactsRow: tokens 13pt medium (0.78 alpha), middle-dot // separators 13pt semibold (0.4 alpha), spacing 8, top pad 4. Row( modifier = Modifier .fillMaxWidth() .padding(top = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(8.dp, horizontalAlignment), verticalAlignment = Alignment.CenterVertically, ) { tokens.forEachIndexed { index, token -> @@ -721,29 +1013,13 @@ fun HeroActionStack( TextButton(onClick = { showResumeDialog = false onPlay() - }) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Resume") - } + }) { Text("Resume") } }, dismissButton = { TextButton(onClick = { showResumeDialog = false onPlayFromBeginning() - }) { - Icon( - imageVector = Icons.Default.Replay, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Play from Beginning") - } + }) { Text("Play from Beginning") } }, ) } @@ -815,7 +1091,34 @@ fun SeasonChips( ) { if (seasons.size <= 1) return + val selectedSeasonIndex = seasons.indexOfFirst { + it.seasonNumber == selectedSeasonNumber + } + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = selectedSeasonIndex.coerceAtLeast(0), + ) + + // Pager swipes can move beyond the chips visible on compact cover screens. + // Follow the shared selection, but leave the row alone while its chip is + // already fully visible so nearby swipes do not cause needless movement. + LaunchedEffect(selectedSeasonIndex, seasons.size) { + if (selectedSeasonIndex < 0) return@LaunchedEffect + + val layoutInfo = listState.layoutInfo + val selectedItem = layoutInfo.visibleItemsInfo.firstOrNull { + it.index == selectedSeasonIndex + } + val isFullyVisible = selectedItem != null && + selectedItem.offset >= layoutInfo.viewportStartOffset && + selectedItem.offset + selectedItem.size <= layoutInfo.viewportEndOffset + + if (!isFullyVisible) { + listState.animateScrollToItem(selectedSeasonIndex) + } + } + LazyRow( + state = listState, contentPadding = PaddingValues(horizontal = SafePadding), horizontalArrangement = Arrangement.spacedBy(SmallPadding), modifier = modifier.fillMaxWidth(), @@ -826,7 +1129,7 @@ fun SeasonChips( contentType = { "season-chip" }, ) { season -> val isSelected = season.seasonNumber == selectedSeasonNumber - val label = if (season.isSpecials) "Specials" else "Season ${season.seasonNumber}" + val label = phoneSeasonLabel(season) // iOS PhoneSeasonChips: 14pt (semibold selected / medium // unselected), hpad 16, height 36, unselected fill white-0.06. Surface( @@ -871,8 +1174,8 @@ object HeroMetadata { val s = detail.seasonNumber val e = detail.episodeNumber return when { - s != null && e != null -> "Season $s · Episode $e" - s != null -> "Season $s" + s != null && e != null -> "${phoneSeasonNumberLabel(s)} · Episode $e" + s != null -> phoneSeasonNumberLabel(s) else -> null } } @@ -917,6 +1220,12 @@ object HeroMetadata { } } +internal fun phoneSeasonLabel(season: Season): String = + if (season.isSpecialsForDisplay()) "Specials" else phoneSeasonNumberLabel(season.seasonNumber) + +private fun phoneSeasonNumberLabel(seasonNumber: Int): String = + if (seasonNumber == 0) "Specials" else "Season $seasonNumber" + // ── Play label helper ───────────────────────────────────────── // iOS parity: the play-button label stays neutral ("Play" / "Play S·E") even diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt index 06b41abf8..561e735b6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt @@ -35,6 +35,7 @@ import org.prairieserver.prairie.android.downloads.LEGACY_PUBLIC_DOWNLOAD_PERMIS import org.prairieserver.prairie.android.downloads.hasLegacyPublicDownloadPermission import org.prairieserver.prairie.android.ui.components.DetailLoadingSkeleton import org.prairieserver.prairie.android.ui.components.ErrorView +import org.prairieserver.prairie.android.ui.components.swipeBackToDismiss import org.prairieserver.prairie.android.ui.screens.cast.PrairieCastTargetPickerSheet import org.prairieserver.prairie.android.ui.screens.downloads.openDownloadTargetInExternalApp import org.prairieserver.prairie.android.ui.screens.watchtogether.SuggestToRoomViewModel @@ -292,6 +293,10 @@ fun ItemDetailScreen( Box( modifier = modifier .fillMaxSize() + // Swipe right on the page to go back (iOS interactive pop) — a + // lighter alternative to reaching for the back arrow on a tall + // detail page. + .swipeBackToDismiss(onDismiss = onBackClick) .background(MaterialTheme.colorScheme.background), ) { when { @@ -556,9 +561,11 @@ fun ItemDetailScreen( SeriesDetailContent( translation = translationSlot, detail = detail, + similarItems = state.similarItems, seasons = state.seasons, selectedSeasonNumber = state.selectedSeasonNumber, episodes = state.episodes, + episodesBySeason = state.episodesBySeason, isLoadingEpisodes = state.isLoadingEpisodes, isFavorite = state.isFavorite, isInWatchlist = state.isInWatchlist, @@ -648,7 +655,11 @@ fun ItemDetailScreen( ?: playbackResumePosition(detail.userData), ) }, - onSuggestToRoom = if (suggestRoom != null && nextEpisode != null) { + onSuggestToRoom = if ( + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && + suggestRoom != null && + nextEpisode != null + ) { { suggestViewModel.suggest( contentId = nextEpisode.contentId, @@ -672,6 +683,28 @@ fun ItemDetailScreen( else -> { val seriesId = detail.seriesId val seasonNumber = detail.seasonNumber + val episodeSeason = if (detail.type == "episode") { + state.seasons.firstOrNull { it.seasonNumber == seasonNumber } + } else { + null + } + val seasonPosterUrl = episodeSeason?.posterUrl?.takeIf { it.isNotBlank() } + val portraitArtwork = if (detail.type == "episode") { + DetailPortraitArtwork( + url = seasonPosterUrl ?: state.episodeSeriesPosterUrl, + thumbhash = if (seasonPosterUrl != null) { + episodeSeason?.posterThumbhash + } else { + state.episodeSeriesPosterThumbhash + }, + reserveSpace = true, + ) + } else { + DetailPortraitArtwork( + url = detail.posterUrl, + thumbhash = detail.posterThumbhash, + ) + } // Derive download state for the currently-selected // version. Re-reads on every UI emission so the // worker's upsertLocal progress + status transitions @@ -727,6 +760,8 @@ fun ItemDetailScreen( MovieDetailContent( translation = translationSlot, detail = detail, + similarItems = state.similarItems, + portraitArtwork = portraitArtwork, isFavorite = state.isFavorite, isInWatchlist = state.isInWatchlist, selectedVersionIndex = videoDisplayVersionIndex, @@ -782,6 +817,7 @@ fun ItemDetailScreen( seasons = state.seasons, selectedSeasonNumber = state.selectedSeasonNumber, episodes = state.episodes, + episodesBySeason = state.episodesBySeason, isLoadingEpisodes = state.isLoadingEpisodes, onSeasonSelected = { viewModel.selectSeason(it) }, onEpisodePlayClick = { contentId, resumePositionSeconds -> @@ -846,7 +882,9 @@ fun ItemDetailScreen( resumePositionSeconds = playbackResumePosition(detail.userData), ) }, - onSuggestToRoom = suggestRoom?.let { + onSuggestToRoom = if ( + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && suggestRoom != null + ) { { suggestViewModel.suggest( contentId = detail.contentId, @@ -856,6 +894,8 @@ fun ItemDetailScreen( posterUrl = detail.posterUrl, ) } + } else { + null }, onWatchTogether = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { { onWatchTogether(detail.contentId, explicitFileId) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt index 31d3bd328..04d0a5a14 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt @@ -9,6 +9,7 @@ import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan import org.prairieserver.prairie.model.catalog.sortedForDisplay import org.prairieserver.prairie.model.download.DownloadCapability import org.prairieserver.prairie.model.download.DownloadRecord @@ -22,6 +23,7 @@ import org.prairieserver.prairie.repository.MetadataAiRepository import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.repository.EbookReaderRepository import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.RecommendationRepository import org.prairieserver.prairie.viewmodel.applyLocalPlaybackProgress import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.playback.SUBTITLE_OFF_FINGERPRINT @@ -37,6 +39,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.update import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -46,9 +51,19 @@ import kotlinx.coroutines.launch data class ItemDetailUiState( val isLoading: Boolean = true, val detail: ItemDetail? = null, + val similarItems: List = emptyList(), val seasons: List = emptyList(), val selectedSeasonNumber: Int = 1, val episodes: List = emptyList(), + /** Parent-series portrait art used when an episode's own artwork is a wide still. */ + val episodeSeriesPosterUrl: String? = null, + val episodeSeriesPosterThumbhash: String? = null, + /** + * Route-scoped episode lists keyed by season. Unlike the repository's + * durable network-fallback cache, this map is UI-first: once a season has + * loaded, chip taps and pager swipes can reuse it without another request. + */ + val episodesBySeason: Map> = emptyMap(), val isLoadingEpisodes: Boolean = false, /** First-file ids of EVERY episode across ALL seasons (loaded once for the * series-level downloaded roll-up — the per-season `episodes` only covers @@ -111,6 +126,7 @@ class ItemDetailViewModel( private val downloadsRepository: DownloadsRepository, private val downloadEnqueuer: DownloadEnqueuer, private val ebookReaderRepository: EbookReaderRepository, + private val recommendationRepository: RecommendationRepository, metadataAiRepository: MetadataAiRepository, savedStateHandle: SavedStateHandle, private val userItemState: org.prairieserver.prairie.repository.port.UserItemStatePort = @@ -342,6 +358,7 @@ class ItemDetailViewModel( } // Restore a persisted audio/subtitle override for this item. seedPersistedTrackSelection() + viewModelScope.launch { loadSimilar(detail) } // For series, load seasons if (detail.type == "series") { loadSeasons(detail.contentId) @@ -385,6 +402,35 @@ class ItemDetailViewModel( } } + private suspend fun loadSimilar(detail: ItemDetail) { + if (detail.type == "episode" || _uiState.value.similarItems.isNotEmpty()) return + + val scored = when ( + val result = recommendationRepository.getSimilar(detail.contentId, limit = 12) + ) { + is ApiResult.Success -> result.data.items + else -> return + } + if (scored.isEmpty()) return + + val items = coroutineScope { + scored + .map { ref -> + async { + when (val result = catalogRepository.getItemDetail(ref.mediaItemId)) { + is ApiResult.Success -> result.data + else -> null + } + } + } + .awaitAll() + .filterNotNull() + } + if (items.isNotEmpty()) { + _uiState.update { it.copy(similarItems = items) } + } + } + /** * Quiet refresh for returning to an already-loaded detail screen (e.g. * backing out of the player): re-reads userData so the Play button's @@ -417,9 +463,19 @@ class ItemDetailViewModel( } } if (current.type == "series") { - loadEpisodes(current.contentId, _uiState.value.selectedSeasonNumber) + loadEpisodes( + current.contentId, + _uiState.value.selectedSeasonNumber, + forceRefresh = true, + ) } else if (current.type == "episode") { - current.seriesId?.let { loadEpisodes(it, _uiState.value.selectedSeasonNumber) } + current.seriesId?.let { + loadEpisodes( + it, + _uiState.value.selectedSeasonNumber, + forceRefresh = true, + ) + } } } @@ -464,23 +520,21 @@ class ItemDetailViewModel( viewModelScope.launch { when (val result = catalogRepository.getSeasons(seriesId)) { is ApiResult.Success -> { - val seasons = result.data.seasons.sortedForDisplay() - val selectedSeason = seasons.firstOrNull { it.seasonNumber == initialSeasonNumber } - ?: seasons.firstOrNull() + val plan = result.data.seasons.initialSeasonDisplayPlan(initialSeasonNumber) _uiState.update { it.copy( - seasons = seasons, - selectedSeasonNumber = selectedSeason?.seasonNumber ?: 1, + seasons = plan.seasons, + selectedSeasonNumber = plan.selectedSeasonNumber ?: 1, ) } - if (selectedSeason != null) { + plan.episodeRequestSeasonNumber?.let { seasonNumber -> loadEpisodes( seriesId = seriesId, - seasonNumber = selectedSeason.seasonNumber, - seasonsForDownloadRollup = seasons, + seasonNumber = seasonNumber, + seasonsForDownloadRollup = plan.seasons, ) - } else { - loadAllEpisodeFileIds(seriesId, seasons) + } ?: run { + loadAllEpisodeFileIds(seriesId, plan.seasons) } } else -> { /* Season load failure is non-critical */ } @@ -536,7 +590,9 @@ class ItemDetailViewModel( if (!routeActive) return@launch when (val r = catalogRepository.getEpisodes(seriesId, season.seasonNumber)) { is ApiResult.Success -> { - accumulator.recordSeason(season.seasonNumber, r.data.episodes) + val episodes = withLocalProgress(r.data.episodes) + cacheEpisodes(season.seasonNumber, episodes) + accumulator.recordSeason(season.seasonNumber, episodes) _uiState.update { it.copy( allEpisodeFileIds = accumulator.fileIds, @@ -594,6 +650,21 @@ class ItemDetailViewModel( } else -> { /* Season load failure is non-critical */ } } + + // Resolve seasons before the series fallback. Otherwise a cache-fast + // series poster can paint for a frame and then be replaced by the + // selected season poster when its request completes. + when (val result = catalogRepository.getItemDetailForPrefetch(seriesId)) { + is ApiResult.Success -> { + _uiState.update { + it.copy( + episodeSeriesPosterUrl = result.data.posterUrl, + episodeSeriesPosterThumbhash = result.data.posterThumbhash, + ) + } + } + else -> { /* Series poster fallback is optional. */ } + } } } @@ -606,7 +677,14 @@ class ItemDetailViewModel( // Optimistic write; a failed load reverts to [loadedSeasonNumber] so // the new season header can't sit above the old season's still-loaded // episodes (see loadEpisodes' error branches). - _uiState.update { it.copy(selectedSeasonNumber = seasonNumber) } + _uiState.update { + val cachedEpisodes = it.episodesBySeason[seasonNumber] + it.copy( + selectedSeasonNumber = seasonNumber, + episodes = cachedEpisodes.orEmpty(), + isLoadingEpisodes = cachedEpisodes == null, + ) + } val detail = _uiState.value.detail ?: return val seriesId = if (detail.type == "series") detail.contentId else detail.seriesId ?: return loadEpisodes(seriesId, seasonNumber) @@ -616,18 +694,50 @@ class ItemDetailViewModel( seriesId: String, seasonNumber: Int, seasonsForDownloadRollup: List? = null, + forceRefresh: Boolean = false, ) { episodeLoadJob?.cancel() + val cachedEpisodes = _uiState.value.episodesBySeason[seasonNumber] + if (!forceRefresh && cachedEpisodes != null) { + loadedSeasonNumber = seasonNumber + _uiState.update { + it.copy( + selectedSeasonNumber = seasonNumber, + episodes = cachedEpisodes, + isLoadingEpisodes = false, + ) + } + seasonsForDownloadRollup?.let { seasons -> + loadAllEpisodeFileIds( + seriesId = seriesId, + seasons = seasons, + seedEpisodes = cachedEpisodes, + skipSeasonNumber = seasonNumber, + ) + } + return + } episodeLoadJob = viewModelScope.launch { - _uiState.update { it.copy(isLoadingEpisodes = true) } + _uiState.update { + it.copy( + isLoadingEpisodes = true, + episodes = if (it.selectedSeasonNumber == seasonNumber) { + cachedEpisodes.orEmpty() + } else { + it.episodes + }, + ) + } when (val result = catalogRepository.getEpisodes(seriesId, seasonNumber)) { is ApiResult.Success -> { val episodes = withLocalProgress(result.data.episodes) loadedSeasonNumber = seasonNumber _uiState.update { + val cache = it.episodesBySeason + (seasonNumber to episodes) it.copy( isLoadingEpisodes = false, - episodes = episodes, + episodesBySeason = cache, + episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, ) } seasonsForDownloadRollup?.let { seasons -> @@ -642,20 +752,13 @@ class ItemDetailViewModel( // Failed season switch: revert the optimistic selection to the // season whose episodes are actually on screen. A // successful-but-empty season keeps the new selection (empty state). - is ApiResult.Error -> { + is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { + val fallbackSeasonNumber = loadedSeasonNumber ?: it.selectedSeasonNumber it.copy( isLoadingEpisodes = false, - selectedSeasonNumber = loadedSeasonNumber ?: it.selectedSeasonNumber, - ) - } - seasonsForDownloadRollup?.let { loadAllEpisodeFileIds(seriesId, it) } - } - is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - isLoadingEpisodes = false, - selectedSeasonNumber = loadedSeasonNumber ?: it.selectedSeasonNumber, + selectedSeasonNumber = fallbackSeasonNumber, + episodes = it.episodesBySeason[fallbackSeasonNumber].orEmpty(), ) } seasonsForDownloadRollup?.let { loadAllEpisodeFileIds(seriesId, it) } @@ -664,6 +767,19 @@ class ItemDetailViewModel( } } + private fun cacheEpisodes( + seasonNumber: Int, + episodes: List, + ) { + _uiState.update { + val cache = it.episodesBySeason + (seasonNumber to episodes) + it.copy( + episodesBySeason = cache, + episodes = if (it.selectedSeasonNumber == seasonNumber) episodes else it.episodes, + ) + } + } + private suspend fun withLocalProgress(detail: ItemDetail): ItemDetail = applyLocalPlaybackProgress(detail, userItemState.localPlaybackProgress(detail.contentId)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt index bd609e9cd..744066044 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MediaSelectors.kt @@ -4,14 +4,21 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -36,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.theme.DarkOutline +import org.prairieserver.prairie.android.ui.theme.DarkSurface import org.prairieserver.prairie.android.ui.theme.DarkSurfaceVariant import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.FileVersion @@ -47,6 +55,13 @@ import org.prairieserver.prairie.player.DolbyVisionDetection * Subtitles) — the phone counterpart of the TV detail's selector row. * Icon + group label on the left, the current value (ellipsized) and a * chevron on the right; tap opens the matching bottom-sheet picker. + * + * [interactive] = false when the group holds a single real choice (one + * version, one audio track, one subtitle track), mirroring Apple's + * `DetailPlaybackFormatting.shouldEnable*Selector`. The row then keeps its + * box and its value but drops the chevron and the tap target: a picker whose + * only outcome is the value already printed is a dead end, not a choice. The + * "Auto"/"Off" rows the sheets prepend are pseudo-entries and do not count. */ @Composable fun TrackSelectorRow( @@ -55,6 +70,7 @@ fun TrackSelectorRow( value: String, onClick: () -> Unit, modifier: Modifier = Modifier, + interactive: Boolean = true, ) { Row( modifier = modifier @@ -62,7 +78,7 @@ fun TrackSelectorRow( .clip(RoundedCornerShape(8.dp)) .background(DarkSurfaceVariant.copy(alpha = 0.7f)) .border(1.dp, DarkOutline, RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) + .then(if (interactive) Modifier.clickable(onClick = onClick) else Modifier) .padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -89,12 +105,14 @@ fun TrackSelectorRow( textAlign = TextAlign.End, modifier = Modifier.weight(1f), ) - Icon( - imageVector = Icons.Outlined.KeyboardArrowDown, - contentDescription = "Select", - modifier = Modifier.size(14.dp), - tint = Color.White.copy(alpha = 0.62f), - ) + if (interactive) { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = "Select", + modifier = Modifier.size(14.dp), + tint = Color.White.copy(alpha = 0.62f), + ) + } } } @@ -108,47 +126,30 @@ fun VersionPickerSheet( onSelect: (Int?) -> Unit, onDismiss: () -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - ) { - PickerHeader(title = "Select Version") - - LazyColumn( - modifier = Modifier.heightIn(max = 400.dp), - contentPadding = PaddingValues(bottom = 32.dp), - ) { - item { - PickerItem( - title = "Auto", - subtitle = "Best available version", - badges = emptyList(), - isSelected = selectedIndex == null, - onClick = { onSelect(null) }, - ) - if (versions.isNotEmpty()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + PickerSheetScaffold(title = "Select Version", onDismiss = onDismiss) { + item { + PickerItem( + title = "Auto", + subtitle = "Best available version", + badges = emptyList(), + isSelected = selectedIndex == null, + onClick = { onSelect(null) }, + ) + if (versions.isNotEmpty()) { + HorizontalDivider(color = DarkOutline) } + } - itemsIndexed(versions) { index, version -> - PickerItem( - title = formatVersionTitle(version), - subtitle = formatVersionSubtitle(version), - badges = buildVersionBadges(version), - isSelected = index == selectedIndex, - onClick = { onSelect(index) }, - ) - if (index < versions.lastIndex) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + itemsIndexed(versions) { index, version -> + PickerItem( + title = formatVersionTitle(version), + subtitle = formatVersionSubtitle(version), + badges = buildVersionBadges(version), + isSelected = index == selectedIndex, + onClick = { onSelect(index) }, + ) + if (index < versions.lastIndex) { + HorizontalDivider(color = DarkOutline) } } } @@ -162,47 +163,30 @@ fun AudioPickerSheet( onSelect: (Int?) -> Unit, onDismiss: () -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - ) { - PickerHeader(title = "Select Audio Track") - - LazyColumn( - modifier = Modifier.heightIn(max = 400.dp), - contentPadding = PaddingValues(bottom = 32.dp), - ) { - item { - PickerItem( - title = "Auto", - subtitle = "Use the file default track", - badges = emptyList(), - isSelected = selectedIndex == null, - onClick = { onSelect(null) }, - ) - if (tracks.isNotEmpty()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + PickerSheetScaffold(title = "Select Audio Track", onDismiss = onDismiss) { + item { + PickerItem( + title = "Auto", + subtitle = "Use the file default track", + badges = emptyList(), + isSelected = selectedIndex == null, + onClick = { onSelect(null) }, + ) + if (tracks.isNotEmpty()) { + HorizontalDivider(color = DarkOutline) } + } - itemsIndexed(tracks) { index, track -> - PickerItem( - title = formatAudioTitle(track, index), - subtitle = formatAudioSubtitle(track), - badges = buildAudioBadges(track), - isSelected = index == selectedIndex, - onClick = { onSelect(index) }, - ) - if (index < tracks.lastIndex) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + itemsIndexed(tracks) { index, track -> + PickerItem( + title = formatAudioTitle(track, index), + subtitle = formatAudioSubtitle(track), + badges = buildAudioBadges(track), + isSelected = index == selectedIndex, + onClick = { onSelect(index) }, + ) + if (index < tracks.lastIndex) { + HorizontalDivider(color = DarkOutline) } } } @@ -216,76 +200,96 @@ fun SubtitlePickerSheet( onSelect: (Int?) -> Unit, onDismiss: () -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.surface, - ) { - PickerHeader(title = "Select Subtitles") - - LazyColumn( - modifier = Modifier.heightIn(max = 400.dp), - contentPadding = PaddingValues(bottom = 32.dp), - ) { - item { - PickerItem( - title = "Auto", - subtitle = "Use the file default track", - badges = emptyList(), - isSelected = selectedIndex == null, - onClick = { onSelect(null) }, - ) - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + PickerSheetScaffold(title = "Select Subtitles", onDismiss = onDismiss) { + item { + PickerItem( + title = "Auto", + subtitle = "Use the file default track", + badges = emptyList(), + isSelected = selectedIndex == null, + onClick = { onSelect(null) }, + ) + HorizontalDivider(color = DarkOutline) + } - // Off option - item { - PickerItem( - title = "Off", - subtitle = "No subtitles", - badges = emptyList(), - isSelected = selectedIndex == -1, - onClick = { onSelect(-1) }, - ) - if (tracks.isNotEmpty()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + // Off option + item { + PickerItem( + title = "Off", + subtitle = "No subtitles", + badges = emptyList(), + isSelected = selectedIndex == -1, + onClick = { onSelect(-1) }, + ) + if (tracks.isNotEmpty()) { + HorizontalDivider(color = DarkOutline) } + } - itemsIndexed(tracks) { index, track -> - PickerItem( - title = formatSubtitleTitle(track, index), - subtitle = formatSubtitleSubtitle(track), - badges = buildSubtitleBadges(track), - isSelected = index == selectedIndex, - onClick = { onSelect(index) }, - ) - if (index < tracks.lastIndex) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + itemsIndexed(tracks) { index, track -> + PickerItem( + title = formatSubtitleTitle(track, index), + subtitle = formatSubtitleSubtitle(track), + badges = buildSubtitleBadges(track), + isSelected = index == selectedIndex, + onClick = { onSelect(index) }, + ) + if (index < tracks.lastIndex) { + HorizontalDivider(color = DarkOutline) } } } } +/** + * Shared bottom-sheet chrome for the three playback pickers, matching the + * detail page's card language: a plain header followed by a bordered, + * rounded options card (same DarkSurfaceVariant @0.7 + 1dp DarkOutline + * treatment as [TrackSelectorRow]) instead of a full-bleed M3 list. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PickerHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +private fun PickerSheetScaffold( + title: String, + onDismiss: () -> Unit, + content: LazyListScope.() -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = DarkSurface, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 20.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(DarkSurfaceVariant.copy(alpha = 0.7f)) + .border(1.dp, DarkOutline, RoundedCornerShape(12.dp)), + ) { + LazyColumn( + modifier = Modifier.heightIn(max = 420.dp), + content = content, + ) + } + + // Insets first, then the fixed gap — the other order would clamp the + // spacer to 16.dp and swallow the nav-bar inset entirely. + Spacer( + modifier = Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .height(16.dp), + ) + } } @Composable @@ -301,11 +305,12 @@ private fun PickerItem( .fillMaxWidth() .clickable(onClick = onClick) .background(if (isSelected) Color.White.copy(alpha = 0.06f) else Color.Transparent) - .padding(horizontal = 20.dp, vertical = 14.dp), + .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -314,6 +319,9 @@ private fun PickerItem( style = MaterialTheme.typography.bodyLarge, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) badges.forEach { badge -> BadgePill(text = badge) @@ -324,17 +332,22 @@ private fun PickerItem( text = subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp), ) } } - if (isSelected) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.size(20.dp), - ) + Spacer(modifier = Modifier.width(12.dp)) + Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { + if (isSelected) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt index a655c7192..7d733d5a6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.prairieserver.prairie.android.ui.theme.PrairieBackground import org.prairieserver.prairie.android.ui.util.rememberDominantColor +import org.prairieserver.prairie.common.ui.movieDirectorCredit import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.Season @@ -57,6 +58,11 @@ import org.prairieserver.prairie.model.catalog.Season @Composable fun MovieDetailContent( detail: ItemDetail, + portraitArtwork: DetailPortraitArtwork = DetailPortraitArtwork( + url = detail.posterUrl, + thumbhash = detail.posterThumbhash, + ), + similarItems: List = emptyList(), isFavorite: Boolean, isInWatchlist: Boolean, selectedVersionIndex: Int, @@ -84,6 +90,7 @@ fun MovieDetailContent( seasons: List = emptyList(), selectedSeasonNumber: Int = 1, episodes: List = emptyList(), + episodesBySeason: Map> = emptyMap(), isLoadingEpisodes: Boolean = false, onSeasonSelected: (Int) -> Unit = {}, onEpisodePlayClick: (String, Double?) -> Unit = { _, _ -> }, @@ -133,12 +140,14 @@ fun MovieDetailContent( verticalArrangement = Arrangement.spacedBy(36.dp), ) { item(contentType = "detail-hero") { - DetailHero( + AdaptiveDetailHero( detail = detail, eyebrow = eyebrow, sourceTokens = sourceTokens, factsLine = factsLine, + portraitArtwork = portraitArtwork, dominantColor = dominantColor, + directorText = movieDirectorCredit(detail), translation = translation, ) { HeroActionStack( @@ -259,6 +268,11 @@ fun MovieDetailContent( label = "Video", value = formatVersionValueLabel(selectedVersion, isAutoVersion), onClick = { showVersionPicker = true }, + // Apple's shouldEnable*Selector: a picker is offered + // only when there is more than one real choice. The + // sheets' Auto/Off rows are pseudo-entries and do + // not count toward it. + interactive = detail.versions.size > 1, ) if (audioTracks.isNotEmpty()) { TrackSelectorRow( @@ -266,6 +280,7 @@ fun MovieDetailContent( label = "Audio", value = formatAudioValueLabel(audioTracks, selectedAudioIndex, selectedVersion?.effectiveAudioTrackIndex), onClick = { showAudioPicker = true }, + interactive = audioTracks.size > 1, ) } if (subtitleTracks.isNotEmpty()) { @@ -274,6 +289,7 @@ fun MovieDetailContent( label = "Subtitles", value = formatSubtitleValueLabel(subtitleTracks, selectedSubtitleIndex), onClick = { showSubtitlePicker = true }, + interactive = subtitleTracks.size > 1, ) } } @@ -295,43 +311,19 @@ fun MovieDetailContent( label = if (selectedSeasonNumber == 0) "Specials" else "Season $selectedSeasonNumber", title = "Episodes", ) - if (seasons.size > 1) { - SeasonChips( - seasons = seasons, - selectedSeasonNumber = selectedSeasonNumber, - onSeasonSelected = onSeasonSelected, - ) - } - when { - isLoadingEpisodes -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - episodes.isEmpty() -> { - Text( - text = "No episodes available", - style = MaterialTheme.typography.bodySmall, - color = DetailTertiaryText, - modifier = Modifier.padding(horizontal = SafePadding), - ) - } - else -> { - EpisodeList( - episodes = episodes, - onEpisodePlayClick = onEpisodePlayClick, - onEpisodeDetailClick = onEpisodeDetailClick, - onEpisodeDownloadClick = onEpisodeDownloadClick, - episodeDownloadState = episodeDownloadState, - highlightContentId = detail.contentId, - ) - } - } + SeasonEpisodePager( + seasons = seasons, + selectedSeasonNumber = selectedSeasonNumber, + episodes = episodes, + episodesBySeason = episodesBySeason, + isLoadingEpisodes = isLoadingEpisodes, + onSeasonSelected = onSeasonSelected, + onEpisodePlayClick = onEpisodePlayClick, + onEpisodeDetailClick = onEpisodeDetailClick, + onEpisodeDownloadClick = onEpisodeDownloadClick, + episodeDownloadState = episodeDownloadState, + highlightContentId = detail.contentId, + ) } } } @@ -359,7 +351,7 @@ fun MovieDetailContent( if (detail.type != "episode") { item(contentType = "detail-similar") { SimilarRail( - contentId = detail.contentId, + items = similarItems, onSelect = onItemDetailClick, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt new file mode 100644 index 000000000..43502306c --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonEpisodePager.kt @@ -0,0 +1,216 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.catalog.EpisodeListItem +import org.prairieserver.prairie.model.catalog.Season +import kotlin.math.absoluteValue + +/** + * Shared season selector and episode pager used by series and episode details. + * Loaded season pages come from the route-scoped ViewModel cache, so swiping + * back to a season does not issue another episode request. + */ +@Composable +internal fun SeasonEpisodePager( + seasons: List, + selectedSeasonNumber: Int, + episodes: List, + episodesBySeason: Map>, + isLoadingEpisodes: Boolean, + onSeasonSelected: (Int) -> Unit, + onEpisodePlayClick: (String, Double?) -> Unit, + onEpisodeDetailClick: (String) -> Unit, + onEpisodeDownloadClick: ((EpisodeListItem) -> Unit)?, + episodeDownloadState: (EpisodeListItem) -> DetailDownloadState, + highlightContentId: String? = null, +) { + if (seasons.size <= 1) { + SeasonEpisodePage( + episodes = episodes, + isLoading = isLoadingEpisodes, + onEpisodePlayClick = onEpisodePlayClick, + onEpisodeDetailClick = onEpisodeDetailClick, + onEpisodeDownloadClick = onEpisodeDownloadClick, + episodeDownloadState = episodeDownloadState, + highlightContentId = highlightContentId, + ) + return + } + + val initialPage = seasons.indexOfFirst { it.seasonNumber == selectedSeasonNumber } + .coerceAtLeast(0) + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { seasons.size }, + ) + val scope = rememberCoroutineScope() + val currentSelectedSeasonNumber = rememberUpdatedState(selectedSeasonNumber) + val currentOnSeasonSelected = rememberUpdatedState(onSeasonSelected) + + // A completed finger swipe becomes the shared season selection. Waiting + // for settledPage avoids loading a season when a partial drag snaps back. + // Keep this collector alive when a chip optimistically changes the shared + // selection: restarting it would immediately emit the still-old page and + // undo the chip tap before the pager animation can begin. + LaunchedEffect(pagerState, seasons) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { page -> + seasons.getOrNull(page) + ?.takeIf { it.seasonNumber != currentSelectedSeasonNumber.value } + ?.let { currentOnSeasonSelected.value(it.seasonNumber) } + } + } + + // Chip taps and ViewModel failure rollbacks drive the pager in the other + // direction. targetPage guards against cancelling an in-flight animation. + LaunchedEffect(selectedSeasonNumber, seasons) { + val selectedPage = seasons.indexOfFirst { it.seasonNumber == selectedSeasonNumber } + if (selectedPage >= 0 && pagerState.targetPage != selectedPage) { + pagerState.animateScrollToPage(selectedPage) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + SeasonChips( + seasons = seasons, + selectedSeasonNumber = selectedSeasonNumber, + onSeasonSelected = { seasonNumber -> + val page = seasons.indexOfFirst { it.seasonNumber == seasonNumber } + onSeasonSelected(seasonNumber) + if (page >= 0 && pagerState.targetPage != page) { + scope.launch { pagerState.animateScrollToPage(page) } + } + }, + ) + + // A pager with an unconstrained height sizes itself to the TALLEST page + // it has composed — with the neighbours kept alive, a long season next + // to a short one left the short season floating over empty space. + // Measure each page's real content height (unbounded, so a page taller + // than the pager still reports its full size) and size the pager to + // the current page, animated so season switches slide rather than jump. + val density = LocalDensity.current + val pageHeightsPx = remember(seasons) { mutableStateMapOf() } + val currentPageHeightPx = pageHeightsPx[pagerState.currentPage] + val pagerHeight by animateDpAsState( + targetValue = with(density) { (currentPageHeightPx ?: 0).toDp() }, + animationSpec = tween(durationMillis = 260), + label = "seasonPagerHeight", + ) + + HorizontalPager( + state = pagerState, + key = { page -> seasons[page].contentId }, + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .then(if (currentPageHeightPx != null) Modifier.height(pagerHeight) else Modifier), + ) { page -> + val season = seasons[page] + val cachedEpisodes = episodesBySeason[season.seasonNumber] + ?: episodes.takeIf { season.seasonNumber == selectedSeasonNumber } + val pageOffset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).absoluteValue.coerceIn(0f, 1f) + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.Top, unbounded = true), + ) { + SeasonEpisodePage( + episodes = cachedEpisodes.orEmpty(), + isLoading = cachedEpisodes == null && + (season.seasonNumber != selectedSeasonNumber || isLoadingEpisodes), + onEpisodePlayClick = onEpisodePlayClick, + onEpisodeDetailClick = onEpisodeDetailClick, + onEpisodeDownloadClick = onEpisodeDownloadClick, + episodeDownloadState = episodeDownloadState, + highlightContentId = highlightContentId, + modifier = Modifier + .onSizeChanged { pageHeightsPx[page] = it.height } + .graphicsLayer { + alpha = 1f - (pageOffset * 0.18f) + scaleX = 1f - (pageOffset * 0.015f) + scaleY = 1f - (pageOffset * 0.015f) + }, + ) + } + } + } +} + +@Composable +private fun SeasonEpisodePage( + episodes: List, + isLoading: Boolean, + onEpisodePlayClick: (String, Double?) -> Unit, + onEpisodeDetailClick: (String) -> Unit, + onEpisodeDownloadClick: ((EpisodeListItem) -> Unit)?, + episodeDownloadState: (EpisodeListItem) -> DetailDownloadState, + highlightContentId: String?, + modifier: Modifier = Modifier, +) { + when { + isLoading -> { + Box( + modifier = modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + episodes.isEmpty() -> { + Text( + text = "No episodes available", + style = MaterialTheme.typography.bodySmall, + color = DetailTertiaryText, + modifier = modifier.padding(horizontal = SafePadding), + ) + } + else -> { + EpisodeList( + episodes = episodes, + onEpisodePlayClick = onEpisodePlayClick, + onEpisodeDetailClick = onEpisodeDetailClick, + onEpisodeDownloadClick = onEpisodeDownloadClick, + episodeDownloadState = episodeDownloadState, + highlightContentId = highlightContentId, + modifier = modifier, + ) + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt index 2e95e9d1a..a97bce3c7 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt @@ -2,7 +2,6 @@ package org.prairieserver.prairie.android.ui.screens.detail import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -21,7 +20,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -44,9 +42,11 @@ import org.prairieserver.prairie.model.catalog.Season @Composable fun SeriesDetailContent( detail: ItemDetail, + similarItems: List = emptyList(), seasons: List, selectedSeasonNumber: Int, episodes: List, + episodesBySeason: Map>, isLoadingEpisodes: Boolean, isFavorite: Boolean, isInWatchlist: Boolean, @@ -101,7 +101,7 @@ fun SeriesDetailContent( verticalArrangement = Arrangement.spacedBy(36.dp), ) { item(contentType = "detail-hero") { - DetailHero( + AdaptiveDetailHero( detail = detail, eyebrow = eyebrow, sourceTokens = sourceTokens, @@ -187,7 +187,7 @@ fun SeriesDetailContent( verticalAlignment = Alignment.Bottom, ) { SectionHeader( - label = selectedSeason?.let { "Season ${it.seasonNumber}" } ?: "Episodes", + label = seriesSeasonSectionLabel(selectedSeason), title = "Episodes", trailingText = episodeCountSubtitle, modifier = Modifier.weight(1f), @@ -212,7 +212,10 @@ fun SeriesDetailContent( when { allDownloaded -> Icon( imageVector = Icons.Filled.DownloadDone, - contentDescription = "Season $seasonNumberForDownload downloaded", + contentDescription = seasonDownloadContentDescription( + selectedSeason, + isDownloaded = true, + ), tint = DetailPrimaryText, modifier = Modifier.size(24.dp), ) @@ -223,7 +226,10 @@ fun SeriesDetailContent( ) else -> Icon( imageVector = Icons.Outlined.FileDownload, - contentDescription = "Download season $seasonNumberForDownload", + contentDescription = seasonDownloadContentDescription( + selectedSeason, + isDownloaded = false, + ), tint = DetailPrimaryText, modifier = Modifier.size(24.dp), ) @@ -231,42 +237,18 @@ fun SeriesDetailContent( } } } - if (seasons.size > 1) { - SeasonChips( - seasons = seasons, - selectedSeasonNumber = selectedSeasonNumber, - onSeasonSelected = onSeasonSelected, - ) - } - when { - isLoadingEpisodes -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - episodes.isEmpty() -> { - Text( - text = "No episodes available", - style = MaterialTheme.typography.bodySmall, - color = DetailTertiaryText, - modifier = Modifier.padding(horizontal = SafePadding), - ) - } - else -> { - EpisodeList( - episodes = episodes, - onEpisodePlayClick = onEpisodePlayClick, - onEpisodeDetailClick = onEpisodeDetailClick, - onEpisodeDownloadClick = onEpisodeDownloadClick, - episodeDownloadState = episodeDownloadState, - ) - } - } + SeasonEpisodePager( + seasons = seasons, + selectedSeasonNumber = selectedSeasonNumber, + episodes = episodes, + episodesBySeason = episodesBySeason, + isLoadingEpisodes = isLoadingEpisodes, + onSeasonSelected = onSeasonSelected, + onEpisodePlayClick = onEpisodePlayClick, + onEpisodeDetailClick = onEpisodeDetailClick, + onEpisodeDownloadClick = onEpisodeDownloadClick, + episodeDownloadState = episodeDownloadState, + ) } } @@ -290,7 +272,7 @@ fun SeriesDetailContent( item(contentType = "detail-similar") { SimilarRail( - contentId = detail.contentId, + items = similarItems, onSelect = onItemDetailClick, ) } @@ -315,3 +297,18 @@ fun SeriesDetailContent( ) } } + +internal fun seriesSeasonSectionLabel(season: Season?): String = + season?.let(::phoneSeasonLabel) ?: "Episodes" + +internal fun seasonDownloadContentDescription( + season: Season, + isDownloaded: Boolean, +): String { + val label = phoneSeasonLabel(season) + return if (isDownloaded) { + "$label downloaded" + } else { + "Download ${label.replaceFirstChar(Char::lowercase)}" + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SimilarRail.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SimilarRail.kt index bdc894048..5359c0ece 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SimilarRail.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SimilarRail.kt @@ -7,30 +7,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import org.prairieserver.prairie.android.ui.components.MediaCard import org.prairieserver.prairie.model.catalog.ItemDetail -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.repository.CatalogRepository -import org.prairieserver.prairie.repository.RecommendationRepository -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import org.koin.compose.koinInject /** * "More Like This" section — header plus a horizontal poster rail — * shown at the bottom of Movie / Series detail pages. Mirrors - * `PhoneSimilarRail.swift`: - * 1. Hit `/recommendations/similar/{contentId}` for scored IDs - * 2. Resolve each ID to an `ItemDetail` in parallel - * 3. Render a poster card per resolved item; tap opens detail + * `PhoneSimilarRail.swift`. Items are loaded eagerly by + * [ItemDetailViewModel] and rendered as poster cards that open detail. * * The whole section (header included) stays hidden until the request * resolves with items — servers without media embeddings return an @@ -39,19 +25,10 @@ import org.koin.compose.koinInject */ @Composable fun SimilarRail( - contentId: String, + items: List, onSelect: (String) -> Unit, modifier: Modifier = Modifier, - recommendationRepository: RecommendationRepository = koinInject(), - catalogRepository: CatalogRepository = koinInject(), ) { - var items by remember(contentId) { mutableStateOf>(emptyList()) } - - LaunchedEffect(contentId) { - items = emptyList() - items = loadSimilar(contentId, recommendationRepository, catalogRepository) - } - if (items.isNotEmpty()) { Column( verticalArrangement = Arrangement.spacedBy(14.dp), @@ -63,34 +40,6 @@ fun SimilarRail( } } -private suspend fun loadSimilar( - contentId: String, - recommendationRepository: RecommendationRepository, - catalogRepository: CatalogRepository, -): List { - val scored = when (val res = recommendationRepository.getSimilar(contentId, limit = 12)) { - is ApiResult.Success -> res.data.items - else -> return emptyList() - } - if (scored.isEmpty()) return emptyList() - - // Resolve detail pages in parallel — preserve engine ranking by - // dropping null results (failed lookups) without reordering. - return coroutineScope { - scored - .map { ref -> - async { - when (val r = catalogRepository.getItemDetail(ref.mediaItemId)) { - is ApiResult.Success -> r.data - else -> null - } - } - } - .awaitAll() - .filterNotNull() - } -} - @Composable private fun SimilarRailContent( items: List, @@ -122,4 +71,3 @@ private fun SimilarRailContent( } } } - diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt deleted file mode 100644 index 2210f5ce3..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt +++ /dev/null @@ -1,504 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.home - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PageSize -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Star -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.android.ui.theme.PillShape -import org.prairieserver.prairie.android.ui.util.playbackResumePosition -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.model.section.SectionItem -import kotlin.math.absoluteValue -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged - -/** - * Hero carousel rendered at the top of the Home screen. - * - * Mirrors iOS `FeaturedCarousel` (FeaturedCarousel.swift): a centered deck - * of landscape cards with rounded corners. Inactive cards peek to either - * side at reduced scale and opacity, the active card sits on top with full - * emphasis, and a soft gradient on the artwork keeps the bottom-aligned - * title block legible. The page-level blurred backdrop owned by the parent - * [HomeScreen] continues to bleed past the cards and behind the rest of - * the screen. - * - * @param onActiveBackdropChange invoked with the active hero's backdrop URL - * + thumbhash whenever the page changes, so the parent can update its - * blurred page-level backdrop. - */ -@Composable -fun FeaturedCarousel( - items: List, - onPlayClick: (String, Double?) -> Unit, - onInfoClick: (String) -> Unit, - modifier: Modifier = Modifier, - onActiveBackdropChange: ((url: String?, thumbhash: String?) -> Unit)? = null, - extraTopInset: androidx.compose.ui.unit.Dp = 0.dp, -) { - if (items.isEmpty()) return - - val pagerState = rememberPagerState(pageCount = { items.size }) - val configuration = LocalConfiguration.current - val screenWidthDp = configuration.screenWidthDp.toFloat() - - // Match iOS metrics: card width is screen minus 32pt margin (capped at 780), - // and height is 84% of screen width clamped between 300–390. - val cardWidth = (screenWidthDp - 32f).coerceAtMost(780f).dp - val cardHeight = (screenWidthDp * 0.84f).coerceIn(300f, 390f).dp - val sideInset = ((screenWidthDp - cardWidth.value) / 2f).coerceAtLeast(16f).dp - val cardCornerRadius = 28.dp - - val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - // Push the deck below the floating chrome (status bar + ~52dp chrome + breathing room). - // Callers with taller chrome (e.g. Libraries with header + tab row) pass - // additional inset via [extraTopInset]. - val deckTopInset = statusBarTop + 64.dp + extraTopInset - - LaunchedEffect(pagerState, items.size) { - if (items.size > 1) { - while (true) { - delay(8000) - val next = (pagerState.currentPage + 1) % items.size - pagerState.animateScrollToPage(next) - } - } - } - - if (onActiveBackdropChange != null) { - LaunchedEffect(pagerState, items) { - snapshotFlow { pagerState.currentPage } - .distinctUntilChanged() - .collect { page -> - val item = items.getOrNull(page) ?: return@collect - onActiveBackdropChange(item.backdropUrl, item.backdropThumbhash) - } - } - } - - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(deckTopInset)) - - HorizontalPager( - state = pagerState, - pageSize = PageSize.Fixed(cardWidth), - pageSpacing = 12.dp, - contentPadding = PaddingValues(horizontal = sideInset), - modifier = Modifier - .fillMaxWidth() - .height(cardHeight), - ) { page -> - val item = items[page] - val pageOffset = ( - (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction - ).absoluteValue.coerceIn(0f, 1f) - val emphasis = 1f - pageOffset - - FeaturedCard( - item = item, - emphasis = emphasis, - cornerRadius = cardCornerRadius, - onPlayClick = { onPlayClick(item.contentId, playbackResumePosition(item)) }, - onInfoClick = { onInfoClick(item.contentId) }, - modifier = Modifier - .width(cardWidth) - .height(cardHeight), - ) - } - - if (items.size > 1) { - Spacer(modifier = Modifier.height(14.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - items.forEachIndexed { index, _ -> - val isSelected = pagerState.currentPage == index - val width by animateDpAsState( - targetValue = if (isSelected) 22.dp else 8.dp, - animationSpec = tween(300), - label = "dotWidth", - ) - Box( - modifier = Modifier - .height(8.dp) - .width(width) - .clip(PillShape) - .background( - if (isSelected) Color.White - else Color.White.copy(alpha = 0.34f), - ), - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - } - } -} - -@Composable -private fun FeaturedCard( - item: SectionItem, - emphasis: Float, - cornerRadius: androidx.compose.ui.unit.Dp, - onPlayClick: () -> Unit, - onInfoClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val shape = RoundedCornerShape(cornerRadius) - - // iOS card emphasis curve: inactive cards drop to 0.92 scale, 0.46 opacity, - // and shift 18pt down. Active card snaps to full scale/opacity at emphasis = 1. - val inactiveScale = 0.92f - val inactiveOpacity = 0.46f - val inactiveYOffsetDp = 18f - - Box( - modifier = modifier - .graphicsLayer { - val scale = inactiveScale + (1f - inactiveScale) * emphasis - scaleX = scale - scaleY = scale - alpha = inactiveOpacity + (1f - inactiveOpacity) * emphasis - translationY = inactiveYOffsetDp.dp.toPx() * (1f - emphasis) - } - .clip(shape) - .clickable(enabled = emphasis > 0.5f) { onInfoClick() }, - ) { - ThumbhashImage( - url = item.backdropUrl, - thumbhash = item.backdropThumbhash, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - - // Card overlay: vertical darkening for title legibility plus a leading - // horizontal scrim so copy stays readable on bright artwork. - // Mirrors iOS `cardOverlay` lines 466–502. - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0.0f to Color.Black.copy(alpha = 0.18f), - 0.34f to Color.Black.copy(alpha = 0.26f), - 0.78f to Color.Black.copy(alpha = 0.72f), - 1.0f to Color.Black.copy(alpha = 0.90f), - ), - ), - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - 0.0f to Color.Black.copy(alpha = 0.62f), - 0.38f to Color.Black.copy(alpha = 0.18f), - 0.78f to Color.Transparent, - ), - ), - ) - - FeaturedCardContent( - item = item, - visibility = emphasis, - onPlayClick = onPlayClick, - onInfoClick = onInfoClick, - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .padding(20.dp), - ) - - // Hairline border on top of the clipped content. Mirrors iOS - // `shape.strokeBorder(Color.white.opacity(0.08 + 0.10 * emphasis))`. - Box( - modifier = Modifier - .fillMaxSize() - .border( - width = (0.75f + 0.25f * emphasis).dp, - color = Color.White.copy(alpha = 0.08f + 0.10f * emphasis), - shape = shape, - ), - ) - } -} - -@Composable -private fun FeaturedCardContent( - item: SectionItem, - visibility: Float, - onPlayClick: () -> Unit, - onInfoClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.graphicsLayer { alpha = visibility.coerceIn(0f, 1f) }, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - val eyebrow = remember(item) { eyebrowFor(item) } - if (eyebrow != null) { - Text( - text = eyebrow.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = Color.White.copy(alpha = 0.76f), - letterSpacing = 1.0.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - if (!item.logoUrl.isNullOrBlank()) { - ThumbhashImage( - url = item.logoUrl, - thumbhash = null, - contentDescription = item.title, - contentScale = ContentScale.Fit, - transparent = true, - modifier = Modifier - .height(64.dp) - .widthIn(max = 240.dp), - ) - } else { - Text( - text = item.title, - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.ExtraBold, - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - letterSpacing = (-0.5).sp, - ) - } - - if (!item.overview.isNullOrBlank()) { - Text( - text = item.overview!!, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.82f), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - val chips = remember(item) { metadataChips(item) } - if (chips.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - chips.forEach { chip -> MetadataChip(chip) } - } - } - - FeaturedActionRow( - item = item, - onPlayClick = onPlayClick, - onInfoClick = onInfoClick, - ) - } -} - -@Composable -private fun MetadataChip(chip: HeroChip) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - modifier = Modifier - .clip(PillShape) - .background(Color.Black.copy(alpha = 0.42f)) - .border( - width = 0.8.dp, - color = Color.White.copy(alpha = 0.16f), - shape = PillShape, - ) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - if (chip.icon != null) { - Icon( - imageVector = chip.icon, - contentDescription = null, - tint = chip.iconTint ?: Color.White.copy(alpha = 0.94f), - modifier = Modifier.size(12.dp), - ) - } - Text( - text = chip.title, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.94f), - maxLines = 1, - ) - } -} - -@Composable -private fun FeaturedActionRow( - item: SectionItem, - onPlayClick: () -> Unit, - onInfoClick: () -> Unit, -) { - val posSeconds = item.positionSeconds - val durSeconds = item.durationSeconds - val resumeProgress: Float? = - if (posSeconds != null && durSeconds != null && durSeconds > 0 && posSeconds > 60) { - (posSeconds / durSeconds).toFloat().coerceIn(0.01f, 0.99f) - } else null - val remainingMinutes: Int? = if (resumeProgress != null && posSeconds != null && durSeconds != null) { - ((durSeconds - posSeconds) / 60.0).toInt().coerceAtLeast(1) - } else null - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Box { - Button( - onClick = onPlayClick, - shape = PillShape, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - ), - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 10.dp), - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (remainingMinutes != null) "Resume · ${remainingMinutes}m left" else "Play", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - if (resumeProgress != null) { - LinearProgressIndicator( - progress = { resumeProgress }, - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .align(Alignment.BottomCenter) - .clip(PillShape), - color = Color.Black.copy(alpha = 0.78f), - trackColor = Color.Black.copy(alpha = 0.22f), - ) - } - } - OutlinedButton( - onClick = onInfoClick, - shape = PillShape, - colors = ButtonDefaults.outlinedButtonColors( - containerColor = Color.Black.copy(alpha = 0.42f), - contentColor = Color.White, - ), - border = BorderStroke( - width = 0.8.dp, - color = Color.White.copy(alpha = 0.18f), - ), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp), - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "More Info", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - } - } -} - -private data class HeroChip( - val title: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector? = null, - val iconTint: Color? = null, -) - -private fun metadataChips(item: SectionItem): List { - val chips = mutableListOf() - chips += HeroChip(title = item.type.replaceFirstChar { it.uppercase() }) - episodeToken(item)?.let { chips += HeroChip(title = it) } - item.ratingImdb?.let { rating -> - chips += HeroChip( - title = "%.1f".format(rating), - icon = Icons.Default.Star, - iconTint = Color(0xFFFFCA28), - ) - } - if (item.year > 0) chips += HeroChip(title = item.year.toString()) - return chips -} - -private fun eyebrowFor(item: SectionItem): String? { - if (!item.type.equals("episode", ignoreCase = true)) return null - val seriesTitle = item.seriesTitle - return if (!seriesTitle.isNullOrBlank()) seriesTitle else null -} - -private fun episodeToken(item: SectionItem): String? { - if (!item.type.equals("episode", ignoreCase = true)) return null - val season = item.seasonNumber - val episode = item.episodeNumber - return when { - season != null && episode != null -> "S$season E$episode" - season != null -> "Season $season" - episode != null -> "Episode $episode" - else -> null - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt index ab1117980..8bdc4b45e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -45,23 +46,31 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.prairieserver.prairie.android.ui.components.PrairieWordmark +import org.prairieserver.prairie.android.ui.components.TabTopBarActions +import org.prairieserver.prairie.android.ui.components.TopBarIconButton +import org.prairieserver.prairie.android.ui.components.topBarGlass +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import org.prairieserver.prairie.android.ui.components.EmptyStateView import org.prairieserver.prairie.android.ui.components.ErrorView import org.prairieserver.prairie.android.ui.components.MediaRowSkeleton +import org.prairieserver.prairie.android.ui.components.ProfileMenu import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress import org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingViewModel import org.prairieserver.prairie.android.ui.screens.pairing.CompanionPairingBottomOverlay import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar import org.prairieserver.prairie.common.pairing.CompanionPairingStatus import org.prairieserver.prairie.common.pairing.CompanionPairingTarget +import org.prairieserver.prairie.common.ui.components.LocalImagePresentationDeferral +import org.prairieserver.prairie.common.ui.components.avatarRef import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.section.splitFeatured import org.prairieserver.prairie.viewmodel.HomeViewModel import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.koin.compose.viewmodel.koinViewModel @@ -72,8 +81,8 @@ private const val ChromeFadeDistanceDp = 72f * Phone Home screen. * * Mirrors iOS `HomeView.swift` (phone) 1:1: a flat OLED background (no hero — - * iOS deliberately excludes `featured` sections from Home so the configured - * Home rows render without a separate hero surface), a runway spacer that + * a `featured` section renders as an ordinary row in its server order; the + * phone apps have no hero surface at all), a runway spacer that * reserves room under the floating chrome, the resume-first section rows, and * a floating top chrome (wordmark + search + profile menu) that fades in a * subtle glass surface as content scrolls underneath it. The screen owns its @@ -93,7 +102,7 @@ fun HomeScreen( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -109,13 +118,19 @@ fun HomeScreen( var presentedPairingTarget by remember { mutableStateOf(null) } var dismissedPairingSessions by rememberSaveable { mutableStateOf(emptyList()) } val sections = state.sections - // iOS Home excludes `featured` sections entirely (HomeViewModel.regularSections) - // — Home renders only the configured rows, never a hero billboard. + // No hero billboard on phone (matches iOS): a `featured` section is just + // another row, rendered in the order the server configured it. val regularSections = remember(sections) { - sections.splitFeatured().rest.filter { it.items.isNotEmpty() } + sections.filter { it.items.isNotEmpty() } } val listState = rememberLazyListState() + // Pass the State object down without reading it here, so starting/stopping a + // gesture does not recompose the whole Home screen. Individual unloaded + // images observe it only to release a decoded result once scrolling stops. + val deferNewArtworkPresentation = remember(listState) { + derivedStateOf { listState.isScrollInProgress } + } LaunchedEffect(scrollToTopTick) { if (scrollToTopTick > 0) listState.animateScrollToItem(0) } @@ -140,7 +155,7 @@ fun HomeScreen( val chromeFadePx = remember(density) { with(density) { ChromeFadeDistanceDp.dp.toPx() } } - val scrollProgress by remember(chromeFadePx) { + val scrollProgress = remember(chromeFadePx) { derivedStateOf { if (listState.firstVisibleItemIndex > 0) { 1f @@ -150,6 +165,12 @@ fun HomeScreen( } } + // Home's own blur source: the floating chrome blurs the rows scrolling + // beneath it. Local rather than the shell's tab-wide source because the + // chrome sits inside that source and an effect must not read a source + // that contains it. + val chromeHaze = rememberHazeState() + // Home can show the same item in several rows at once. Each poster placement // now carries a unique hero key (see MediaCard) so duplicates never collide // in the shared-transition layout — no per-screen claim registry needed. @@ -171,62 +192,72 @@ fun HomeScreen( else -> PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize(), + // Background sits inside the source so the glass captures an + // opaque scene; a transparent capture composites the blur over + // the sharp content beneath instead of replacing it. + modifier = Modifier + .fillMaxSize() + .hazeSource(chromeHaze) + .background(MaterialTheme.colorScheme.background), ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - // iOS `sectionSpacing` = PrairieTheme.largePadding (24). - verticalArrangement = Arrangement.spacedBy(24.dp), + CompositionLocalProvider( + LocalImagePresentationDeferral provides deferNewArtworkPresentation, ) { - // Reserve runway under the floating header so the first row - // doesn't slide under the status-bar chrome. iOS runway = - // topInset + 40 + smallPadding(8) + largePadding(24) + - // smallPadding(8) - headerTopReclaim(16) = topInset + 64. - item(key = "topRunway") { - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(64.dp), - ) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + // iOS `sectionSpacing` = PrairieTheme.largePadding (24). + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Reserve runway under the floating header so the first row + // doesn't slide under the status-bar chrome. iOS runway = + // topInset + 40 + smallPadding(8) + largePadding(24) + + // smallPadding(8) - headerTopReclaim(16) = topInset + 64. + item(key = "topRunway") { + Spacer( + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .height(64.dp), + ) + } - items( - items = regularSections, - key = { it.id }, - contentType = { "section-row" }, - ) { section -> - HomeSectionRow( - section = section, - onItemClick = onItemClick, - onItemPlay = { item -> - // Continue Watching can include audiobooks; the play - // glyph must not drop them into the video player. Home - // has no callback reaching Route.AudiobookPlayer (that - // route needs a fileId SectionItem doesn't carry), so - // send audiobooks to their detail page, which dispatches - // audiobook playback correctly. - if (isAudiobookItemType(item.type)) { - onItemClick(item.contentId) - } else { - onPlayClick(item.contentId, item.positionSeconds) - } - }, - onSetWatched = viewModel::setWatched, - onToggleFavorite = viewModel::toggleFavorite, - onToggleWatchlist = viewModel::toggleWatchlist, - onDismissContinueWatching = { item -> - item.progressUpdatedAt?.let { ts -> - viewModel.dismissContinueWatching(item.contentId, ts) - } - }, - ) - } + items( + items = regularSections, + key = { it.id }, + contentType = { "section-row" }, + ) { section -> + HomeSectionRow( + section = section, + onItemClick = onItemClick, + onItemPlay = { item -> + // Continue Watching can include audiobooks; the play + // glyph must not drop them into the video player. Home + // has no callback reaching Route.AudiobookPlayer (that + // route needs a fileId SectionItem doesn't carry), so + // send audiobooks to their detail page, which dispatches + // audiobook playback correctly. + if (isAudiobookItemType(item.type)) { + onItemClick(item.contentId) + } else { + onPlayClick(item.contentId, item.positionSeconds) + } + }, + onSetWatched = viewModel::setWatched, + onToggleFavorite = viewModel::toggleFavorite, + onToggleWatchlist = viewModel::toggleWatchlist, + onDismissContinueWatching = { item -> + item.progressUpdatedAt?.let { ts -> + viewModel.dismissContinueWatching(item.contentId, ts) + } + }, + ) + } - // iOS bottom padding = PrairieTheme.largePadding (24), plus the - // translucent bottom chrome the content scrolls beneath. - item(key = "bottomPad") { - Spacer(modifier = Modifier.height(24.dp + LocalBottomChromeInset.current)) + // iOS bottom padding = PrairieTheme.largePadding (24), plus the + // translucent bottom chrome the content scrolls beneath. + item(key = "bottomPad") { + Spacer(modifier = Modifier.height(24.dp + LocalBottomChromeInset.current)) + } } } } @@ -235,6 +266,7 @@ fun HomeScreen( // Floating top chrome — fades in a glass surface as content scrolls under. HomeFloatingChrome( scrollProgress = scrollProgress, + hazeState = chromeHaze, activeProfile = activeProfile, onSearchClick = onSearchClick, onRemoteControlClick = onRemoteControlClick, @@ -242,7 +274,7 @@ fun HomeScreen( onRemoteDisconnectClick = onRemoteDisconnectClick, isRemoteControlActive = isRemoteControlActive, onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, + onWatchTogetherClick = onWatchTogetherClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, @@ -292,7 +324,8 @@ private fun HomeLoadingSkeleton() { @Composable private fun HomeFloatingChrome( - scrollProgress: Float, + scrollProgress: State, + hazeState: HazeState, activeProfile: Profile?, onSearchClick: () -> Unit, onRemoteControlClick: () -> Unit, @@ -300,245 +333,113 @@ private fun HomeFloatingChrome( onRemoteDisconnectClick: () -> Unit, isRemoteControlActive: Boolean, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues() - // iOS chrome: translucent glass fill plus a bottom hairline that strengthens - // as it fades in (white 0.06 → 0.10, 0.75pt). headerTopReclaim(16) pulls the - // row up beside the status-bar glyphs; horizontal = PrairieTheme.padding(16), - // bottom = PrairieTheme.smallPadding(8). - val hairlineAlpha = 0.06f + 0.04f * scrollProgress - Box( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surface.copy(alpha = 0.32f * scrollProgress), - ), - ) { + // iOS chrome: progressive glass that fades in as rows scroll under and + // feathers out along its bottom edge (same recipe as Libraries), so rows + // dissolve into the header rather than meeting a hard line. The glass + // extends past the action row so the feather has room on a short bar. + // headerTopReclaim(16) pulls the row up beside the status-bar glyphs; + // horizontal = PrairieTheme.padding(16), bottom = PrairieTheme.smallPadding(8). + Box(modifier = Modifier.fillMaxWidth()) { + // Glass fades in with scroll; alpha lives on a graphics layer so the + // buttons above stay fully visible at rest. It matches the whole + // chrome, i.e. the action row plus the feather runway below it. + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer { alpha = scrollProgress.value } + .topBarGlass(hazeState, progressive = true), + ) Box( modifier = Modifier .padding(top = statusBarPadding.calculateTopPadding()) - .padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp + HomeChromeFeatherExtension) .fillMaxWidth(), ) { - // Leading: Prairie wordmark (iOS PrairieWordmarkView width: 72). + // Leading: Silo wordmark (iOS PrairieWordmarkView width: 72). PrairieWordmark( modifier = Modifier .align(Alignment.CenterStart), width = 72.dp, ) - // Trailing: search + profile menu cluster. - androidx.compose.foundation.layout.Row( + // Trailing: remote-control + search + profile menu cluster. + TabTopBarActions( modifier = Modifier.align(Alignment.CenterEnd), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Mirrors Apple's PrairieControlModeButton: chrome-free at rest, - // filled disc while controlling a TV; the active state opens a - // menu instead of jumping straight to the remote. - Box { - var remoteMenuExpanded by remember { mutableStateOf(false) } - HomeChromeButton( - onClick = { - if (isRemoteControlActive) { - remoteMenuExpanded = true - } else { - onRemoteControlClick() - } - }, - isActive = isRemoteControlActive, - ) { - Icon( - imageVector = Icons.Outlined.SettingsRemote, - contentDescription = "Remote Control", - ) - } - DropdownMenu( - expanded = remoteMenuExpanded, - onDismissRequest = { remoteMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text("Remote Control") }, - onClick = { - remoteMenuExpanded = false - onRemoteControlClick() - }, - ) - DropdownMenuItem( - text = { Text("Choose TV") }, + activeProfile = activeProfile, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + leadingActions = { + // Mirrors Apple's PrairieControlModeButton: chrome-free at rest, + // filled disc while controlling a TV; the active state opens a + // menu instead of jumping straight to the remote. + Box { + var remoteMenuExpanded by remember { mutableStateOf(false) } + TopBarIconButton( onClick = { - remoteMenuExpanded = false - onRemoteChooseTvClick() - }, - ) - HorizontalDivider() - DropdownMenuItem( - text = { - Text( - "Turn Off Control Mode", - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - remoteMenuExpanded = false - onRemoteDisconnectClick() + if (isRemoteControlActive) { + remoteMenuExpanded = true + } else { + onRemoteControlClick() + } }, - ) + isActive = isRemoteControlActive, + ) { + Icon( + imageVector = Icons.Outlined.SettingsRemote, + contentDescription = "Remote Control", + ) + } + DropdownMenu( + expanded = remoteMenuExpanded, + onDismissRequest = { remoteMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Remote Control") }, + onClick = { + remoteMenuExpanded = false + onRemoteControlClick() + }, + ) + DropdownMenuItem( + text = { Text("Choose TV") }, + onClick = { + remoteMenuExpanded = false + onRemoteChooseTvClick() + }, + ) + HorizontalDivider() + DropdownMenuItem( + text = { + Text( + "Turn Off Control Mode", + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + remoteMenuExpanded = false + onRemoteDisconnectClick() + }, + ) + } } - } - - HomeChromeButton(onClick = onSearchClick) { - Icon( - imageVector = Icons.Outlined.Search, - contentDescription = "Search", - ) - } - - HomeProfileMenu( - activeProfile = activeProfile, - onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, - onSettingsClick = onSettingsClick, - onSwitchProfileClick = onSwitchProfileClick, - onSwitchServerClick = onSwitchServerClick, - onSignOutClick = onSignOutClick, - ) - } - } - - // Bottom hairline border (iOS 0.75pt, white 0.06–0.10). - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(0.75.dp) - .background(Color.White.copy(alpha = hairlineAlpha)), - ) - } -} - -@Composable -private fun HomeChromeButton( - onClick: () -> Unit, - isActive: Boolean = false, - content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit, -) { - // iOS top-bar icon buttons are bare 40x40 tap targets (no chip background). - Surface( - onClick = onClick, - color = if (isActive) MaterialTheme.colorScheme.onSurface else Color.Transparent, - contentColor = if (isActive) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurface, - shape = CircleShape, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Box( - modifier = Modifier.size(40.dp), - contentAlignment = Alignment.Center, - content = content, - ) - } -} - -@Composable -private fun HomeProfileMenu( - activeProfile: Profile?, - onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, - onSettingsClick: () -> Unit, - onSwitchProfileClick: () -> Unit, - onSwitchServerClick: () -> Unit, - onSignOutClick: () -> Unit, -) { - var menuExpanded by rememberSaveable { mutableStateOf(false) } - Box { - HomeChromeButton(onClick = { menuExpanded = true }) { - if (activeProfile != null) { - // iOS ProfileAvatarView size: 36. - ProfileAvatar( - avatar = activeProfile.avatar, - name = activeProfile.name, - size = 36.dp, - ) - } else { - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - if (onRequestsClick != null) { - DropdownMenuItem( - text = { Text("Requests") }, - onClick = { - menuExpanded = false - onRequestsClick() - }, - ) - } - if (onLiveTvClick != null) { - DropdownMenuItem( - text = { Text("Live TV") }, - onClick = { - menuExpanded = false - onLiveTvClick() - }, - ) - } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } - DropdownMenuItem( - text = { Text("Settings") }, - onClick = { - menuExpanded = false - onSettingsClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Profile") }, - onClick = { - menuExpanded = false - onSwitchProfileClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Server") }, - onClick = { - menuExpanded = false - onSwitchServerClick() - }, - ) - DropdownMenuItem( - text = { - Text( - text = "Sign Out", - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - menuExpanded = false - onSignOutClick() }, ) } } } + +/** How far the Home chrome's glass runs past its action row to feather out. */ +private val HomeChromeFeatherExtension = 40.dp + diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt index 9fc0d5bd5..9b3bbd04f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt @@ -43,12 +43,9 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -66,14 +63,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel @@ -81,11 +77,20 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.android.ui.components.EmptyStateView import org.prairieserver.prairie.android.ui.components.ErrorView import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset -import org.prairieserver.prairie.android.ui.components.HeroBackdropImage -import org.prairieserver.prairie.android.ui.components.HeroTintBackground import org.prairieserver.prairie.android.ui.components.MediaGridDefaults import org.prairieserver.prairie.android.ui.components.MediaRowsSkeleton import org.prairieserver.prairie.android.ui.components.PosterGridSkeleton +import org.prairieserver.prairie.android.ui.components.TabTopBarActions +import org.prairieserver.prairie.android.ui.components.SortFilterControlsRow +import org.prairieserver.prairie.android.ui.components.SortMenuOption +import org.prairieserver.prairie.android.ui.components.topBarGlass +import dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.HazeState +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.runtime.mutableIntStateOf import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.FilterList @@ -102,14 +107,13 @@ import org.prairieserver.prairie.catalog.filter.BrowseFacetMediaType import org.prairieserver.prairie.catalog.filter.CatalogFacet import org.prairieserver.prairie.catalog.filter.CatalogFilterQueryBuilder import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.common.ui.components.avatarRef import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse import org.prairieserver.prairie.model.catalog.isAudiobookItemType -import org.prairieserver.prairie.android.ui.screens.home.FeaturedCarousel import org.prairieserver.prairie.android.ui.screens.home.HomeSectionRow import org.prairieserver.prairie.android.ui.screens.profiles.ProfileAvatar import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceElevated import org.prairieserver.prairie.android.ui.util.formatCardDate -import org.prairieserver.prairie.android.ui.util.rememberDominantColor import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.MediaItemUserState @@ -117,7 +121,6 @@ import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.section.LibraryCollection import org.prairieserver.prairie.model.section.ResolvedSection -import org.prairieserver.prairie.model.section.splitFeatured import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository @@ -143,7 +146,21 @@ enum class LibraryBrowseSort( ) { RecentlyAdded("Recently Added", "added_at", "desc"), Title("Title", "title", "asc"), - ReleaseDate("Release Date", "release_date", "desc"), + ReleaseDate("Release Date", "release_date", "desc"); + + companion object { + /** + * The sort rides the persisted [CatalogFilterState] (same as + * BrowseViewModel), so restoring saved browse prefs also restores the + * chip label. [CatalogFilterState]'s own defaults are exactly + * [RecentlyAdded]'s pair, so a state saved before the sort travelled + * with it — or one naming a field this client does not offer — falls + * back to [RecentlyAdded]. + */ + fun fromFilterState(state: CatalogFilterState): LibraryBrowseSort = + entries.firstOrNull { it.sortField == state.sort && it.sortOrder == state.order } + ?: RecentlyAdded + } } data class LibrariesUiState( @@ -194,6 +211,10 @@ class LibrariesViewModel( private var recommendedLoadedLibraryId: Int? = null private var browseLoadedLibraryId: Int? = null private var collectionsLoadedLibraryId: Int? = null + private var recommendedRequestGeneration = 0L + private var catalogRequestGeneration = 0L + private var catalogQueryGeneration = 0L + private var collectionsRequestGeneration = 0L private val pageSize = 42 init { @@ -241,6 +262,13 @@ class LibrariesViewModel( // whatever filters are already active. val restoreBrowsePrefs = selectedLibraryId != null && selectedLibraryId != previousLibraryId + // Null when there is nothing to restore, so the branches + // below keep the live state untouched. + val restoredFilterState = if (restoreBrowsePrefs) { + browsePrefs?.savedState(selectedLibraryId) ?: CatalogFilterState() + } else { + null + } _uiState.update { it.copy( @@ -248,9 +276,12 @@ class LibrariesViewModel( libraries = libraries, selectedLibraryId = selectedLibraryId, librariesError = null, - filterState = if (restoreBrowsePrefs) - (browsePrefs?.savedState(selectedLibraryId) ?: CatalogFilterState()) - else it.filterState, + filterState = restoredFilterState ?: it.filterState, + // The sort lives inside the persisted filter state, + // so derive the chip from what was restored. + browseSort = restoredFilterState + ?.let(LibraryBrowseSort::fromFilterState) + ?: it.browseSort, preserveFilters = if (restoreBrowsePrefs) (browsePrefs?.preserveEnabled(selectedLibraryId) ?: true) else it.preserveFilters, @@ -290,6 +321,10 @@ class LibrariesViewModel( recommendedLoadedLibraryId = null browseLoadedLibraryId = null collectionsLoadedLibraryId = null + // Restore this library's persisted filter/sort state (iOS parity) so a + // preserved selection doesn't flash the unfiltered grid; default to a + // clean filter — and therefore RecentlyAdded — when nothing is saved. + val restoredFilterState = browsePrefs?.savedState(libraryId) ?: CatalogFilterState() _uiState.update { it.copy( selectedLibraryId = libraryId, @@ -298,10 +333,8 @@ class LibrariesViewModel( catalogItems = emptyList(), catalogTotal = 0, catalogHasMore = false, - // Restore this library's persisted filter/preserve state (iOS - // parity) so a preserved selection doesn't flash the unfiltered - // grid; default to a clean filter when nothing is saved. - filterState = browsePrefs?.savedState(libraryId) ?: CatalogFilterState(), + filterState = restoredFilterState, + browseSort = LibraryBrowseSort.fromFilterState(restoredFilterState), availableFilters = null, preserveFilters = browsePrefs?.preserveEnabled(libraryId) ?: true, selectedNamePrefix = null, @@ -322,16 +355,22 @@ class LibrariesViewModel( /** Apply a new facet/match filter selection, persist it (when preserve is * on), and reload the catalog. Mirrors BrowseViewModel.applyFilterState. */ fun applyFilterState(state: CatalogFilterState) { - if (state == _uiState.value.filterState) return + val current = _uiState.value.filterState + // [selectBrowseSort] owns sort/order. Callers derive `state` from a + // composition snapshot that can be a frame stale — the Reset control + // changes the sort and clears the facets in the same frame — so take + // only the facet/match parts and keep the sort already committed here. + val reconciled = state.copy(sort = current.sort, order = current.order) + if (reconciled == current) return _uiState.update { it.copy( - filterState = state, + filterState = reconciled, catalogItems = emptyList(), catalogTotal = 0, catalogHasMore = false, ) } - browsePrefs?.saveState(_uiState.value.selectedLibraryId, state) + browsePrefs?.saveState(_uiState.value.selectedLibraryId, reconciled) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } @@ -343,14 +382,23 @@ class LibrariesViewModel( } fun selectBrowseSort(sort: LibraryBrowseSort) { + // The sort rides the persisted filter state so "Preserve sort & filters" + // actually restores it, instead of the chips coming back while the sort + // snaps to Recently Added. + val nextFilterState = _uiState.value.filterState.copy( + sort = sort.sortField, + order = sort.sortOrder, + ) _uiState.update { it.copy( browseSort = sort, + filterState = nextFilterState, catalogItems = emptyList(), catalogTotal = 0, catalogHasMore = false, ) } + browsePrefs?.saveState(_uiState.value.selectedLibraryId, nextFilterState) _uiState.value.selectedLibraryId?.let { loadCatalog(it, reset = true, force = true) } } @@ -400,41 +448,62 @@ class LibrariesViewModel( private fun loadRecommended(libraryId: Int, force: Boolean) { if (!force && recommendedLoadedLibraryId == libraryId) return recommendedLoadedLibraryId = libraryId + val requestGeneration = ++recommendedRequestGeneration viewModelScope.launch { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = true, - sectionsError = null, - sections = emptyList(), - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = true, + sectionsError = null, + sections = emptyList(), + ) + } else { + it + } } when (val result = sectionRepository.getLibrarySections(libraryId)) { is ApiResult.Success -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, - sectionsError = null, - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = result.data.sections.filter { section -> section.items.isNotEmpty() }, + sectionsError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = emptyList(), - sectionsError = result.message.ifBlank { "Failed to load recommendations" }, - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = emptyList(), + sectionsError = result.message.ifBlank { "Failed to load recommendations" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isRecommendedRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingSections = false, - sections = emptyList(), - sectionsError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isRecommendedRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingSections = false, + sections = emptyList(), + sectionsError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } @@ -446,18 +515,37 @@ class LibrariesViewModel( return } browseLoadedLibraryId = libraryId + val requestState = _uiState.value + val requestIdentity = CatalogRequestIdentity( + libraryId = libraryId, + browseSort = requestState.browseSort, + selectedNamePrefix = requestState.selectedNamePrefix, + filterState = requestState.filterState, + ) + val requestGeneration = ++catalogRequestGeneration + val queryGeneration = + if (reset) ++catalogQueryGeneration else catalogQueryGeneration + val offset = if (reset) 0 else requestState.catalogItems.size viewModelScope.launch { - val state = _uiState.value - val offset = if (reset) 0 else state.catalogItems.size + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch - if (reset && state.availableFilters == null) { + if (reset && requestState.availableFilters == null) { launch { // includeTechnical: resolution + audio/subtitle-language facets // are only fetched on request (iOS parity). Keep the FULL // response so the filter sheet has every facet, not just genres. when (val filters = catalogRepository.getFilters(libraryId, includeTechnical = true)) { is ApiResult.Success -> { - _uiState.update { it.copy(availableFilters = filters.data) } + if (!isCatalogQueryCurrent(queryGeneration, requestIdentity)) { + return@launch + } + _uiState.update { + if (isCatalogQueryCurrent(queryGeneration, requestIdentity, it)) { + it.copy(availableFilters = filters.data) + } else { + it + } + } } else -> Unit } @@ -465,38 +553,34 @@ class LibrariesViewModel( } _uiState.update { - if (reset) { - it.copy( - isLoadingCatalog = true, - isLoadingMoreCatalog = false, - catalogError = null, - ) + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it + } else if (reset) { + it.copy(isLoadingCatalog = true, isLoadingMoreCatalog = false, catalogError = null) } else { - it.copy( - isLoadingMoreCatalog = true, - catalogError = null, - ) + it.copy(isLoadingMoreCatalog = true, catalogError = null) } } when ( val result = catalogRepository.browse( libraryId = libraryId, - sort = state.browseSort.sortField, - order = state.browseSort.sortOrder, + sort = requestState.browseSort.sortField, + order = requestState.browseSort.sortOrder, offset = offset, limit = pageSize, - namePrefix = state.selectedNamePrefix, + namePrefix = requestState.selectedNamePrefix, // Full facet filtering (genre/decade/rating/studio/language/...) // via the shared query builder — replaces the single-genre param. - queryGroups = CatalogFilterQueryBuilder.buildGroups(state.filterState), - match = CatalogFilterQueryBuilder.matchParam(state.filterState) - .takeIf { state.filterState.hasActiveFilters }, + queryGroups = CatalogFilterQueryBuilder.buildGroups(requestState.filterState), + match = CatalogFilterQueryBuilder.matchParam(requestState.filterState) + .takeIf { requestState.filterState.hasActiveFilters }, ) ) { is ApiResult.Success -> { // Overlay local optimistic watched/favorite (mirrors Home/Browse). val overlaid = overlayLocalState(result.data.items) + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch // Audiobook libraries expose book-native facets // (author/narrator/series) — detected from the first item. val detectedMediaType = overlaid.firstOrNull()?.let { first -> @@ -507,33 +591,47 @@ class LibrariesViewModel( } } _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogItems = if (reset) overlaid else it.catalogItems + overlaid, - catalogTotal = result.data.total, - catalogHasMore = result.data.hasMore, - browseMediaType = detectedMediaType ?: it.browseMediaType, - catalogError = null, - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogItems = if (reset) overlaid else it.catalogItems + overlaid, + catalogTotal = result.data.total, + catalogHasMore = result.data.hasMore, + browseMediaType = detectedMediaType ?: it.browseMediaType, + catalogError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogError = result.message.ifBlank { "Failed to load catalog" }, - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogError = result.message.ifBlank { "Failed to load catalog" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isCatalogRequestCurrent(requestGeneration, requestIdentity)) return@launch _uiState.update { - it.copy( - isLoadingCatalog = false, - isLoadingMoreCatalog = false, - catalogError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isCatalogRequestCurrent(requestGeneration, requestIdentity, it)) { + it.copy( + isLoadingCatalog = false, + isLoadingMoreCatalog = false, + catalogError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } @@ -565,49 +663,108 @@ class LibrariesViewModel( return } collectionsLoadedLibraryId = libraryId + val requestGeneration = ++collectionsRequestGeneration viewModelScope.launch { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = true, - collectionsError = null, - collections = emptyList(), - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = true, + collectionsError = null, + collections = emptyList(), + ) + } else { + it + } } when (val result = sectionRepository.getLibraryCollections(libraryId)) { is ApiResult.Success -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collections = result.data, - collectionsError = null, - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collections = result.data, + collectionsError = null, + ) + } else { + it + } } } is ApiResult.Error -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collectionsError = result.message.ifBlank { "Failed to load collections" }, - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collectionsError = result.message.ifBlank { "Failed to load collections" }, + ) + } else { + it + } } } is ApiResult.NetworkError -> { + if (!isCollectionsRequestCurrent(requestGeneration, libraryId)) return@launch _uiState.update { - it.copy( - isLoadingCollections = false, - collectionsError = "Network error: ${result.exception.message ?: "unknown"}", - ) + if (isCollectionsRequestCurrent(requestGeneration, libraryId, it)) { + it.copy( + isLoadingCollections = false, + collectionsError = "Network error: ${result.exception.message ?: "unknown"}", + ) + } else { + it + } } } } } } -} -// Chrome metrics: status bar + this constant ≈ visible chrome height (header -// row + tab selector). Used to push tab content below the floating chrome and -// to drive the carousel's [extraTopInset] on the Recommended tab. -private val LibrariesChromeContentHeight: Dp = 110.dp + private fun isRecommendedRequestCurrent( + generation: Long, + libraryId: Int, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == recommendedRequestGeneration && state.selectedLibraryId == libraryId + + private fun isCatalogRequestCurrent( + generation: Long, + identity: CatalogRequestIdentity, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == catalogRequestGeneration && + identity.matches(state) + + private fun isCatalogQueryCurrent( + generation: Long, + identity: CatalogRequestIdentity, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == catalogQueryGeneration && + identity.matches(state) + + private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = + state.selectedLibraryId == libraryId && + state.browseSort == browseSort && + state.selectedNamePrefix == selectedNamePrefix && + state.filterState == filterState + + private fun isCollectionsRequestCurrent( + generation: Long, + libraryId: Int, + state: LibrariesUiState = _uiState.value, + ): Boolean = + generation == collectionsRequestGeneration && state.selectedLibraryId == libraryId + + private data class CatalogRequestIdentity( + val libraryId: Int, + val browseSort: LibraryBrowseSort, + val selectedNamePrefix: String?, + val filterState: CatalogFilterState, + ) +} // Distance the Recommended tab must scroll for the chrome scrim to fully // fade in. Mirrors `chromeScrimFadeDistance` on iOS. @@ -616,14 +773,13 @@ private const val ChromeFadeDistanceDp = 80f @Composable fun LibrariesScreen( onItemClick: (String) -> Unit, - onPlayClick: (String, Double?) -> Unit, onCollectionClick: (String, Int) -> Unit, viewModel: LibrariesViewModel, activeProfile: Profile?, onLibrarySelectorClick: () -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, @@ -633,24 +789,8 @@ fun LibrariesScreen( val state by viewModel.uiState.collectAsState() val selectedLibrary = state.libraries.firstOrNull { it.id == state.selectedLibraryId } - // Hero backdrop sampled from the active featured carousel page. Mirrors - // iOS `LibraryRecommendedView` — the parent owns the URL so the page-level - // tint + blur extend past the carousel. - var heroBackdropUrl by rememberSaveable(state.selectedLibraryId) { - mutableStateOf(null) - } - var heroBackdropThumbhash by rememberSaveable(state.selectedLibraryId) { - mutableStateOf(null) - } - - val heroTint by rememberDominantColor( - imageUrl = heroBackdropUrl, - fallback = MaterialTheme.colorScheme.background, - ) - // Recommended tab scroll state — drives the chrome scrim opacity so the - // header reads as part of the artwork while the hero is at rest, then - // resolves to a solid scrim once the user scrolls past. + // header fades in its scrim once the user scrolls the rows underneath it. val recommendedListState = rememberLazyListState() val density = LocalDensity.current val chromeFadePx = remember(density) { @@ -671,86 +811,97 @@ fun LibrariesScreen( 1f } - val showHero = state.selectedTab == LibrariesSubtab.Recommended && - state.sections.any { it.featured } && - heroBackdropUrl != null + // The chrome floats over the content, which scrolls up beneath its + // feathered glass edge. Its height is measured (the selector wraps to two + // lines) and handed to each subtab as the inset its own top must clear. + val chromeHaze = rememberHazeState() + var chromeHeightPx by remember { mutableIntStateOf(0) } + val chromeHeight = with(density) { chromeHeightPx.toDp() } Box( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - if (showHero) { - HeroTintBackground(tint = heroTint) - HeroBackdropImage( - url = heroBackdropUrl, - thumbhash = heroBackdropThumbhash, - ) - } - - when { - state.isLoadingLibraries && state.libraries.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } - state.librariesError != null && state.libraries.isEmpty() -> { - ErrorView( - message = state.librariesError ?: "Failed to load libraries", - onRetry = viewModel::refresh, - modifier = Modifier.fillMaxSize(), - ) - } - selectedLibrary == null -> { - EmptyStateView( - title = "No libraries available", - subtitle = "Libraries visible to this profile will show up here", - icon = Icons.Default.VideoLibrary, - modifier = Modifier.fillMaxSize(), - ) - } - else -> { - when (state.selectedTab) { - LibrariesSubtab.Recommended -> RecommendedTabContent( - state = state, - listState = recommendedListState, - onItemClick = onItemClick, - onPlayClick = onPlayClick, - onRetry = viewModel::retryCurrentTab, - onActiveBackdropChange = { url, thumbhash -> - heroBackdropUrl = url - heroBackdropThumbhash = thumbhash - }, - ) - LibrariesSubtab.Browse -> BrowseTabContent( - state = state, - onItemClick = onItemClick, - onRetry = viewModel::retryCurrentTab, - onLoadMore = viewModel::loadMoreCatalog, - onSortChanged = viewModel::selectBrowseSort, - onNamePrefixChanged = viewModel::selectNamePrefix, - onDensityChanged = viewModel::selectViewDensity, - onApplyFilter = viewModel::applyFilterState, - onSetPreserve = viewModel::setPreserveFilters, - ) - LibrariesSubtab.Collections -> CollectionsTabContent( - state = state, - onCollectionClick = { collectionId -> - state.selectedLibraryId?.let { libraryId -> - onCollectionClick(collectionId, libraryId) - } - }, - onRetry = viewModel::retryCurrentTab, - ) + LibraryContentViewport( + modifier = Modifier + .fillMaxSize() + // Background inside the source so the glass captures an + // opaque scene rather than compositing over the sharp content. + .hazeSource(chromeHaze) + .background(MaterialTheme.colorScheme.background) + .clipToBounds(), + ) { + // Hold content until the chrome has been measured once so the + // first frame does not lay rows out under the header and jump. + if (chromeHeightPx > 0) { + val topInset = chromeHeight + when { + state.isLoadingLibraries && state.libraries.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize().padding(top = topInset), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + state.librariesError != null && state.libraries.isEmpty() -> { + ErrorView( + message = state.librariesError ?: "Failed to load libraries", + onRetry = viewModel::refresh, + modifier = Modifier.fillMaxSize().padding(top = topInset), + ) + } + selectedLibrary == null -> { + EmptyStateView( + title = "No libraries available", + subtitle = "Libraries visible to this profile will show up here", + icon = Icons.Default.VideoLibrary, + modifier = Modifier.fillMaxSize().padding(top = topInset), + ) + } + state.selectedTab == LibrariesSubtab.Recommended -> { + RecommendedTabContent( + state = state, + listState = recommendedListState, + topInset = topInset, + onItemClick = onItemClick, + onRetry = viewModel::retryCurrentTab, + ) + } + state.selectedTab == LibrariesSubtab.Browse -> { + BrowseTabContent( + state = state, + topInset = topInset, + onItemClick = onItemClick, + onRetry = viewModel::retryCurrentTab, + onLoadMore = viewModel::loadMoreCatalog, + onSortChanged = viewModel::selectBrowseSort, + onNamePrefixChanged = viewModel::selectNamePrefix, + onDensityChanged = viewModel::selectViewDensity, + onApplyFilter = viewModel::applyFilterState, + onSetPreserve = viewModel::setPreserveFilters, + ) + } + else -> { + CollectionsTabContent( + state = state, + topInset = topInset, + onCollectionClick = { collectionId -> + state.selectedLibraryId?.let { libraryId -> + onCollectionClick(collectionId, libraryId) + } + }, + onRetry = viewModel::retryCurrentTab, + ) + } } } } LibrariesFloatingChrome( scrimProgress = chromeScrimProgress, + hazeState = chromeHaze, selectedLibrary = selectedLibrary, canSwitch = state.libraries.size > 1, activeProfile = activeProfile, @@ -759,41 +910,46 @@ fun LibrariesScreen( onTabSelected = viewModel::selectTab, onSearchClick = onSearchClick, onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, + onWatchTogetherClick = onWatchTogetherClick, onSettingsClick = onSettingsClick, onSwitchProfileClick = onSwitchProfileClick, onSwitchServerClick = onSwitchServerClick, onSignOutClick = onSignOutClick, + modifier = Modifier.onSizeChanged { chromeHeightPx = it.height }, ) } } +@Composable +private fun LibraryContentViewport( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth(), + content = content, + ) +} + @Composable private fun RecommendedTabContent( state: LibrariesUiState, listState: androidx.compose.foundation.lazy.LazyListState, + topInset: Dp, onItemClick: (String) -> Unit, - onPlayClick: (String, Double?) -> Unit, onRetry: () -> Unit, - onActiveBackdropChange: (url: String?, thumbhash: String?) -> Unit, ) { when { state.isLoadingSections && state.sections.isEmpty() -> { MediaRowsSkeleton( - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } state.sectionsError != null && state.sections.isEmpty() -> { ErrorView( message = state.sectionsError ?: "Failed to load recommendations", onRetry = onRetry, - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } state.sections.isEmpty() -> { @@ -801,51 +957,24 @@ private fun RecommendedTabContent( title = "No recommendations yet", subtitle = "Try switching libraries or browsing the full catalog", icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), - modifier = Modifier - .fillMaxSize() - .padding(top = LibrariesChromeContentHeight) - .windowInsetsPadding(WindowInsets.statusBars), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } else -> { - val (featuredSection, regularSections) = remember(state.sections) { - state.sections.splitFeatured().let { it.featured to it.rest } - } - + // No hero carousel (matches iOS): a `featured` section is just + // another row, kept in the order the server configured it. // iOS `LibraryRecommendedView`: LazyVStack(spacing: largePadding = 24) // between section rows. LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), + // Rows start below the floating chrome and scroll up under it. + contentPadding = PaddingValues(top = topInset + 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), ) { - if (featuredSection != null && featuredSection.items.isNotEmpty()) { - item(key = "library-featured") { - FeaturedCarousel( - items = featuredSection.items, - onPlayClick = onPlayClick, - onInfoClick = onItemClick, - onActiveBackdropChange = onActiveBackdropChange, - // Push the deck below the taller Libraries chrome - // (header + tab selector). Carousel already adds - // `statusBar + 64dp`; this covers the tab row. - extraTopInset = 50.dp, - ) - } - } else { - // No featured → reserve runway under the chrome so the - // first row doesn't slide under the floating header. - item(key = "no-featured") { - Spacer( - modifier = Modifier - .windowInsetsPadding(WindowInsets.statusBars) - .height(LibrariesChromeContentHeight + 8.dp), - ) - } - } items( - items = regularSections, + items = state.sections, key = { section -> section.id }, ) { section -> // No "See All" — iOS has no such affordance (H3, Jim @@ -867,6 +996,7 @@ private fun RecommendedTabContent( @Composable private fun BrowseTabContent( state: LibrariesUiState, + topInset: Dp, onItemClick: (String) -> Unit, onRetry: () -> Unit, onLoadMore: () -> Unit, @@ -877,103 +1007,83 @@ private fun BrowseTabContent( onSetPreserve: (Boolean) -> Unit, ) { var showFilterSheet by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = LibrariesChromeContentHeight), - ) { - // Sort chips + a Filter button that opens the shared FilterSheet. Genre - // is now a Categories facet inside the sheet (no inline genre rail, L3), - // and view-density moved into the sheet's "View" section (L4). - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier - .weight(1f) - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - LibraryBrowseSort.entries.forEach { sort -> - FilterChip( - selected = state.browseSort == sort, - onClick = { onSortChanged(sort) }, - label = { Text(sort.label) }, - colors = libraryChipColors(state.browseSort == sort), - ) - } - } - BadgedBox( - badge = { - if (state.filterState.activeFacetCount > 0) { - Badge { Text("${state.filterState.activeFacetCount}") } - } + val isCustomised = state.browseSort != LibraryBrowseSort.RecentlyAdded || + state.filterState.hasActiveFilters || + state.selectedNamePrefix != null + + // Sort ▾ / Filter (n) / Reset — the same control row as the saved lists — + // plus removable chips for active facets. Rendered as the grid's header + // so it scrolls with the content under the chrome's glass. + val controlsHeader: @Composable () -> Unit = { + Column(modifier = Modifier.padding(bottom = 4.dp)) { + SortFilterControlsRow( + sortLabel = state.browseSort.label, + sortActive = state.browseSort != LibraryBrowseSort.RecentlyAdded, + sortOptions = LibraryBrowseSort.entries.map { SortMenuOption(id = it.name, label = it.label) }, + selectedSortId = state.browseSort.name, + onSelectSort = { id -> onSortChanged(LibraryBrowseSort.valueOf(id)) }, + filterCount = state.filterState.activeFacetCount, + onOpenFilters = { showFilterSheet = true }, + showReset = isCustomised, + onReset = { + onSortChanged(LibraryBrowseSort.RecentlyAdded) + onApplyFilter(state.filterState.resetFilters()) + onNamePrefixChanged(null) }, - ) { - IconButton(onClick = { showFilterSheet = true }) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = "Filters", - ) - } - } - } - - // Active filter chips — removable capsules, one per selected facet value. - if (state.filterState.hasActiveFilters) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - CatalogFacet.available(state.browseMediaType).forEach { facet -> - state.filterState.valuesFor(facet).sorted().forEach { value -> - LibraryActiveFilterChip( - label = facetValueLabel(facet, value), - onRemove = { onApplyFilter(state.filterState.toggle(facet, value)) }, - ) + ) + if (state.filterState.hasActiveFilters) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(top = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CatalogFacet.available(state.browseMediaType).forEach { facet -> + state.filterState.valuesFor(facet).sorted().forEach { value -> + LibraryActiveFilterChip( + label = facetValueLabel(facet, value), + onRemove = { onApplyFilter(state.filterState.toggle(facet, value)) }, + ) + } } } } } + } + Box(modifier = Modifier.fillMaxSize()) { when { state.isLoadingCatalog && state.catalogItems.isEmpty() -> { PosterGridSkeleton( progress = rememberShimmerProgress(), - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } state.catalogError != null && state.catalogItems.isEmpty() -> { - ErrorView( - message = state.catalogError ?: "Failed to load catalog", - onRetry = onRetry, - modifier = Modifier.fillMaxSize(), - ) + // Controls stay mounted so a rejected sort/filter/letter can be + // changed from here rather than only retried. + Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { controlsHeader() } + ErrorView( + message = state.catalogError ?: "Failed to load catalog", + onRetry = onRetry, + modifier = Modifier.weight(1f), + ) + } } state.catalogItems.isEmpty() -> { - EmptyStateView( - title = "No items found", - subtitle = "Try adjusting the sort or switching libraries", - icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), - modifier = Modifier.fillMaxSize(), - ) + Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) { + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { controlsHeader() } + EmptyStateView( + title = if (isCustomised) "No matches" else "No items found", + subtitle = if (isCustomised) "No titles match the current sort or filters." else "Try switching libraries", + icon = libraryIcon(state.libraries.firstOrNull { it.id == state.selectedLibraryId }?.type.orEmpty()), + modifier = Modifier.weight(1f), + ) + } } else -> { - Text( - text = "${state.catalogTotal} items", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) CatalogGrid( items = state.catalogItems, isLoadingMore = state.isLoadingMoreCatalog, @@ -990,6 +1100,9 @@ private fun BrowseTabContent( selectedNamePrefix = state.selectedNamePrefix, onNamePrefixSelected = onNamePrefixChanged, viewDensity = state.catalogDensity, + bottomContentInset = LocalBottomChromeInset.current, + topContentInset = topInset, + header = controlsHeader, modifier = Modifier.fillMaxSize(), ) } @@ -1048,27 +1161,22 @@ private fun LibraryActiveFilterChip( @Composable private fun CollectionsTabContent( state: LibrariesUiState, + topInset: Dp, onCollectionClick: (String) -> Unit, onRetry: () -> Unit, ) { - val contentTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + - LibrariesChromeContentHeight when { state.isLoadingCollections && state.collections.isEmpty() -> { PosterGridSkeleton( progress = rememberShimmerProgress(), - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } state.collectionsError != null && state.collections.isEmpty() -> { ErrorView( message = state.collectionsError ?: "Failed to load collections", onRetry = onRetry, - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } state.collections.isEmpty() -> { @@ -1076,23 +1184,22 @@ private fun CollectionsTabContent( title = "No collections found", subtitle = "This library does not have any collections yet", icon = Icons.Default.VideoLibrary, - modifier = Modifier - .fillMaxSize() - .padding(top = contentTopPadding), + modifier = Modifier.fillMaxSize().padding(top = topInset), ) } else -> { - // iOS `LibraryCollectionsView`: adaptive 110pt poster grid with - // shared column/row spacing and 16pt padding insets. + // iOS `LibraryCollectionsView`: adaptive poster grid with shared + // column/row spacing and 16pt padding insets. Follows the Library + // grid's view density so both tabs show the same column count. LazyVerticalGrid( - columns = GridCells.Adaptive(MediaGridDefaults.PosterGridMinWidth), + columns = GridCells.Adaptive(state.catalogDensity.minCardWidth), horizontalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridHorizontalSpacing), verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - top = contentTopPadding, + top = topInset + 16.dp, bottom = 24.dp + LocalBottomChromeInset.current, ), ) { @@ -1118,7 +1225,7 @@ private fun InlineLibraryCollectionCard( ) { // iOS `LibraryCollectionCard`: VStack(spacing: 6) of a 2:3.3 poster // (smallCornerRadius = 6) carrying a bottom-trailing count badge, a - // prairieCaption (12) name (2 lines), and a prairieSmall (11) secondary + // siloCaption (12) name (2 lines), and a siloSmall (11) secondary // type label. Column( modifier = Modifier.clickable(onClick = onClick), @@ -1170,6 +1277,7 @@ private fun InlineLibraryCollectionCard( @Composable private fun LibrariesFloatingChrome( scrimProgress: Float, + hazeState: HazeState, selectedLibrary: UserLibrary?, canSwitch: Boolean, activeProfile: Profile?, @@ -1178,11 +1286,12 @@ private fun LibrariesFloatingChrome( onTabSelected: (LibrariesSubtab) -> Unit, onSearchClick: () -> Unit, onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, + onWatchTogetherClick: (() -> Unit)?, onSettingsClick: () -> Unit, onSwitchProfileClick: () -> Unit, onSwitchServerClick: () -> Unit, onSignOutClick: () -> Unit, + modifier: Modifier = Modifier, ) { val statusBarPadding = WindowInsets.statusBars.asPaddingValues() val animatedFill by animateFloatAsState( @@ -1190,22 +1299,21 @@ private fun LibrariesFloatingChrome( label = "librariesChromeFill", ) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - // iOS chrome scrim: LinearGradient(black@0.55 → black@0.25 → - // clear) faded in by the scroll-driven opacity. - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.background.copy(alpha = 0.55f * animatedFill), - MaterialTheme.colorScheme.background.copy(alpha = 0.25f * animatedFill), - MaterialTheme.colorScheme.background.copy(alpha = 0f), - ), - ), - ) - .padding(top = statusBarPadding.calculateTopPadding() + 8.dp), - ) { + Box(modifier = modifier.fillMaxWidth()) { + // Progressive glass, faded in by the scroll-driven opacity on the + // Recommended tab and always on for Browse / Collections. Its bottom + // edge feathers to clear so rows dissolve into the chrome. + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer { alpha = animatedFill } + .topBarGlass(hazeState, progressive = true), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = statusBarPadding.calculateTopPadding() + 8.dp), + ) { // Top row: library selector on the left, action icons on the right. Row( modifier = Modifier @@ -1220,28 +1328,16 @@ private fun LibrariesFloatingChrome( modifier = Modifier.weight(1f), ) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - ChromeIconButton(onClick = onSearchClick) { - Icon( - imageVector = Icons.Outlined.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(18.dp), - ) - } - ChromeProfileMenu( - activeProfile = activeProfile, - onRequestsClick = onRequestsClick, - onLiveTvClick = onLiveTvClick, - onSettingsClick = onSettingsClick, - onSwitchProfileClick = onSwitchProfileClick, - onSwitchServerClick = onSwitchServerClick, - onSignOutClick = onSignOutClick, - ) - } + TabTopBarActions( + activeProfile = activeProfile, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) } // iOS: top bar bottom inset = smallPadding (8). @@ -1255,8 +1351,9 @@ private fun LibrariesFloatingChrome( modifier = Modifier.padding(horizontal = 16.dp), ) - // iOS: tab selector bottom inset = padding (16). - Spacer(modifier = Modifier.height(16.dp)) + // iOS: tab selector bottom inset = padding (16). + Spacer(modifier = Modifier.height(16.dp)) + } } } @@ -1273,7 +1370,7 @@ private fun LibrarySelectorButton( modifier: Modifier = Modifier, ) { // iOS `LibrarySelectorButton`: VStack(spacing: 1) of a name+chevron row - // (prairieTitle = 18pt bold) above a prairieCaption (12pt) type label. + // (siloTitle = 18pt bold) above a siloCaption (12pt) type label. Column( modifier = modifier .clickable(enabled = canSwitch && library != null, onClick = onClick) @@ -1312,120 +1409,6 @@ private fun LibrarySelectorButton( } } -@Composable -private fun ChromeIconButton( - onClick: () -> Unit, - content: @Composable BoxScope.() -> Unit, -) { - // iOS `TopBarIconButton`/`ProfileAvatarMenu`: plain 40pt hit target with - // no surface fill or border — just the icon/avatar over the chrome scrim. - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - content = content, - ) -} - -@Composable -private fun ChromeProfileMenu( - activeProfile: Profile?, - onRequestsClick: (() -> Unit)?, - onLiveTvClick: (() -> Unit)?, - onSettingsClick: () -> Unit, - onSwitchProfileClick: () -> Unit, - onSwitchServerClick: () -> Unit, - onSignOutClick: () -> Unit, -) { - var menuExpanded by rememberSaveable { mutableStateOf(false) } - Box { - ChromeIconButton(onClick = { menuExpanded = true }) { - if (activeProfile != null) { - ProfileAvatar( - avatar = activeProfile.avatar, - name = activeProfile.name, - size = 36.dp, - ) - } else { - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f)), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Outlined.Person, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - ) { - if (onRequestsClick != null) { - DropdownMenuItem( - text = { Text("Requests") }, - onClick = { - menuExpanded = false - onRequestsClick() - }, - ) - } - if (onLiveTvClick != null) { - DropdownMenuItem( - text = { Text("Live TV") }, - onClick = { - menuExpanded = false - onLiveTvClick() - }, - ) - } - if (onRequestsClick != null || onLiveTvClick != null) { - HorizontalDivider() - } - DropdownMenuItem( - text = { Text("Settings") }, - onClick = { - menuExpanded = false - onSettingsClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Profile") }, - onClick = { - menuExpanded = false - onSwitchProfileClick() - }, - ) - DropdownMenuItem( - text = { Text("Switch Server") }, - onClick = { - menuExpanded = false - onSwitchServerClick() - }, - ) - DropdownMenuItem( - text = { - Text( - text = "Sign Out", - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - menuExpanded = false - onSignOutClick() - }, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun LibrariesSelectorSheet( @@ -1504,7 +1487,7 @@ private fun LibrarySubtabChip( selected: Boolean, onClick: () -> Unit, ) { - // iOS `LibraryPageTabSelector` chip: Capsule, prairieCaption (12pt), + // iOS `LibraryPageTabSelector` chip: Capsule, siloCaption (12pt), // selected = onSurface (white #EDEDED) fill / background (black) label and // semibold weight; unselected = surfaceElevated (#15171C) fill / secondary // label and regular weight. Padding h16 v8. @@ -1538,9 +1521,9 @@ private fun LibrarySelectorRow( onClick: () -> Unit, ) { // iOS `LibraryPickerRow`: cornerRadius (8) card, selected fill onSurface@10% - // else surfaceElevated, hairline prairieOutline (white@12%) border. Row + // else surfaceElevated, hairline siloOutline (white@12%) border. Row // padding h16 v14; circle 40 onSurface@12% with an 18pt icon; name - // prairieHeadline (16) above a prairieCaption (12) secondary label; + // siloHeadline (16) above a siloCaption (12) secondary label; // a 14pt checkmark on the selected row. Surface( onClick = onClick, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt new file mode 100644 index 000000000..0d0c26a3d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourLocalCache.kt @@ -0,0 +1,29 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import android.content.Context + +/** + * Local record of "this profile finished (or skipped) the tour", keyed per + * server + profile. The server stays the source of truth — this only lets + * the app skip the blocking state fetch on every profile selection once the + * answer is known to be "done". It is set on a server-confirmed done state + * or a locally initiated complete/skip, never on a failed check, so a + * network error can't silence a tour that is still pending. + */ +class OnboardingTourLocalCache(context: Context) { + + private val prefs = + context.getSharedPreferences("onboarding_tour", Context.MODE_PRIVATE) + + private fun key(serverId: String?, profileId: String?): String? { + if (serverId.isNullOrBlank() || profileId.isNullOrBlank()) return null + return "done:$serverId:$profileId" + } + + fun isDone(serverId: String?, profileId: String?): Boolean = + key(serverId, profileId)?.let { prefs.getBoolean(it, false) } ?: false + + fun markDone(serverId: String?, profileId: String?) { + key(serverId, profileId)?.let { prefs.edit().putBoolean(it, true).apply() } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt new file mode 100644 index 000000000..050304720 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourScreen.kt @@ -0,0 +1,370 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.util.lerp +import kotlin.math.abs +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.android.ui.components.aurora.AuroraAccent +import org.prairieserver.prairie.android.ui.components.aurora.AuroraGhostButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraInk +import org.prairieserver.prairie.android.ui.components.aurora.AuroraInkSecondary +import org.prairieserver.prairie.android.ui.components.aurora.AuroraPrimaryButton +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScreen +import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim +import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant +import org.prairieserver.prairie.android.ui.components.aurora.auroraGlass +import org.prairieserver.prairie.model.onboarding.OnboardingStep + +/** Gutter the fixed rows and the pager's peek share. */ +private val TourGutter = 20.dp + +/** Client-side illustration keys — the server only ever names them. */ +private fun illustrationFor(key: String?): ImageVector = when (key) { + "watchlist" -> Icons.Filled.Favorite + "watch-together" -> Icons.Filled.Groups + "calendar" -> Icons.Filled.CalendarMonth + "playback" -> Icons.Filled.PlayCircle + "subtitles" -> Icons.Filled.Subtitles + else -> Icons.Filled.AutoAwesome +} + +/** + * Server-driven first-run tour: one step per page, skip always reachable. + * Kind filtering happened in the ViewModel; by the time a step renders here + * it is one of the known kinds. + */ +@Composable +fun OnboardingTourScreen( + onDone: () -> Unit, + viewModel: OnboardingTourViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { viewModel.load() } + + LaunchedEffect(state.finished) { + if (state.finished) onDone() + } + + // scrollable = false: this screen sizes itself against the display — pips + // and buttons pinned, the cards taking the space between them. Inside a + // scrolling parent that weighted body would collapse to nothing. + // horizontalPadding = 0: the pager runs full-bleed so the next card peeks + // in from the edge; the fixed rows re-apply the gutter themselves. + AuroraScreen( + variant = AuroraVariant.SignIn, + scrim = AuroraScrim.Soft, + scrollable = false, + horizontalPadding = 0.dp, + ) { + if (state.isLoading || state.steps.isEmpty()) { + // Skip stays reachable while the manifest loads: this gate sits + // between profile selection and Home with the back stack already + // cleared, so a slow server must never hold the app hostage for + // the full request timeout. + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TourGutter), + horizontalArrangement = Arrangement.End, + ) { + AuroraGhostButton(label = "Skip", onClick = viewModel::onSkip) + } + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color(0xFFF3EFE9)) + } + } + return@AuroraScreen + } + + val step = state.steps[state.currentIndex] + val isLast = state.currentIndex == state.steps.lastIndex + + val pagerState = rememberPagerState( + // The tour can resume mid-way, so the pager has to open on the + // step the ViewModel restored rather than snapping to zero. + initialPage = state.currentIndex, + pageCount = { state.steps.size }, + ) + + // Pager -> ViewModel: only once a swipe has settled, so a drag the user + // releases halfway doesn't record a step they never actually saw. + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .collect { viewModel.onPageSettled(it) } + } + // ViewModel -> pager: Back/Next (and the resume index) drive the pager + // so buttons and swipes stay one shared position. Guarded on the + // pager's target, not its settled page — mid-fling the two disagree and + // an unguarded call here would cancel the user's own swipe. + LaunchedEffect(state.currentIndex) { + if (pagerState.targetPage != state.currentIndex) { + pagerState.animateScrollToPage(state.currentIndex) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + // Progress pips + skip + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TourGutter), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.weight(1f), + ) { + state.steps.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .height(6.dp) + .width(if (index == state.currentIndex) 18.dp else 6.dp) + .background( + color = if (index == state.currentIndex) { + Color(0xFFF3EFE9) + } else { + Color.White.copy(alpha = 0.25f) + }, + shape = CircleShape, + ), + ) + } + } + AuroraGhostButton(label = "Skip", onClick = viewModel::onSkip) + } + + Spacer(Modifier.height(24.dp)) + + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + // Inset the pages rather than the pager: the pager itself keeps + // the full width, so the neighbouring card slides in from the + // display edge instead of being clipped at a gutter. + contentPadding = PaddingValues(horizontal = TourGutter), + pageSpacing = 12.dp, + verticalAlignment = Alignment.CenterVertically, + ) { page -> + // Distance of this page from the settled position: 0 while it + // is the current card, ±1 once fully a neighbour. Driven by the + // live scroll offset so the card tracks the finger. + val offset = ( + (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + ).coerceIn(-1f, 1f) + val distance = abs(offset) + + TourStepPage( + step = state.steps[page], + selected = state.pendingChoices[state.steps[page].id] ?: "", + onChosen = viewModel::onSettingChosen, + modifier = Modifier.graphicsLayer { + // Neighbours sit slightly back and dimmed, so the stack + // reads as depth rather than a filmstrip. + val scale = lerp(0.92f, 1f, 1f - distance) + scaleX = scale + scaleY = scale + alpha = lerp(0.5f, 1f, 1f - distance) + }, + ) + } + + Spacer(Modifier.height(20.dp)) + + val isTerminal = step.kind == "handoff" || isLast + Row( + modifier = Modifier.padding(horizontal = TourGutter), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (state.currentIndex > 0) { + AuroraGhostButton(label = "Back", onClick = viewModel::onBack) + } + AuroraPrimaryButton( + label = when { + isTerminal -> "Done" + state.currentIndex == 0 -> "Show me" + else -> "Next" + }, + onClick = if (isTerminal) viewModel::onFinish else viewModel::onAdvance, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** + * One tour card. Rendered per pager page rather than for the current step + * alone, so the neighbouring pages are already drawn as a swipe reveals them. + */ +@Composable +private fun TourStepPage( + step: OnboardingStep, + selected: String, + onChosen: (OnboardingStep, String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + // No drop shadow: a full-height translucent card lets the shadow's + // own edge show through as a faint box, and it tracks the card + // across a swipe. + .auroraGlass(cornerRadius = 28.dp, elevation = 0.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + Brush.linearGradient( + listOf(AuroraAccent.copy(alpha = 0.28f), AuroraAccent.copy(alpha = 0.06f)), + ), + RoundedCornerShape(20.dp), + ) + .border(1.dp, AuroraAccent.copy(alpha = 0.30f), RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = illustrationFor(step.illustration), + contentDescription = null, + tint = AuroraAccent, + modifier = Modifier.size(30.dp), + ) + } + Spacer(Modifier.height(24.dp)) + step.title?.let { + Text( + text = it, + fontSize = 27.sp, + fontWeight = FontWeight.SemiBold, + color = AuroraInk, + lineHeight = 34.sp, + ) + } + step.body?.let { + Spacer(Modifier.height(12.dp)) + Text( + text = it, + fontSize = 15.sp, + color = AuroraInkSecondary, + lineHeight = 23.sp, + ) + } + + if (step.kind == "setting_choice" && step.setting != null) { + Spacer(Modifier.height(22.dp)) + SettingChoiceCard(step = step, selected = selected, onChosen = onChosen) + } + } +} + +/** + * Renders the manifest's options as a tappable list. Selection lives in the + * ViewModel (persisted when the user advances past the step), so the + * highlight can never disagree with what gets saved. + */ +@Composable +private fun SettingChoiceCard( + step: OnboardingStep, + selected: String, + onChosen: (OnboardingStep, String) -> Unit, +) { + val spec = step.setting ?: return + + Column( + modifier = Modifier + .fillMaxWidth() + // A hairline well rather than another glass panel: this now sits + // inside the card's glass, and stacking the two muddies both. + .background(Color.White.copy(alpha = 0.04f), RoundedCornerShape(20.dp)) + .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(20.dp)) + .padding(10.dp) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + spec.options.forEach { option -> + val isSelected = option.value == selected + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (isSelected) Color.White.copy(alpha = 0.12f) else Color.Transparent, + shape = RoundedCornerShape(12.dp), + ) + .clickable { onChosen(step, option.value) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(16.dp) + .background( + color = if (isSelected) Color(0xFFF3EFE9) else Color.Transparent, + shape = CircleShape, + ) + .border( + width = 1.5.dp, + color = if (isSelected) Color(0xFFF3EFE9) else Color.White.copy(alpha = 0.4f), + shape = CircleShape, + ), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = option.label, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = Color(0xFFF3EFE9), + ) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt new file mode 100644 index 000000000..79777add9 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/onboarding/OnboardingTourViewModel.kt @@ -0,0 +1,296 @@ +package org.prairieserver.prairie.android.ui.screens.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingStep +import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.repository.OnboardingRepository +import org.prairieserver.prairie.repository.ProfileRepository + +/** Step kinds this client can render; anything else is dropped at load. */ +private val KNOWN_KINDS = setOf("welcome", "feature_card", "setting_choice", "handoff") + +data class OnboardingTourUiState( + val isLoading: Boolean = true, + /** Empty after load with [finished] set = nothing to show. */ + val steps: List = emptyList(), + val tourId: String = "", + val currentIndex: Int = 0, + /** + * setting_choice values picked (or defaulted) but not yet persisted. + * Written when the user advances past the step, so what the card shows + * as selected is exactly what gets saved. + */ + val pendingChoices: Map = emptyMap(), + val finished: Boolean = false, +) + +/** + * Drives the server-driven first-run tour. Progress and completion post to + * the server per profile, so finishing here silences the web and TV too. + * setting_choice steps write through the existing profile-update path. + */ +class OnboardingTourViewModel( + private val onboardingRepository: OnboardingRepository, + private val profileRepository: ProfileRepository, + private val playerSettingsStore: PlayerSettingsStore, + private val tokenManager: TokenManager, + private val localCache: OnboardingTourLocalCache, +) : ViewModel() { + + private val _uiState = MutableStateFlow(OnboardingTourUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var loadStarted = false + + fun load() { + // The screen calls this from a LaunchedEffect that re-runs on every + // composition restart (rotation, theme change); without the guard a + // mid-tour user would be re-fetched back to step 1. + if (loadStarted) return + loadStarted = true + viewModelScope.launch { + // Known-done locally: skip the network entirely. The flag is only + // ever set from a server-confirmed done state or our own + // complete/skip, so trusting it can't hide a pending tour. + if (localCache.isDone(tokenManager.getCurrentServerId(), tokenManager.getProfileId())) { + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + // Fetch state and manifest together — the manifest is discarded + // when state says done, but that waste is cheaper than serializing + // two round trips in front of first render. + val stateDeferred = async { onboardingRepository.getState() } + val flowDeferred = async { onboardingRepository.getFlow(surface = "phone") } + val resumeStep: String? + when (val state = stateDeferred.await()) { + is ApiResult.Success -> { + if (state.data.done) { + // Server-confirmed done — safe to cache locally. + markDoneLocally() + flowDeferred.cancel() + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + resumeStep = state.data.lastStep + } + // On any error, skip the tour rather than block first run — + // but don't cache done: the server was never consulted. + is ApiResult.Error, is ApiResult.NetworkError -> { + flowDeferred.cancel() + _uiState.update { it.copy(isLoading = false, finished = true) } + return@launch + } + } + when (val flow = flowDeferred.await()) { + is ApiResult.Success -> applyFlow(flow.data, resumeStep) + is ApiResult.Error, is ApiResult.NetworkError -> { + _uiState.update { it.copy(isLoading = false, finished = true) } + } + } + } + } + + private suspend fun applyFlow(flow: OnboardingFlow, resumeStep: String?) { + val steps = flow.steps.filter { it.kind in KNOWN_KINDS } + if (steps.isEmpty()) { + // Nothing renderable: mark complete so we never loop — locally + // only once the server acknowledged, so an offline auto-complete + // is retried next launch instead of silently diverging. + _uiState.update { it.copy(isLoading = false, finished = true) } + // Snapshot before the POST: finished=true navigates to Home, where + // the active profile can change while this is still in flight. + val serverId = tokenManager.getCurrentServerId() + val profileId = tokenManager.getProfileId() + withContext(NonCancellable) { + if (onboardingRepository.complete(flow.tourId, null) is ApiResult.Success) { + localCache.markDone(serverId, profileId) + } + } + return + } + // Seed each setting_choice with its manifest default so a user who + // accepts what the card already shows still has it persisted on + // advance. + val defaults = steps + .filter { it.kind == "setting_choice" } + .mapNotNull { step -> step.setting?.default?.let { step.id to it } } + .toMap() + // Progress recorded from another device or before a process death: + // resume at the server's last-seen step instead of step one. + val startIndex = resumeStep + ?.let { last -> steps.indexOfFirst { it.id == last } } + ?.takeIf { it >= 0 } + ?: 0 + _uiState.update { + it.copy( + isLoading = false, + steps = steps, + tourId = flow.tourId, + currentIndex = startIndex, + pendingChoices = defaults, + ) + } + } + + fun onAdvance() { + val current = _uiState.value + val next = current.currentIndex + 1 + if (next >= current.steps.size) { + finish(skipped = false, persistCurrentChoice = true) + return + } + moveTo(next) + } + + fun onBack() { + moveTo(_uiState.value.currentIndex - 1) + } + + /** + * Settle handler for swipe navigation. The pager is the one that moved, so + * this only reconciles state; the screen must not echo it back as a scroll + * or the two chase each other. + */ + fun onPageSettled(index: Int) { + if (index == _uiState.value.currentIndex) return + moveTo(index) + } + + /** + * Single path for every index change — button or swipe — so a step reached + * by swiping records and persists exactly like one reached by tapping. + */ + private fun moveTo(target: Int) { + val current = _uiState.value + val index = target.coerceIn(0, current.steps.lastIndex.coerceAtLeast(0)) + if (index == current.currentIndex) return + // Advancing past a setting_choice commits it; going back doesn't, so a + // user who swipes backwards to reconsider isn't saving on the way out. + if (index > current.currentIndex) { + persistChoiceIfAny(current.steps.getOrNull(current.currentIndex)) + viewModelScope.launch { + current.steps.getOrNull(index)?.let { + onboardingRepository.recordStep(current.tourId, it.id) + } + } + } + _uiState.update { it.copy(currentIndex = index) } + } + + fun onSkip() = finish(skipped = true, persistCurrentChoice = false) + + fun onFinish() = finish(skipped = false, persistCurrentChoice = true) + + private fun finish(skipped: Boolean, persistCurrentChoice: Boolean) { + val current = _uiState.value + if (persistCurrentChoice) { + persistChoiceIfAny(current.steps.getOrNull(current.currentIndex)) + } + // Skip tapped while the manifest is still loading: there is no tour + // id to post against. Let the user through; server state stays + // not-done, so the tour is simply offered again another time. + if (current.tourId.isBlank()) { + _uiState.update { it.copy(finished = true) } + return + } + viewModelScope.launch { + val lastStep = current.steps.getOrNull(current.currentIndex)?.id + // Snapshot the identity that is finishing the tour. finished=true + // navigates to Home, where the user can switch profile or server + // while this POST is still in flight — reading the token manager on + // acknowledgement would then mark whichever profile is active by + // then, letting it skip a tour it never saw. + val serverId = tokenManager.getCurrentServerId() + val profileId = tokenManager.getProfileId() + // finished=true (below) navigates away with popUpTo, which clears + // this ViewModel and cancels its scope — the POST must survive + // that or the server never learns the tour ended and re-shows it. + // The local done-cache is written only on the server's ack: if + // the POST is lost, the next launch re-consults the server and + // retries the tour rather than silently diverging from every + // other client. + withContext(NonCancellable) { + val result = if (skipped) { + onboardingRepository.skip(current.tourId, lastStep) + } else { + onboardingRepository.complete(current.tourId, lastStep) + } + if (result is ApiResult.Success) { + localCache.markDone(serverId, profileId) + } + } + } + _uiState.update { it.copy(finished = true) } + } + + private fun markDoneLocally() { + viewModelScope.launch { + withContext(NonCancellable) { + localCache.markDone(tokenManager.getCurrentServerId(), tokenManager.getProfileId()) + } + } + } + + /** Records a tapped option locally; nothing is written until advance. */ + fun onSettingChosen(step: OnboardingStep, value: String) { + _uiState.update { it.copy(pendingChoices = it.pendingChoices + (step.id to value)) } + } + + /** + * Writes one setting_choice value. UpdateProfileRequest is typed per + * field, so the manifest's string key maps onto the matching field; + * unknown keys (a newer server) are ignored rather than failing the + * tour. Only profile_field targets exist for phones today. + */ + private fun persistChoiceIfAny(step: OnboardingStep?) { + val spec = step?.setting ?: return + if (spec.target != "profile_field") return + val value = _uiState.value.pendingChoices[step.id] ?: return + val request = when (spec.key) { + "quality_preference" -> UpdateProfileRequest(qualityPreference = value) + "subtitle_language" -> UpdateProfileRequest(subtitleLanguage = value) + "subtitle_mode" -> UpdateProfileRequest(subtitleMode = value) + "auto_skip_intro" -> UpdateProfileRequest(autoSkipIntro = value.toBoolean()) + "auto_skip_credits" -> UpdateProfileRequest(autoSkipCredits = value.toBoolean()) + else -> return + } + viewModelScope.launch { + withContext(NonCancellable) { + // Android playback and the Settings screen read quality and + // auto-skip from the local player settings store, not the + // profile fields — mirror those there too or the choice the + // tour just showed has no visible effect in this app. + when (spec.key) { + "quality_preference" -> playerSettingsStore.setPreferredQuality(value) + // The tour's step is still the profile DTO's boolean, but + // what this device plays back with is the enum that + // superseded it, so the mirror writes the mode. `never` is + // not reachable from the tour; the settings screen offers it. + "auto_skip_intro" -> playerSettingsStore.setIntroSkipMode( + IntroSkipMode.fromLegacyBoolean(value.toBoolean()), + ) + "auto_skip_credits" -> playerSettingsStore.setAutoSkipCredits(value.toBoolean()) + } + // Best-effort against the profile: the local store above is + // what this device plays back with, and Settings re-syncs + // from the server later. A rejected PUT here shouldn't trap + // the user in a tour they can't leave. + profileRepository.updateActiveProfile(request) + } + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt index fe52704ae..b15ea99fc 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/pairing/CompanionPairingBottomOverlay.kt @@ -15,16 +15,21 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckCircle @@ -55,15 +60,11 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.prairieserver.prairie.common.pairing.CompanionPairingApproval import org.prairieserver.prairie.common.pairing.CompanionPairingServer import org.prairieserver.prairie.common.pairing.CompanionPairingStatus import org.prairieserver.prairie.common.pairing.CompanionPairingTarget -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Refresh /** Bottom-anchored companion setup card matching the iOS presentation. */ @Composable @@ -150,10 +151,20 @@ private fun PairingCard( onDecline: () -> Unit, onDismiss: () -> Unit, ) { + val bottomInset = maxOf( + LocalBottomChromeInset.current, + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(), + ) + Card( modifier = Modifier - .navigationBarsPadding() - .padding(horizontal = 10.dp, vertical = 8.dp) + .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + .padding( + start = 10.dp, + top = 8.dp, + end = 10.dp, + bottom = bottomInset + 8.dp, + ) .widthIn(max = 640.dp) .fillMaxWidth() .animateContentSize() @@ -287,21 +298,9 @@ private fun ServerPicker( .height(48.dp) .padding(top = 6.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Continue", style = MaterialTheme.typography.titleMedium) } TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Cancel") } } @@ -335,12 +334,6 @@ private fun DiscoveryStep( ) PrimaryAction(label = "Set Up", onClick = onPair, modifier = Modifier.padding(top = 14.dp)) TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Not Now") } } @@ -379,12 +372,6 @@ private fun MatchConfirmation( modifier = Modifier.padding(top = 14.dp), ) TextButton(onClick = onDecline, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Doesn't match") } } @@ -424,12 +411,6 @@ private fun ProgressStep(status: CompanionPairingStatus, onCancel: () -> Unit) { strokeWidth = 3.dp, ) TextButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Cancel") } } @@ -465,12 +446,6 @@ private fun TerminalStep( PrimaryAction(label = primaryLabel, onClick = onPrimary, modifier = Modifier.padding(top = 8.dp)) onSecondary?.let { secondary -> TextButton(onClick = secondary, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Close") } } @@ -483,26 +458,12 @@ private fun PrimaryAction( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val icon = when { - label.equals("Continue", ignoreCase = true) || label.equals("Approve", ignoreCase = true) -> - Icons.AutoMirrored.Filled.ArrowForward - label.equals("Try Again", ignoreCase = true) || label.equals("Retry", ignoreCase = true) -> - Icons.Default.Refresh - label.startsWith("Set Up", ignoreCase = true) -> Icons.Default.Link - else -> Icons.Default.Check - } Button( onClick = onClick, modifier = modifier .fillMaxWidth() .height(48.dp), ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text(label, style = MaterialTheme.typography.titleMedium) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/FavoritesScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/FavoritesScreen.kt index 2ec4fc6d7..da56503bb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/FavoritesScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/FavoritesScreen.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.android.ui.screens.personal import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import org.prairieserver.prairie.android.ui.components.PrairieTopBar /** @@ -28,9 +29,13 @@ fun FavoritesScreen( }, containerColor = MaterialTheme.colorScheme.background, ) { padding -> + val controls = rememberPersonalListControls(PersonalListSource.Favorites) + val query by controls.queryState() FavoritesGridContent( onItemClick = onItemClick, contentPadding = padding, + query = query, + header = { state -> PersonalListControlsRow(controls = controls, total = state.total) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControls.kt new file mode 100644 index 000000000..3ae01e647 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControls.kt @@ -0,0 +1,130 @@ +package org.prairieserver.prairie.android.ui.screens.personal + +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.activity.compose.LocalActivity +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModelStoreOwner +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.android.ui.components.SortFilterControlsRow +import org.prairieserver.prairie.android.ui.components.SortMenuOption +import org.prairieserver.prairie.android.ui.screens.browse.FilterSheet +import org.prairieserver.prairie.catalog.filter.BrowseFacetMediaType +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.viewmodel.PersonalListQuery +import org.prairieserver.prairie.viewmodel.PersonalListViewModel + +/** `/catalog?source=` values the saved lists fetch through. */ +object PersonalListSource { + const val Watchlist = "watchlist" + const val Favorites = "favorites" +} + +/** + * The sort/filter controls for one saved list, resolved against the + * Activity's ViewModelStore keyed by [source] and the active server/profile + * so the For You inline grid and the standalone Watchlist / Favorites screens + * share one selection (nav back-stack entries would otherwise each get their + * own), while a profile or server switch starts fresh instead of inheriting + * the previous identity's query and facet vocabulary. + */ +@Composable +fun rememberPersonalListControls(source: String): PersonalListControlsViewModel { + val registry: ServerRegistry = koinInject() + val serverId by registry.activeServerId.collectAsState() + val entry by registry.activeEntry.collectAsState() + val identity = "${serverId ?: "-"}:${entry?.profileId ?: "-"}" + val key = "personal-controls-$source-$identity" + val activity = LocalActivity.current as? ComponentActivity + return if (activity != null) { + koinViewModel( + viewModelStoreOwner = activity as ViewModelStoreOwner, + key = key, + parameters = { parametersOf(source) }, + ) + } else { + koinViewModel(key = key, parameters = { parametersOf(source) }) + } +} + +/** Pushes the controls' derived query into the list ViewModel whenever it changes. */ +@Composable +fun ApplyPersonalListQuery(controls: PersonalListControlsViewModel, listViewModel: PersonalListViewModel) { + val state by controls.uiState.collectAsState() + LaunchedEffect(state.query) { listViewModel.applyQuery(state.query) } +} + +/** The controls' current query, for callers that hand it to a grid. */ +@Composable +fun PersonalListControlsViewModel.queryState(): State { + val state by uiState.collectAsState() + return remember(state.query) { mutableStateOf(state.query) } +} + +/** + * Sort ▾ · Filter (n) · Reset with the item count on the trailing side — the + * phone counterpart of the TV `PersonalControlHeader`, built on the shared + * [SortFilterControlsRow]. Sits in the grid's spanning header so it scrolls + * with the content and stays reachable when the list is empty. + */ +@Composable +fun PersonalListControlsRow( + controls: PersonalListControlsViewModel, + total: Int, + modifier: Modifier = Modifier, +) { + val state by controls.uiState.collectAsState() + var showFilterSheet by remember { mutableStateOf(false) } + + SortFilterControlsRow( + modifier = modifier, + sortLabel = state.sort.label, + sortActive = state.sort != PersonalListSort.ListOrder, + sortOptions = PersonalListSort.entries.mapIndexed { index, sort -> + SortMenuOption( + id = sort.name, + label = sort.label, + selectedLabel = if (sort.hasDirection) "${sort.label} · ${sort.directionLabel(state.order)}" else sort.label, + flipsOnReselect = sort.hasDirection, + dividerAbove = index == 1, + ) + }, + selectedSortId = state.sort.name, + onSelectSort = { id -> controls.selectSort(PersonalListSort.valueOf(id)) }, + filterCount = state.activeFacetCount, + onOpenFilters = { showFilterSheet = true }, + showReset = state.isCustomised, + onReset = controls::resetAll, + trailing = { + if (total > 0) { + Text( + text = if (total == 1) "1 title" else "$total titles", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + + if (showFilterSheet) { + FilterSheet( + currentFilters = state.filters, + availableFilters = state.availableFilters, + mediaType = BrowseFacetMediaType.Video, + onCommit = controls::applyFilters, + onDismiss = { showFilterSheet = false }, + ) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControlsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControlsViewModel.kt new file mode 100644 index 000000000..c255b8f43 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListControlsViewModel.kt @@ -0,0 +1,128 @@ +package org.prairieserver.prairie.android.ui.screens.personal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.catalog.filter.CatalogFilterQueryBuilder +import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.viewmodel.PersonalListQuery + +/** + * Sort keys for the saved lists (Watchlist / Favorites). Mirrors the TV + * `TvLibrarySortOption.availableForPersonalList()` set: the default is the + * server's stored list order, which is "no sort" on the wire, so [value] is + * null there and it has no direction. "Recently Added" is the explicit + * added_at sort (newest first by default). + */ +enum class PersonalListSort( + val value: String?, + val label: String, + val defaultOrder: String, + private val ascendingLabel: String, + private val descendingLabel: String, +) { + ListOrder(null, "List Order", "desc", "", ""), + Title("title", "Title", "asc", "A–Z", "Z–A"), + RecentlyAdded("added_at", "Recently Added", "desc", "Oldest first", "Newest first"), + Year("year", "Year", "desc", "Oldest first", "Newest first"), + Rating("rating_imdb", "Rating", "desc", "Lowest first", "Highest first"), + Runtime("runtime", "Runtime", "asc", "Shortest first", "Longest first"), + ; + + val hasDirection: Boolean get() = value != null + + fun directionLabel(order: String): String = + if (order == "asc") ascendingLabel else descendingLabel +} + +data class PersonalListControlsState( + val sort: PersonalListSort = PersonalListSort.ListOrder, + val order: String = PersonalListSort.ListOrder.defaultOrder, + /** Facet selections + match mode; its sort/order fields are unused here. */ + val filters: CatalogFilterState = CatalogFilterState(), + /** Vocabularies scoped to this list (`/catalog/filters?source=…`). */ + val availableFilters: CatalogFiltersResponse? = null, +) { + val activeFacetCount: Int get() = filters.activeFacetCount + + /** Anything to reset — a non-default sort or any facet. */ + val isCustomised: Boolean + get() = sort != PersonalListSort.ListOrder || filters.hasActiveFilters + + /** What the shared list ViewModel should fetch with. */ + val query: PersonalListQuery + get() = PersonalListQuery( + sort = sort.value, + order = if (sort.hasDirection) order else null, + queryGroups = CatalogFilterQueryBuilder.buildGroups(filters), + match = CatalogFilterQueryBuilder.matchParam(filters).takeIf { filters.hasActiveFilters }, + ) +} + +/** + * Sort + filter selection for one saved list, applied server-side through + * `PersonalListViewModel.applyQuery`. Session-only and shared between the + * For You inline grid and the standalone screen (the caller scopes it to the + * Activity, keyed by [source]) — the same shape as the TV app's + * `TvPersonalListControlsViewModel`. + */ +class PersonalListControlsViewModel( + private val source: String, + private val catalogRepository: CatalogRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PersonalListControlsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadFilters() + } + + /** Re-picking the active key flips its direction; a new key starts at its default. */ + fun selectSort(sort: PersonalListSort) { + _uiState.update { state -> + if (sort == state.sort && sort.hasDirection) { + state.copy(order = if (state.order == "asc") "desc" else "asc") + } else { + state.copy(sort = sort, order = sort.defaultOrder) + } + } + } + + fun applyFilters(filters: CatalogFilterState) { + _uiState.update { it.copy(filters = filters) } + } + + fun resetFilters() { + _uiState.update { it.copy(filters = it.filters.resetFilters()) } + } + + /** Back to the defaults: list order, no facets. */ + fun resetAll() { + _uiState.update { + it.copy( + sort = PersonalListSort.ListOrder, + order = PersonalListSort.ListOrder.defaultOrder, + filters = it.filters.resetFilters(), + ) + } + } + + private fun loadFilters() { + viewModelScope.launch { + val result = catalogRepository.getFilters(includeTechnical = true, source = source) + if (result is ApiResult.Success) { + _uiState.update { it.copy(availableFilters = result.data) } + } + // Vocabularies are a convenience; without them the sheet simply + // offers its fixed facets. + } + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListsScreen.kt index f7a614e16..8fba835db 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalListsScreen.kt @@ -49,18 +49,30 @@ fun PersonalListsScreen( } when (selectedTabIndex) { - 0 -> FavoritesGridContent( - onItemClick = onItemClick, - modifier = Modifier - .weight(1f) - .fillMaxSize(), - ) - else -> WatchlistGridContent( - onItemClick = onItemClick, - modifier = Modifier - .weight(1f) - .fillMaxSize(), - ) + 0 -> { + val controls = rememberPersonalListControls(PersonalListSource.Favorites) + val query by controls.queryState() + FavoritesGridContent( + onItemClick = onItemClick, + modifier = Modifier + .weight(1f) + .fillMaxSize(), + query = query, + header = { state -> PersonalListControlsRow(controls = controls, total = state.total) }, + ) + } + else -> { + val controls = rememberPersonalListControls(PersonalListSource.Watchlist) + val query by controls.queryState() + WatchlistGridContent( + onItemClick = onItemClick, + modifier = Modifier + .weight(1f) + .fillMaxSize(), + query = query, + header = { state -> PersonalListControlsRow(controls = controls, total = state.total) }, + ) + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt index 561514b9d..24aada9f5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/PersonalMediaGridContent.kt @@ -5,6 +5,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -35,6 +38,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight @@ -43,15 +47,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.components.EmptyStateView import org.prairieserver.prairie.android.ui.components.ErrorView -import org.prairieserver.prairie.android.ui.components.LoadingIndicator +import org.prairieserver.prairie.android.ui.components.PosterGridSkeleton +import org.prairieserver.prairie.android.ui.components.rememberShimmerProgress import org.prairieserver.prairie.android.ui.components.MediaCardContextMenu import org.prairieserver.prairie.android.ui.components.MediaGridDefaults import org.prairieserver.prairie.android.ui.components.WatchedBadge import org.prairieserver.prairie.android.ui.components.rememberBrowseItemCardActions import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.overlays.CardOverlayVariant +import org.prairieserver.prairie.common.overlays.CardOverlays +import org.prairieserver.prairie.common.overlays.LocalCardOverlayUiState import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.overlays.OverlayDataExtractor import org.prairieserver.prairie.viewmodel.FavoritesViewModel import org.prairieserver.prairie.viewmodel.HistoryViewModel +import org.prairieserver.prairie.viewmodel.PersonalListQuery import org.prairieserver.prairie.viewmodel.PersonalListUiState import org.prairieserver.prairie.viewmodel.WatchlistViewModel import org.koin.compose.viewmodel.koinViewModel @@ -61,14 +71,21 @@ fun FavoritesGridContent( onItemClick: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + header: (@Composable (PersonalListUiState) -> Unit)? = null, + /** Sort/filter to fetch with; null keeps whatever the ViewModel has. */ + query: PersonalListQuery? = null, viewModel: FavoritesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + if (query != null) { + LaunchedEffect(query) { viewModel.applyQuery(query) } + } PersonalMediaGridContent( state = state, modifier = modifier, contentPadding = contentPadding, + header = header, emptyTitle = "No favorites", emptySubtitle = "Tap the heart icon on any item to add it here", emptyIcon = Icons.Outlined.FavoriteBorder, @@ -91,14 +108,21 @@ fun WatchlistGridContent( onItemClick: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + header: (@Composable (PersonalListUiState) -> Unit)? = null, + /** Sort/filter to fetch with; null keeps whatever the ViewModel has. */ + query: PersonalListQuery? = null, viewModel: WatchlistViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + if (query != null) { + LaunchedEffect(query) { viewModel.applyQuery(query) } + } PersonalMediaGridContent( state = state, modifier = modifier, contentPadding = contentPadding, + header = header, emptyTitle = "Watchlist is empty", emptySubtitle = "Tap the bookmark icon on any item to add it here", emptyIcon = Icons.Outlined.BookmarkBorder, @@ -121,6 +145,7 @@ fun HistoryGridContent( onItemClick: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + header: (@Composable (PersonalListUiState) -> Unit)? = null, viewModel: HistoryViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -129,6 +154,7 @@ fun HistoryGridContent( state = state, modifier = modifier, contentPadding = contentPadding, + header = header, emptyTitle = "No watch history", emptySubtitle = "Items you watch will appear here", emptyIcon = Icons.Outlined.History, @@ -157,8 +183,14 @@ private fun PersonalMediaGridContent( itemContent: @Composable (BrowseItem) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), + // Optional full-width row that scrolls with the grid (For You's saved-list + // pills, the sort/filter controls). It also renders above the loading / + // empty / error views so the controls stay reachable when the list has + // nothing to show. Receives the state so it can show the item count. + header: (@Composable (PersonalListUiState) -> Unit)? = null, ) { val gridState = rememberLazyGridState() + val layoutDirection = LocalLayoutDirection.current val shouldLoadMore by remember { derivedStateOf { @@ -176,38 +208,65 @@ private fun PersonalMediaGridContent( when { state.isLoading -> { - LoadingIndicator(modifier = modifier.padding(contentPadding)) + // Poster-grid skeleton (not a spinner) so the list keeps its shape + // while it loads; the header's controls stay reachable above it. + Column(modifier = modifier.padding(contentPadding)) { + header?.let { Box(modifier = Modifier.padding(16.dp)) { it(state) } } + PosterGridSkeleton( + progress = rememberShimmerProgress(), + modifier = Modifier.weight(1f), + ) + } } state.error != null && state.items.isEmpty() -> { - ErrorView( - message = state.error ?: "Unknown error", - onRetry = onRetry, - modifier = modifier.padding(contentPadding), - ) + Column(modifier = modifier.padding(contentPadding)) { + header?.let { Box(modifier = Modifier.padding(16.dp)) { it(state) } } + ErrorView( + message = state.error ?: "Unknown error", + onRetry = onRetry, + modifier = Modifier.weight(1f), + ) + } } state.items.isEmpty() -> { - EmptyStateView( - title = emptyTitle, - subtitle = emptySubtitle, - icon = emptyIcon, - modifier = modifier.padding(contentPadding), - ) + Column(modifier = modifier.padding(contentPadding)) { + header?.let { Box(modifier = Modifier.padding(16.dp)) { it(state) } } + // A narrowed query with no hits is not an empty list — say so, + // and keep the header's controls reachable to widen it (TV parity). + val filtered = !state.query.isDefault + EmptyStateView( + title = if (filtered) "No matches" else emptyTitle, + subtitle = if (filtered) "No titles match the current filters." else emptySubtitle, + icon = emptyIcon, + modifier = Modifier.weight(1f), + ) + } } else -> { PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, - modifier = modifier - .fillMaxSize() - .padding(contentPadding), + modifier = modifier.fillMaxSize(), ) { + // contentPadding goes inside the grid so items scroll edge to + // edge under any chrome the caller reserved space for. LazyVerticalGrid( columns = GridCells.Adaptive(MediaGridDefaults.PosterGridMinWidth), state = gridState, - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues( + start = 16.dp + contentPadding.calculateStartPadding(layoutDirection), + top = 16.dp + contentPadding.calculateTopPadding(), + end = 16.dp + contentPadding.calculateEndPadding(layoutDirection), + bottom = 16.dp + contentPadding.calculateBottomPadding(), + ), horizontalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridHorizontalSpacing), verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), ) { + if (header != null) { + item(key = "header", span = { GridItemSpan(maxLineSpan) }) { + header(state) + } + } items( items = state.items, key = { it.contentId }, @@ -251,6 +310,7 @@ fun MediaGridItem( isInWatchlist: Boolean = false, ) { val (actions, userState) = rememberBrowseItemCardActions(item) + val overlayState = LocalCardOverlayUiState.current var menuExpanded by remember { mutableStateOf(false) } androidx.compose.foundation.layout.Column( @@ -260,17 +320,28 @@ fun MediaGridItem( ), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Box { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f / 3.3f) + .clip(RoundedCornerShape(8.dp)), + ) { ThumbhashImage( url = item.posterUrl, thumbhash = item.posterThumbhash, contentDescription = item.title, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(2f / 3.3f) - .clip(RoundedCornerShape(8.dp)), + modifier = Modifier.fillMaxSize(), ) + if (overlayState.enabled) { + CardOverlays( + data = OverlayDataExtractor.fromBrowseItem(item), + prefs = overlayState.prefs, + variant = CardOverlayVariant.Poster, + modifier = Modifier.fillMaxSize(), + ) + } + if (userState.played) { WatchedBadge(modifier = Modifier.align(Alignment.TopEnd)) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/WatchlistScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/WatchlistScreen.kt index f57cb2a07..ea247a62b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/WatchlistScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/personal/WatchlistScreen.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.android.ui.screens.personal import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import org.prairieserver.prairie.android.ui.components.PrairieTopBar /** @@ -24,9 +25,13 @@ fun WatchlistScreen( }, containerColor = MaterialTheme.colorScheme.background, ) { padding -> + val controls = rememberPersonalListControls(PersonalListSource.Watchlist) + val query by controls.queryState() WatchlistGridContent( onItemClick = onItemClick, contentPadding = padding, + query = query, + header = { state -> PersonalListControlsRow(controls = controls, total = state.total) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt index 4c293267d..794f848c2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/AiTranslateSheet.kt @@ -12,13 +12,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -26,7 +24,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -41,16 +38,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.util.LanguageNames import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.subtitles.SubtitleAiJobKind -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.width private val WarnAmber = Color(0xFFEAB308) private val ErrorRed = Color(0xFFEF4444) @@ -79,6 +76,7 @@ fun AiTranslateSheet( // Tracks-submenu back affordance: closes this sheet and reopens the parent // TracksSheet (wired in PlayerOverlay). Null falls back to a plain dismiss. onBack: (() -> Unit)? = null, + tabletopPaneHeight: Dp? = null, ) { val aiStatus = tools.aiStatus val sourceTracks = remember(subtitleTracks) { subtitleTracks.filter(::isTranslatableSource) } @@ -105,15 +103,16 @@ fun AiTranslateSheet( if (tools.jobJustCompleted) (onBack ?: onDismiss)() } - ModalBottomSheet( + PlayerModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Column( modifier = Modifier .fillMaxWidth() + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard) .background( brush = Brush.verticalGradient( colors = listOf( @@ -121,11 +120,13 @@ fun AiTranslateSheet( Color.Black.copy(alpha = 0.92f), ), ), - ), + ) + .verticalScroll(rememberScrollState()), ) { PlayerSheetHeader( title = "Translate with AI", onBack = onBack, + onDismiss = onDismiss, ) val activeJob = tools.activeJob @@ -147,12 +148,6 @@ fun AiTranslateSheet( onClick = onCancelJob, modifier = Modifier.padding(top = 12.dp), ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Cancel") } } @@ -267,16 +262,6 @@ fun AiTranslateSheet( color = Color.White, ) } else { - Icon( - imageVector = if (mode == AiMode.Audio) { - Icons.Default.AutoAwesome - } else { - Icons.Default.Translate - }, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text(if (mode == AiMode.Audio) "Generate" else "Translate") } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt index fa37d0662..5d486244e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/ChaptersSheet.kt @@ -1,25 +1,28 @@ package org.prairieserver.prairie.android.ui.screens.player -import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -27,27 +30,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import org.prairieserver.prairie.android.ui.util.formatClockTime import org.prairieserver.prairie.model.catalog.VersionChapter -import kotlinx.coroutines.launch -/** - * Chapter picker bottom sheet. Tap a row to seek the player to that chapter's - * `startSeconds`. Opened from the "Chapters" row in [PlayerSettingsSheet]. - * - * Mirrors iOS phone's chapter list behavior — server-supplied via - * `FileVersion.chapters` (FFprobe-extracted at ingest). Thumbnails - * (`thumbnailUrl` + `thumbnailThumbhash`) intentionally not rendered in the - * first cut; text rows are complete shipping content. - */ +/** Adaptive chapter picker shared by regular phones and foldable tabletop mode. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChaptersSheet( @@ -55,134 +50,178 @@ fun ChaptersSheet( chapters: List, onSelect: (chapterIndex: Int) -> Unit, onDismiss: () -> Unit, - // Current playback position (seconds) so the active chapter shows the iOS - // `play.fill` indicator. Display-only; defaults to 0. position: Double = 0.0, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return - // iOS marks the last chapter whose start time is <= currentTime. val currentChapterIndex = chapters.indexOfLast { it.startSeconds <= position } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + val dismissSheet = { + scope.launch { sheetState.hide() } + onDismiss() + } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Column( modifier = Modifier .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard), ) { - Text( - text = "Chapters", - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 8.dp), + PlayerSheetHeader( + title = "Chapters", + subtitle = when (chapters.size) { + 1 -> "1 chapter" + else -> "${chapters.size} chapters" + }, + onDismiss = dismissSheet, ) if (chapters.isEmpty()) { - Text( - text = "No chapters in this title", - color = Color.White.copy(alpha = 0.6f), - fontSize = 14.sp, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - ) + PlayerSheetCard( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = playerSheetHorizontalPadding(tabletopPaneHeight), + vertical = 8.dp, + ), + ) { + Text( + text = "No chapters are available for this title.", + color = Color.White.copy(alpha = 0.62f), + fontSize = 14.sp, + modifier = Modifier.padding(20.dp), + ) + } } else { - LazyColumn(modifier = Modifier.fillMaxWidth()) { + LazyVerticalGrid( + columns = GridCells.Fixed(if (tabletopPaneHeight == null) 1 else 2), + modifier = Modifier + .fillMaxWidth() + .then( + if (tabletopPaneHeight == null) Modifier else Modifier.weight(1f), + ), + contentPadding = PaddingValues( + start = playerSheetHorizontalPadding(tabletopPaneHeight), + end = playerSheetHorizontalPadding(tabletopPaneHeight), + top = 8.dp, + bottom = 24.dp, + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { itemsIndexed( chapters, - key = { _, c -> c.index }, - contentType = { _, _ -> "chapter-row" }, - ) { idx, ch -> - ChapterRow( - chapter = ch, - isCurrent = idx == currentChapterIndex, + key = { _, chapter -> chapter.index }, + contentType = { _, _ -> "chapter-card" }, + ) { index, chapter -> + ChapterCard( + chapter = chapter, + isCurrent = index == currentChapterIndex, onClick = { - onSelect(idx) - scope.launch { sheetState.hide() } - onDismiss() + onSelect(index) + dismissSheet() }, ) } } } - - Spacer(modifier = Modifier.height(16.dp)) } } } @Composable -private fun ChapterRow( +private fun ChapterCard( chapter: VersionChapter, isCurrent: Boolean, onClick: () -> Unit, ) { - // iOS phone row: leading "N." (white 0.6, width 30 trailing-aligned), - // VStack(title, time caption white 0.6 monospaced), Spacer, trailing - // `play.fill` (tint) when this is the current chapter. - Row( + val shape = RoundedCornerShape(16.dp) + Surface( + color = if (isCurrent) PlayerSheetSelectedColor else PlayerSheetCardColor, + shape = shape, modifier = Modifier .fillMaxWidth() - .clickable { onClick() } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + .heightIn(min = 72.dp) + .then( + if (isCurrent) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + shape = shape, + ) + } else { + Modifier + }, + ) + .clickable(onClick = onClick), ) { - Text( - text = "${chapter.index + 1}.", - color = Color.White.copy(alpha = 0.6f), - fontSize = 14.sp, - textAlign = TextAlign.End, - modifier = Modifier.width(30.dp), - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, - color = Color.White, - style = MaterialTheme.typography.bodyLarge, - ) - Text( - text = formatClockTime(chapter.startSeconds), - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - ) - } - if (isCurrent) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "Now playing", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) + Surface( + shape = CircleShape, + color = if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + Color.White.copy(alpha = 0.07f) + }, + modifier = Modifier.size(38.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (isCurrent) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Now playing", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } else { + Text( + text = (chapter.index + 1).toString(), + color = Color.White.copy(alpha = 0.72f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = chapter.title.ifBlank { "Chapter ${chapter.index + 1}" }, + color = Color.White, + fontSize = 15.sp, + fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = formatClockTime(chapter.startSeconds), + color = Color.White.copy(alpha = 0.52f), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ) + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPosture.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPosture.kt new file mode 100644 index 000000000..8fd91ca7d --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPosture.kt @@ -0,0 +1,98 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import android.app.Activity +import android.graphics.Rect +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker + +/** + * Vendor-neutral tabletop posture reported by Jetpack WindowManager. The fold + * bounds are in window coordinates and let the player avoid the physical + * crease or hinge rather than assuming the display splits exactly in half. + */ +internal data class TabletopPlayerPosture( + val foldBounds: Rect, +) + +internal data class TabletopPlayerPaneLayout( + val videoHeightPx: Int, + val controlsHeightPx: Int, +) + +internal fun isTabletopPlayerPosture( + state: FoldingFeature.State, + orientation: FoldingFeature.Orientation, + isSeparating: Boolean, +): Boolean = + state == FoldingFeature.State.HALF_OPENED && + orientation == FoldingFeature.Orientation.HORIZONTAL && + isSeparating + +/** + * Converts window-relative fold bounds into top-video and bottom-controls + * heights. [foldGuardPx] keeps touch targets away from flexible creases whose + * reported bounds can have zero height. + */ +internal fun calculateTabletopPlayerPaneLayout( + rootTopPx: Int, + rootBottomPx: Int, + foldTopPx: Int, + foldBottomPx: Int, + foldGuardPx: Int, +): TabletopPlayerPaneLayout? { + val rootHeightPx = rootBottomPx - rootTopPx + if (rootHeightPx <= 0 || foldGuardPx < 0) return null + + val relativeFoldTopPx = (foldTopPx - rootTopPx).coerceIn(0, rootHeightPx) + val relativeFoldBottomPx = (foldBottomPx - rootTopPx) + .coerceIn(relativeFoldTopPx, rootHeightPx) + val videoHeightPx = (relativeFoldTopPx - foldGuardPx).coerceAtLeast(0) + val controlsTopPx = (relativeFoldBottomPx + foldGuardPx).coerceAtMost(rootHeightPx) + val controlsHeightPx = rootHeightPx - controlsTopPx + + return if (videoHeightPx > 0 && controlsHeightPx > 0) { + TabletopPlayerPaneLayout( + videoHeightPx = videoHeightPx, + controlsHeightPx = controlsHeightPx, + ) + } else { + null + } +} + +@Composable +internal fun rememberTabletopPlayerPosture(activity: Activity?): TabletopPlayerPosture? { + val lifecycleOwner = LocalLifecycleOwner.current + val posture by produceState( + initialValue = null, + activity, + lifecycleOwner, + ) { + val hostActivity = activity ?: return@produceState + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + WindowInfoTracker.getOrCreate(hostActivity) + .windowLayoutInfo(hostActivity) + .collect { layoutInfo -> + value = layoutInfo.displayFeatures + .filterIsInstance() + .firstOrNull { feature -> + isTabletopPlayerPosture( + state = feature.state, + orientation = feature.orientation, + isSeparating = feature.isSeparating, + ) + } + ?.let { feature -> + TabletopPlayerPosture(foldBounds = Rect(feature.bounds)) + } + } + } + } + return posture +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt index 191f7f6b2..b76764208 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/IntroAutoSkipBanner.kt @@ -5,51 +5,101 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Replay import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.domain.player.IntroAutoSkipController import org.prairieserver.prairie.domain.player.IntroAutoSkipState /** - * Banner that surfaces the intro auto-skip flow. + * The phone's intro-skip pill — the same state machine and copy the TV renders, + * with pointer rules instead of focus rules. * - * Three states it crossfades between: * - [IntroAutoSkipState.Hidden]: takes no space (caller can leave the slot composed). - * - [IntroAutoSkipState.ShowingButton]: a manual "Skip Intro" pill. - * - [IntroAutoSkipState.CountingDown]: a countdown ring + "Skipping intro" + Cancel. + * - [IntroAutoSkipState.Asking]: "Skip Intro"; tap seeks past the intro. + * - [IntroAutoSkipState.Skipped]: a small "Intro skipped" caption over a + * "Watch Intro" button; tap plays it after all. * - * The component itself never positions itself; the parent should anchor it - * (typically bottom-end of the player overlay). + * A fill creeps left-to-right behind the label and lands full exactly as the + * timer ends. Tap is Select; a tap outside the pill is not Back. Back itself is + * handled by the player overlay, not here. + * + * The component never positions itself; the parent should anchor it (typically + * bottom-end of the player overlay). */ @Composable fun IntroAutoSkipBanner( state: IntroAutoSkipState, - onSkipNow: () -> Unit, - onCancelCountdown: () -> Unit, + onSelect: () -> Unit, modifier: Modifier = Modifier, - totalSeconds: Int = 5, + totalSeconds: Int = IntroAutoSkipController.DEFAULT_COUNTDOWN_SECONDS, + /** Bumped by the controller when the timer (re)starts; re-anchors the fill. */ + countdownRun: Int = 0, + /** False while the timer is frozen by a pause — the fill holds where it is. */ + timerRunning: Boolean = true, ) { + // The fill shows time remaining, so it runs off the frame clock rather than + // an AnimationSpec, which the system animator duration scale would stretch. + val fill = remember { mutableFloatStateOf(0f) } + val secondsRemaining = state.secondsRemainingOrNull + // Deliberately not keyed on `secondsRemaining`: a tick must not restart the + // sweep. `countdownRun` is what says the clock moved. + LaunchedEffect(countdownRun, timerRunning, totalSeconds, secondsRemaining == null) { + if (secondsRemaining == null || totalSeconds <= 0) { + fill.floatValue = 0f + return@LaunchedEffect + } + val remaining = secondsRemaining.coerceAtLeast(1) + val from = (1f - remaining.toFloat() / totalSeconds.toFloat()).coerceIn(0f, 1f) + fill.floatValue = from + if (!timerRunning) return@LaunchedEffect + val durationMs = remaining * 1000f + val startedAt = withFrameMillis { it } + var progressed = 0f + while (progressed < 1f) { + val frameMs = withFrameMillis { it } + progressed = ((frameMs - startedAt) / durationMs).coerceIn(0f, 1f) + fill.floatValue = from + (1f - from) * progressed + } + } + + // Keyed on the state kind so per-second ticks recompose the slot rather than + // recreating the subtree, which would restart the fill. + val slot = when (state) { + IntroAutoSkipState.Hidden -> 0 + is IntroAutoSkipState.Asking -> 1 + is IntroAutoSkipState.Skipped -> 2 + } AnimatedContent( - targetState = state, + targetState = slot, transitionSpec = { fadeIn(animationSpec = tween(durationMillis = 180)) togetherWith fadeOut(animationSpec = tween(durationMillis = 180)) @@ -58,94 +108,85 @@ fun IntroAutoSkipBanner( modifier = modifier, ) { current -> when (current) { - IntroAutoSkipState.Hidden -> { + 0 -> { // Render nothing but stay in the layout slot so AnimatedContent can fade in/out. Spacer(Modifier.size(0.dp)) } - IntroAutoSkipState.ShowingButton -> { - // iOS: Label("Skip Intro", systemImage: "forward.end.fill"), - // size 16 semibold, black-on-white capsule, padding 18/12. - SkipNowPill(label = "Skip Intro", onClick = onSkipNow) - } - is IntroAutoSkipState.CountingDown -> { - // iOS introSkipButton during countdown: a "Skipping intro in N" - // capsule label stacked above a [Cancel] + [Skip Now] row, - // trailing-aligned with spacing 8 / 10. - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Surface( - color = Color.Black.copy(alpha = 0.65f), - shape = RoundedCornerShape(percent = 50), - ) { - Text( - text = "Skipping intro in ${current.secondsRemaining.coerceAtLeast(0)}", - color = Color.White, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - // Cancel: white text on black 0.65 capsule with a - // white 0.28 hairline border, padding 16/11. - Surface( - onClick = onCancelCountdown, - color = Color.Black.copy(alpha = 0.65f), - shape = RoundedCornerShape(percent = 50), - modifier = Modifier.border( - width = 1.dp, - color = Color.White.copy(alpha = 0.28f), - shape = RoundedCornerShape(percent = 50), - ), - ) { - Text( - text = "Cancel", - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 11.dp), - ) - } - - SkipNowPill(label = "Skip Now", onClick = onSkipNow) - } - } - } + 1 -> IntroPromptPill( + label = stringResource(R.string.intro_skip_pill_skip), + icon = Icons.Filled.SkipNext, + progress = fill.floatValue, + onClick = onSelect, + ) + else -> IntroPromptPill( + label = stringResource(R.string.intro_skip_pill_undo), + caption = stringResource(R.string.intro_skip_pill_undo_caption), + icon = Icons.Filled.Replay, + progress = fill.floatValue, + onClick = onSelect, + ) } } } /** - * iOS "Skip Intro" / "Skip Now" pill: a `forward.end.fill` leading icon plus a - * label, size 16 semibold, black-on-white capsule, padding 18/12. + * The capsule both copies share: black scrim, white label, and a fill that + * tracks the timer behind it. A plain button — hover and tap are Select. An + * optional [caption] sits above the capsule, outside the tap target, so the + * confirmation never reads as part of the action. */ @Composable -private fun SkipNowPill(label: String, onClick: () -> Unit) { - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - ), - shape = RoundedCornerShape(percent = 50), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 18.dp, - vertical = 12.dp, - ), - ) { - Icon( - imageVector = Icons.Filled.SkipNext, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.size(6.dp)) - Text( - text = label, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - ) +private fun IntroPromptPill( + label: String, + icon: ImageVector, + progress: Float, + onClick: () -> Unit, + caption: String? = null, +) { + val shape = RoundedCornerShape(percent = 50) + Column(horizontalAlignment = Alignment.End) { + if (caption != null) { + Text( + text = caption, + color = Color.White.copy(alpha = 0.75f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(end = 10.dp, bottom = 4.dp), + ) + } + Box( + modifier = Modifier + .clip(shape) + .background(Color.Black.copy(alpha = 0.65f), shape) + .clickable(onClick = onClick), + ) { + // matchParentSize so the fill takes the pill's bounds, not the screen's. + Box(Modifier.matchParentSize()) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .fillMaxHeight() + .background(Color.White.copy(alpha = 0.22f)), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = label, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxFillProbe.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxFillProbe.kt new file mode 100644 index 000000000..7710fa35c --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxFillProbe.kt @@ -0,0 +1,189 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.SurfaceView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** Sampled frame size. 144 rows over a 2160-row frame resolve the matte edge to + * about one row, which [MATTE_MARGIN_FLOOR] holds back; 64 columns keep a small + * bright object near the picture edge from averaging away into apparent black. */ +private const val SAMPLE_WIDTH = 64 +private const val SAMPLE_HEIGHT = MATTE_SAMPLE_ROWS + +/** + * The evidence bar is fixed at [MATTE_SAMPLES_TO_SETTLE] frames, so the only + * thing between playback start and a settled picture is how fast those frames + * are collected — the interval, not the count. Gathering them back to back gets + * the same proof inside a few hundred milliseconds, which reads as "it started + * expanded" rather than "it grew". + * + * A read-back is a small composer blit into a 64x144 bitmap, off the render + * thread and asynchronous, so a short burst of them does not contend with frame + * production. The burst is bounded by [FAST_SAMPLE_BUDGET] anyway: a film + * opening on a fade yields no usable frames, and must not spin at this rate for + * the whole of a slow title sequence. + */ +private const val FAST_INTERVAL_MS = 100L +private const val FAST_SAMPLE_BUDGET = 20 +private const val SETTLING_INTERVAL_MS = 400L +private const val SETTLED_INTERVAL_MS = 750L + +/** Read-backs that may fail before the expansion is surrendered. */ +private const val MAX_CONSECUTIVE_COPY_FAILURES = 4 + +/** + * Watches the decoded frame and reports the aspect of the content rect to fit — + * see [LetterboxMatte.kt] for the rule and why fitting it can never cut picture. + * Returns [videoAspect] itself whenever there is nothing to discount, which + * renders as an ordinary fit. + * + * Frames come from `PixelCopy` against the video SurfaceView, which reads that + * surface's own buffer. Nothing about the playback pipeline changes: no + * TextureView, no GL effects chain, no second decoder, no extra network — so + * tunneled decoding, HDR10 and Dolby Vision passthrough are untouched, which + * they would not be if the frames were routed through a readable path instead. + * + * [cacheKey] names the exact file for [LetterboxMatteCache]. A remembered matte + * applies during composition, before the first frame is presented, so a replay + * or a resume opens at its final size; live frames then take over completely. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun rememberLetterboxContentAspect( + playerView: PlayerView?, + enabled: Boolean, + videoAspect: Float, + mediaKey: Any?, + cacheKey: String?, +): Float { + val context = LocalContext.current + val cache = remember(context) { LetterboxMatteCache(context.applicationContext) } + + // Resolved in the same composition that first lays the surface out, so the + // very first presented frame is already at its final size. Keyed on the + // media so a new item never inherits the previous one's answer. + var contentAspect by remember(cacheKey, enabled, videoAspect) { + val remembered = if (enabled && videoAspect > 0f && cacheKey != null) { + cache.read(cacheKey) + } else { + null + } + mutableFloatStateOf( + if (remembered != null) { + contentAspect(videoAspect, safeMatteFraction(remembered)) + } else { + videoAspect + }, + ) + } + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(playerView, enabled, videoAspect, mediaKey, cacheKey, lifecycleOwner) { + if (!enabled || playerView == null || videoAspect <= 0f) { + contentAspect = videoAspect + return@LaunchedEffect + } + + val estimator = LetterboxFillEstimator() + cacheKey?.let { key -> cache.read(key)?.let(estimator::seed) } + contentAspect = estimator.contentAspectFor(videoAspect) + + var persistedMatte: Float? = null + // Never recycled, deliberately. `PixelCopy` has no cancellation path, so + // a request still outstanding when this effect is disposed may yet write + // into the destination — recycling it out from under the platform turns + // a harmless abandoned read-back into a native write to freed memory. + // 36KB waiting for the collector is the cheaper side of that trade. + val bitmap = Bitmap.createBitmap(SAMPLE_WIDTH, SAMPLE_HEIGHT, Bitmap.Config.ARGB_8888) + val pixels = IntArray(SAMPLE_WIDTH * SAMPLE_HEIGHT) + // RESUMED, not STARTED: a backgrounded or picture-in-picture player + // has no reason to be reading frames back. + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + var failures = 0 + var samplesTaken = 0 + while (isActive) { + val surfaceView = playerView.videoSurfaceView as? SurfaceView + if (surfaceView != null && surfaceView.holder.surface?.isValid == true) { + if (copySurface(surfaceView, bitmap)) { + failures = 0 + samplesTaken++ + bitmap.getPixels( + pixels, 0, SAMPLE_WIDTH, 0, 0, SAMPLE_WIDTH, SAMPLE_HEIGHT, + ) + contentAspect = estimator.onSample( + sample = measureMatte(pixels, SAMPLE_WIDTH, SAMPLE_HEIGHT), + codedAspect = videoAspect, + ) + // Record the running minimum as it settles, not just + // at teardown: playback usually ends with the process + // being killed, which never reaches a finally block. + // Only a settled estimate is worth remembering — an + // unsettled one is a single frame's guess, and the next + // play would apply it from ITS first frame, bypassing + // the very settling that held it back here. + val measured = estimator.observedMatte?.takeIf { estimator.isSettled } + if (cacheKey != null && measured != null && measured != persistedMatte) { + persistedMatte = measured + cache.write(cacheKey, measured) + } + } else if (++failures >= MAX_CONSECUTIVE_COPY_FAILURES) { + // A surface that will not read back is not evidence + // for a crop, whatever it showed or remembered before. + // A secure or otherwise unreadable surface refuses for + // good, so give up rather than asking again every + // interval for the rest of playback; `repeatOnLifecycle` + // runs this block afresh on the next resume, which is + // recovery enough for a surface merely being torn down. + estimator.reset() + contentAspect = videoAspect + break + } + } + delay( + when { + estimator.isSettled -> SETTLED_INTERVAL_MS + samplesTaken < FAST_SAMPLE_BUDGET -> FAST_INTERVAL_MS + else -> SETTLING_INTERVAL_MS + }, + ) + } + } + } + + return contentAspect +} + +/** One read-back of [surfaceView]'s buffer into [bitmap]; false on any refusal. */ +private suspend fun copySurface(surfaceView: SurfaceView, bitmap: Bitmap): Boolean = + suspendCancellableCoroutine { continuation -> + val requested = runCatching { + PixelCopy.request( + surfaceView, + bitmap, + { result -> + if (continuation.isActive) continuation.resume(result == PixelCopy.SUCCESS) + }, + Handler(Looper.getMainLooper()), + ) + } + // The surface can be torn down between the validity check and here. + if (requested.isFailure && continuation.isActive) continuation.resume(false) + } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatte.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatte.kt new file mode 100644 index 000000000..a8a8a5b2f --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatte.kt @@ -0,0 +1,295 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.math.roundToInt + +/** + * Geometry for expanding video whose black bars are baked into the picture. + * + * Scope films are almost always distributed inside a 16:9 coded frame — a + * 2.39:1 image with the matte encoded as real pixels (Blu-ray and UHD only + * allow 16:9 frame sizes, so every scope title from disc is hard-matted). + * Nothing in the container, the bitstream, or the server's ffprobe metadata + * describes that inner image: `display_aspect_ratio` reports the coded 16:9, so + * the only way to know where the picture actually starts is to look at pixels. + * + * Once the matte is measured the rule is simply **fit the content rect**: scale + * the coded frame so the picture inside it exactly fills whichever axis of the + * available box binds first, and let the residual black fall wherever the + * aspects genuinely differ. One rule covers every case: + * + * - content wider than the box (2.39 into 2.17) — width binds, a real + * letterbox remains top and bottom; + * - content narrower than the box (1.90 into 2.17) — height binds, a real + * pillarbox remains left and right; + * - content with no matte at all — the content rect *is* the coded frame, the + * scale is unchanged from a plain fit, and nothing moves. + * + * **Why this can never cut picture.** Let the coded frame be `Wc x Hc` holding + * content `Wc x Hn` with matte `M = (Hc - Hn) / 2` per edge, fitted into a box + * `Bw x Bh` at `s = min(Bw/Wc, Bh/Hn)`. Horizontally the frame is `Wc*s <= Bw` + * by the definition of that minimum, so nothing is ever clipped from the sides. + * Vertically the clip is `(Hc*s - Bh) / 2 = (Hn*s - Bh) / 2 + M*s`. When height + * binds, `Hn*s = Bh` and the clip is exactly `M*s`; when width binds, + * `Hn*s < Bh` and the clip is strictly less. So the clip is bounded by the + * scaled matte in every case — the crop lands in encoded black by construction, + * not by a threshold that has to be checked. + * + * [safeMatteFraction] still holds back a slice of the measured matte, because + * the measurement itself is approximate; that is the only guard the rule needs. + * + * Frames are sampled with `PixelCopy` off the video SurfaceView, which reads + * that surface's own buffer — the decoded frame — rather than the composited, + * clipped region on screen. Measurements are therefore always in coded-frame + * terms and do not shift when the crop this file decides on is applied. + */ + +/** Top and bottom black bars of one sampled frame, as fractions of its height. */ +internal data class MatteSample( + val topFraction: Float, + val bottomFraction: Float, +) + +/** Rows in a sampled frame. Sets the resolution of every measurement here. */ +internal const val MATTE_SAMPLE_ROWS = 144 + +/** A channel value at or below this counts as black. PQ and SDR both encode a + * true matte at ~0; the headroom absorbs codec ringing at the matte edge. */ +internal const val MATTE_BLACK_CHANNEL_MAX = 20 + +/** Beyond this much black the frame is a fade or a night scene, not evidence. */ +private const val MAX_CREDIBLE_BLACK_FRACTION = 0.6f + +/** + * Share of the measured matte left uncropped as confidence headroom. + * + * Proportional rather than a flat fraction of frame height, because the rule + * this guards is proportional. A flat 2% of coded height is a rounding error + * against a scope film's 12.9% matte but eats two thirds of the 3.3% matte on a + * 1.90:1 title — which would have declined to expand exactly the content that + * most wants it. + */ +internal const val MATTE_MARGIN_FRACTION = 0.15f + +/** + * Floor for that headroom, in fractions of coded height. A sampled row covers + * `Hc / 144` of the frame, so the matte edge can only be located to about one + * row; holding back one and a half of them keeps quantisation on the safe side + * of the picture even when the proportional share is smaller. + */ +internal const val MATTE_MARGIN_FLOOR = 1.5f / MATTE_SAMPLE_ROWS + +/** Consecutive usable frames required before any expansion is applied. */ +internal const val MATTE_SAMPLES_TO_SETTLE = 4 + +/** + * Measures the black bars in a sampled frame laid out as [width] x [height] + * ARGB pixels. + * + * A row counts as black only when its BRIGHTEST pixel is black, so a caption or + * a studio logo sitting in the bar keeps that row out of the matte. Returns + * null when the frame is too black to carry evidence — a fade must not read as + * a very wide matte, and null neither expands nor contracts. + */ +internal fun measureMatte( + pixels: IntArray, + width: Int, + height: Int, + channelMax: Int = MATTE_BLACK_CHANNEL_MAX, +): MatteSample? { + if (width <= 0 || height <= 0 || pixels.size < width * height) return null + + fun rowIsBlack(row: Int): Boolean { + val start = row * width + for (i in start until start + width) { + val pixel = pixels[i] + if (((pixel shr 16) and 0xFF) > channelMax) return false + if (((pixel shr 8) and 0xFF) > channelMax) return false + if ((pixel and 0xFF) > channelMax) return false + } + return true + } + + var top = 0 + while (top < height && rowIsBlack(top)) top++ + // A fully black frame exits that loop at `height`; stop before the second + // walks back over the same rows and counts them twice. + if (top >= height) return null + var bottom = 0 + while (bottom < height - top && rowIsBlack(height - 1 - bottom)) bottom++ + + if ((top + bottom).toFloat() / height > MAX_CREDIBLE_BLACK_FRACTION) return null + + return MatteSample( + topFraction = top.toFloat() / height, + bottomFraction = bottom.toFloat() / height, + ) +} + +/** The part of a measured matte that may be cropped, after headroom. */ +internal fun safeMatteFraction(measured: Float): Float { + if (measured <= 0f) return 0f + val margin = maxOf(measured * MATTE_MARGIN_FRACTION, MATTE_MARGIN_FLOOR) + return (measured - margin).coerceAtLeast(0f) +} + +/** + * Aspect of the content rect once [matteFraction] is discounted from each edge. + * Falls back to the coded aspect for a matte that is absent or not credible, so + * the caller renders an ordinary fit. + */ +internal fun contentAspect(codedAspect: Float, matteFraction: Float): Float { + if (codedAspect <= 0f || matteFraction <= 0f) return codedAspect + val contentHeight = 1f - 2f * matteFraction + if (contentHeight <= 0f) return codedAspect + return codedAspect / contentHeight +} + +/** + * Fraction of coded height clipped from each edge when a content rect of + * [contentAspect] is fitted into a box of [boxAspect]. Exists to make the + * safety proof in the file header executable — it must never exceed the matte + * that produced [contentAspect]. + */ +internal fun verticalClipFraction( + codedAspect: Float, + contentAspect: Float, + boxAspect: Float, +): Float { + if (codedAspect <= 0f || contentAspect <= 0f || boxAspect <= 0f) return 0f + // Coded height is one unit, so the coded frame is `codedAspect` wide and the + // content rect is `codedAspect / contentAspect` tall. + val contentHeight = codedAspect / contentAspect + if (contentHeight <= 0f) return 0f + // Box height is one unit too, so `s` is the scale that fits the content rect. + val s = minOf(boxAspect / codedAspect, 1f / contentHeight) + if (s <= 1f) return 0f + return (s - 1f) / (2f * s) +} + +/** On-screen size of the picture itself, with the encoded matte discounted. */ +internal data class ExpandedImageSize(val width: Int, val height: Int) + +/** + * Size the picture is drawn at when a content rect of [contentAspect] is fitted + * into a [boxWidth] x [boxHeight] box, with [trueContentAspect] the aspect the + * image really has (the fitted rect keeps a sliver of matte as headroom). + */ +internal fun expandedImageSize( + boxWidth: Int, + boxHeight: Int, + contentAspect: Float, + trueContentAspect: Float = contentAspect, +): ExpandedImageSize? { + if (boxWidth <= 0 || boxHeight <= 0) return null + if (contentAspect <= 0f || trueContentAspect <= 0f) return null + val boxAspect = boxWidth.toFloat() / boxHeight + // The fitted rect, then the true picture inside it at the same scale. + val fittedWidth = if (contentAspect >= boxAspect) { + boxWidth.toFloat() + } else { + boxHeight * contentAspect + } + val width = fittedWidth.roundToInt().coerceAtMost(boxWidth) + val height = (fittedWidth / trueContentAspect).roundToInt().coerceAtMost(boxHeight) + return ExpandedImageSize(width = width, height = height) +} + +/** + * Symmetric horizontal inset that keeps an expanded picture clear of a display + * cutout, given the cutout insets the platform reports for the CURRENT rotation. + * + * Applied symmetrically, which costs twice the cutout width, and that is a + * deliberate trade. A punch-hole sits on one edge only, so insetting just that + * edge would buy back the other half — on the reference device 2981px of image + * instead of 2842px, about 10% more area. It would also leave the picture flush + * against one bezel with a black stripe down the other, and that stripe reads as + * a rendering fault rather than a decision. Worse, the two landscape rotations + * put the cutout on opposite edges (ROTATION_90 left, ROTATION_270 right), so a + * single-edge inset makes the image jump sideways by the full inset when the + * phone is flipped end for end. A centred image costs a little width and stays + * put, which is the right default for something you sit and watch. + * + * Insets only horizontally: the player is landscape by default, where the + * cutout is on a side edge and the picture reaches the sides. In portrait the + * cutout is on the top edge and the video is nowhere near it, so the reported + * top inset is deliberately ignored rather than pushing the picture down. + */ +internal fun cutoutSafeHorizontalInset(cutoutLeftPx: Int, cutoutRightPx: Int): Int = + maxOf(cutoutLeftPx, cutoutRightPx, 0) + +/** + * Tracks the encoded matte across frames and reports the content rect to fit. + * + * The estimate is the **thinnest** matte any usable frame has shown, which is + * what makes this stable in both directions at once. A dark scene reads as more + * black and cannot widen the crop; a frame whose picture reaches the matte edge + * — an IMAX sequence opening up, an ad break, a burned-in subtitle — narrows it + * on the very next sample and it stays narrowed. A monotonically decreasing + * estimate cannot oscillate, so instant revert and the old latch-off fall out of + * the same property instead of needing separate thresholds. + * + * Nothing is applied until [MATTE_SAMPLES_TO_SETTLE] usable frames agree, so a + * single fluke frame cannot resize the picture. + */ +internal class LetterboxFillEstimator( + private val samplesToSettle: Int = MATTE_SAMPLES_TO_SETTLE, +) { + private var usableSamples = 0 + private var seededMatte: Float? = null + + /** + * Thinnest matte seen this session, or null before there has been a usable + * frame. Only live frames land here — never [seed] — so a remembered value + * is replaced by measurement rather than copied forward for ever. + */ + var observedMatte: Float? = null + private set + + /** True once live frames alone are enough to decide with. */ + val isSettled: Boolean + get() = usableSamples >= samplesToSettle + + /** Forgets all evidence — a new media mount. */ + fun reset() { + usableSamples = 0 + seededMatte = null + observedMatte = null + } + + /** + * Starts from a matte measured during an earlier play of this exact file, so + * a rewatch or a resume is already expanded on its first frame instead of + * visibly growing a moment later. + * + * This is remembered evidence rather than a guess, and it is trusted only + * until live frames replace it: once [MATTE_SAMPLES_TO_SETTLE] have arrived + * the seed is ignored entirely, so a stale entry corrects itself within a + * few hundred milliseconds instead of governing the whole session. + */ + fun seed(matteFraction: Float) { + if (matteFraction > 0f) seededMatte = matteFraction + } + + /** + * Feeds one frame and returns the content aspect to render at. + * + * A null [sample] is absence of evidence, not evidence of absence: it holds + * the current estimate, so a fade to black neither expands nor contracts. + */ + fun onSample(sample: MatteSample?, codedAspect: Float): Float { + if (sample != null) { + // The thinner edge governs: cropping is only safe to the extent + // BOTH edges are black. + val matte = minOf(sample.topFraction, sample.bottomFraction) + observedMatte = observedMatte?.let { minOf(it, matte) } ?: matte + usableSamples++ + } + return contentAspectFor(codedAspect) + } + + /** Content aspect implied by the evidence so far. */ + fun contentAspectFor(codedAspect: Float): Float { + val matte = if (isSettled) observedMatte else seededMatte + return contentAspect(codedAspect, safeMatteFraction(matte ?: 0f)) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteCache.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteCache.kt new file mode 100644 index 000000000..10afd4e76 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteCache.kt @@ -0,0 +1,105 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import android.content.Context + +/** Bumped when the key shape or stored form changes, retiring old entries. */ +private const val SCHEMA = "v2" + +/** Entries kept before the oldest quarter is dropped. */ +private const val MAX_ENTRIES = 400 +private const val EVICT_TO = 300 + +/** + * Remembers the encoded letterbox matte measured for a specific file, so the + * next play of it starts expanded instead of growing into place a moment in. + * + * This is a latency cache, not a source of truth. Every hit is re-measured by + * the live probe within the first second of playback and overwritten by what + * that sees, so a wrong entry corrects itself on the next play rather than + * persisting; and because the value it stores is a minimum (see + * [LetterboxFillEstimator.observedMatte]), the direction it can be wrong in is + * under-cropping. + * + * Keys name the exact bytes on screen, not the title: origin, content, media + * file and the coded frame size. A different cut, a different release, or the + * same file arriving transcoded at another resolution all key differently, so + * none of them can inherit a crop measured from another. + */ +/** + * Names the exact bytes on screen. Null when the media cannot be identified + * precisely enough to be worth remembering — no cache entry is far better than + * one a different file could match. + * + * [origin] is what makes the rest of the tuple unambiguous: content and media + * file ids are scoped to the server that issued them, so two servers can hand + * out the same pair for different videos. For streaming that is the server URL; + * a download has none, so the caller passes the local URI of the stored bytes, + * which names the file at least as precisely. Blank means no identity is + * available, and then nothing is remembered at all. + */ +internal fun letterboxMatteCacheKey( + origin: String?, + contentId: String?, + mediaFileId: Int?, + codedWidth: Int, + codedHeight: Int, +): String? { + if (origin.isNullOrBlank()) return null + if (contentId.isNullOrBlank() || mediaFileId == null) return null + if (codedWidth <= 0 || codedHeight <= 0) return null + return "$SCHEMA|$origin|$contentId|$mediaFileId|${codedWidth}x$codedHeight" +} + +class LetterboxMatteCache(context: Context) { + + private val prefs = + context.getSharedPreferences("letterbox_matte", Context.MODE_PRIVATE) + + /** The remembered matte for [key], as a fraction of coded height. */ + fun read(key: String): Float? { + val stored = prefs.getString(key, null) ?: return null + val matte = stored.substringBefore('|').toFloatOrNull() ?: return null + // A matte at or past half the frame is not a letterbox, so refuse it + // rather than seeding a crop from a corrupt or hand-edited entry. + return matte.takeIf { it > 0f && it < 0.5f } + } + + fun write(key: String, matteFraction: Float) { + if (matteFraction >= 0.5f) return + // Settled live frames that reach both edges are positive evidence that + // this file has no matte, so they retire the entry rather than being + // discarded: left in place, a stale positive value would seed the crop + // again on every later play until enough live samples arrived to undo it. + if (matteFraction <= 0f) { + prefs.edit().remove(key).apply() + return + } + evictIfFull() + prefs.edit() + .putString(key, "$matteFraction|${System.currentTimeMillis()}") + .apply() + } + + /** + * Drops the oldest entries once the file grows past [MAX_ENTRIES]. Age is + * the only thing worth ranking on here — every entry is equally cheap to + * re-measure, so evicting one costs a single second of sampling on the next + * play of that file and nothing else. + */ + private fun evictIfFull() { + val all = prefs.all + if (all.size < MAX_ENTRIES) return + val byAge = all.entries + .mapNotNull { entry -> + val stamp = (entry.value as? String) + ?.substringAfter('|', "") + ?.toLongOrNull() + ?: 0L + entry.key to stamp + } + .sortedBy { it.second } + val editor = prefs.edit() + byAge.take((all.size - EVICT_TO).coerceAtLeast(0)).forEach { editor.remove(it.first) } + editor.apply() + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestore.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestore.kt index e18771fe8..3aee43442 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestore.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestore.kt @@ -22,9 +22,10 @@ internal suspend fun prepareMobileFreshSubtitleRestore( sessionId: String, serverUrl: String, persistedPreference: String?, + authoritativeInventory: Boolean = false, loadDownloadedSubtitles: suspend (Int) -> ApiResult, ): MobileFreshSubtitleRestore { - val downloaded = if (mediaFileId == null) { + val downloaded = if (authoritativeInventory || mediaFileId == null) { emptyList() } else { try { @@ -38,12 +39,16 @@ internal suspend fun prepareMobileFreshSubtitleRestore( emptyList() } } - val subtitleTracks = mergeDownloadedSubtitles( - existing = mountedSubtitles, - downloaded = downloaded, - sessionId = sessionId, - serverUrl = serverUrl, - ) + val subtitleTracks = if (authoritativeInventory) { + mountedSubtitles + } else { + mergeDownloadedSubtitles( + existing = mountedSubtitles, + downloaded = downloaded, + sessionId = sessionId, + serverUrl = serverUrl, + ) + } val preference = persistedPreference?.trim()?.takeIf(String::isNotEmpty) val persistedIdentity = decodeSubtitleIdentityPreference(preference) val persistedOrdinal = persistedIdentity diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt new file mode 100644 index 000000000..3adb748c7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTarget.kt @@ -0,0 +1,248 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.resolveCatalogSubtitlePreferenceOrdinal + +/** + * The route-level choices that produced the current mobile player target. + * + * Explicitness is kept separately from the value because a null route argument + * means "keep using automatic selection", not "the resolved player has no + * file/track". This lets a bare repeated play link remain idempotent after its + * first automatic resolution while still detecting a later explicit in-player + * version or track choice. + */ +internal data class MobilePlayerRouteIntent( + val contentId: String, + val fileId: Int? = null, + val quality: String? = null, + val audioTrackIndex: Int? = null, + val subtitleTrackIndex: Int? = null, + val resumePositionSeconds: Double? = null, + val fileIsExplicit: Boolean = fileId != null, + val qualityIsExplicit: Boolean = quality != null, + val audioTrackIsExplicit: Boolean = audioTrackIndex != null, + val subtitleTrackIsExplicit: Boolean = subtitleTrackIndex != null, +) + +/** Route intent plus the values the player is currently loading or playing. */ +internal data class MobilePlayerRouteTarget( + val intent: MobilePlayerRouteIntent, + val contentId: String, + val fileId: Int?, + val quality: String?, + val audioTrackIndex: Int?, + val subtitleTrackIndex: Int?, + val resumePositionSeconds: Double?, +) + +/** + * Owns route provenance independently from the resolved player state. + * + * Track choices are staged until the subtitle/audio transaction reports the + * requested value as committed. Internal persisted/automatic restores never + * call the staging methods, so they cannot become explicit route choices. + */ +internal class MobilePlayerRouteIntentState { + private data class PendingAudioSelection( + val contentId: String, + val routeOrdinal: Int, + val serverIndex: Int, + ) + + private data class PendingSubtitleSelection( + val contentId: String, + val routeOrdinal: Int?, + val identity: SubtitleIdentity, + ) + + private data class VersionSelection( + val contentId: String, + val previous: MobilePlayerRouteIntent, + ) + + var current: MobilePlayerRouteIntent? = null + private set + + private var pendingAudioSelection: PendingAudioSelection? = null + private var pendingSubtitleSelection: PendingSubtitleSelection? = null + private var versionSelection: VersionSelection? = null + + fun beginLoad( + contentId: String, + fileId: Int?, + quality: String?, + audioTrackIndex: Int?, + subtitleTrackIndex: Int?, + resumePositionSeconds: Double?, + preserveCurrent: Boolean, + ) { + clearPendingTrackSelections() + if (preserveCurrent && current != null) return + current = MobilePlayerRouteIntent( + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + ) + versionSelection = null + } + + fun beginVersionSelection(contentId: String, fileId: Int) { + val previous = current + ?.takeIf { it.contentId == contentId } + ?: MobilePlayerRouteIntent(contentId = contentId) + versionSelection = VersionSelection(contentId = contentId, previous = previous) + current = previous.copy(fileId = fileId, fileIsExplicit = true) + clearPendingTrackSelections() + } + + fun qualityForLoad( + contentId: String, + normalizedRequestedQuality: String?, + preserveCurrent: Boolean, + ): String? { + if (!preserveCurrent) return normalizedRequestedQuality + return current + ?.takeIf { it.contentId == contentId && it.qualityIsExplicit } + ?.quality + } + + fun recoverVersionSelection(contentId: String) { + val selection = versionSelection?.takeIf { it.contentId == contentId } ?: return + current = selection.previous + clearPendingTrackSelections() + } + + fun beginAudioSelection(contentId: String, routeOrdinal: Int, serverIndex: Int) { + pendingAudioSelection = PendingAudioSelection(contentId, routeOrdinal, serverIndex) + } + + fun beginSubtitleSelection( + contentId: String, + routeOrdinal: Int?, + identity: SubtitleIdentity, + ) { + pendingSubtitleSelection = PendingSubtitleSelection(contentId, routeOrdinal, identity) + } + + fun applyCommittedTracks( + contentId: String, + committedAudioServerIndex: Int?, + committedSubtitleIdentity: SubtitleIdentity, + transactionFailed: Boolean, + transactionActive: Boolean, + ) { + if (transactionFailed) { + clearPendingTrackSelections() + return + } + + pendingAudioSelection + ?.takeIf { it.contentId == contentId } + ?.let { pending -> + if (pending.serverIndex == committedAudioServerIndex) { + current + ?.takeIf { it.contentId == contentId } + ?.let { intent -> + current = intent.copy( + audioTrackIndex = pending.routeOrdinal, + audioTrackIsExplicit = true, + ) + } + pendingAudioSelection = null + } else if (!transactionActive) { + pendingAudioSelection = null + } + } + + pendingSubtitleSelection + ?.takeIf { it.contentId == contentId } + ?.let { pending -> + if (pending.identity == committedSubtitleIdentity) { + current + ?.takeIf { it.contentId == contentId } + ?.let { intent -> + current = intent.copy( + subtitleTrackIndex = pending.routeOrdinal, + subtitleTrackIsExplicit = true, + ) + } + pendingSubtitleSelection = null + } else if (!transactionActive) { + pendingSubtitleSelection = null + } + } + } + + fun clear() { + current = null + versionSelection = null + clearPendingTrackSelections() + } + + private fun clearPendingTrackSelections() { + pendingAudioSelection = null + pendingSubtitleSelection = null + } +} + +/** + * Produces one atomic target snapshot for external-route redelivery. + * + * While a load is pending, its explicit route intent is the only authoritative + * target. Once mounted, content/file/track values come from live player state; + * the quality intent remains route-owned because it is a playback ceiling, not + * necessarily the selected source file's resolution. + */ +internal fun mobilePlayerRouteTarget( + intent: MobilePlayerRouteIntent?, + state: PlayerViewModel.PlayerUiState, +): MobilePlayerRouteTarget? { + intent ?: return null + if (state.contentId.isBlank() || state.contentId != intent.contentId || state.error != null) { + return null + } + + if (state.isLoading) { + return MobilePlayerRouteTarget( + intent = intent, + contentId = state.contentId, + fileId = intent.fileId, + quality = intent.quality, + audioTrackIndex = intent.audioTrackIndex, + subtitleTrackIndex = intent.subtitleTrackIndex, + resumePositionSeconds = intent.resumePositionSeconds, + ) + } + + if (state.streamUrl.isNullOrBlank()) return null + val version = state.versions.getOrNull(state.selectedVersionIndex) + val catalogSubtitleOrdinal = resolveCatalogSubtitlePreferenceOrdinal( + tracks = version?.subtitleTracks.orEmpty(), + preference = encodeSubtitleIdentityPreference(state.committedSubtitleIdentity), + ) + return MobilePlayerRouteTarget( + intent = intent, + contentId = state.contentId, + fileId = state.mediaFileId, + quality = intent.quality, + audioTrackIndex = state.selectedAudioIndex.takeIf { it in state.audioTracks.indices }, + subtitleTrackIndex = catalogSubtitleOrdinal, + resumePositionSeconds = intent.resumePositionSeconds, + ) +} + +internal fun catalogSubtitleRouteOrdinal( + state: PlayerViewModel.PlayerUiState, + identity: org.prairieserver.prairie.model.playback.SubtitleIdentity, +): Int? = resolveCatalogSubtitlePreferenceOrdinal( + tracks = state.versions + .getOrNull(state.selectedVersionIndex) + ?.subtitleTracks + .orEmpty(), + preference = encodeSubtitleIdentityPreference(identity), +) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt index 631f1e9cc..f015f0348 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelection.kt @@ -1,21 +1,17 @@ package org.prairieserver.prairie.android.ui.screens.player -import org.prairieserver.prairie.common.player.isBitmapSubtitleCodecOrMime -import org.prairieserver.prairie.common.player.downloadedSubtitleArtifactTrackId -import org.prairieserver.prairie.common.player.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity -import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity -import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily -import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily import org.prairieserver.prairie.playback.canonicalSubtitleLanguage - -private val hearingImpairedSubtitleTokenRegex = Regex( - pattern = """(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""", - option = RegexOption.IGNORE_CASE, -) +import org.prairieserver.prairie.playback.hasPositiveSubtitleDiscriminator +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.matchesSubtitleMediaIdentity +import org.prairieserver.prairie.playback.playbackSubtitleIdentity +import org.prairieserver.prairie.playback.resolveDownloadedSubtitlePreferenceOrdinal +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired +import org.prairieserver.prairie.playback.subtitleMediaIdentityOrNull internal sealed class MobileSubtitleAutoSelection { data object NoChange : MobileSubtitleAutoSelection() @@ -23,68 +19,8 @@ internal sealed class MobileSubtitleAutoSelection { data class Select(val ordinal: Int) : MobileSubtitleAutoSelection() } -internal fun mobileSubtitleIdentity(subtitle: PlayerSubtitleInfo): SubtitleIdentity { - val source = subtitle.source?.trim()?.lowercase() - val catalogSource = subtitle.catalogSource?.trim()?.lowercase() - val downloaded = subtitle.downloadId != null || - source == "downloaded" || - catalogSource == "downloaded" - val media = SubtitleMediaIdentity( - trackId = subtitle.downloadId?.let(::downloadedSubtitleArtifactTrackId) - ?: subtitle.mediaTrackId, - label = subtitle.catalogLabel ?: subtitle.label, - language = canonicalSubtitleLanguage(subtitle.language), - codecFamily = canonicalSubtitleCodecFamily( - subtitle.codec ?: subtitleCodecFromUrl(subtitle.url), - ), - forced = subtitle.forced, - hearingImpaired = subtitleLabelIndicatesHearingImpaired( - subtitle.catalogLabel ?: subtitle.label, - ).takeIf { it }, - ) - if (downloaded) { - val downloadId = subtitle.downloadId - return if (downloadId != null) { - SubtitleIdentity.Downloaded(downloadId, media) - } else { - SubtitleIdentity.LocalMedia3(media) - } - } - - val embedded = (source == "embedded" && subtitle.url.isBlank()) || - (source == null && catalogSource == "embedded" && subtitle.url.isBlank()) - if (embedded) { - // PGS stays client-mounted (the server sidecars it as `.sup`); VobSub - // and DVB have no sidecar route and always burn in. - return if ( - isBitmapSubtitleCodecOrMime(media.codecFamily) && - !isClientMountableBitmapCodecFamily(media.codecFamily) - ) { - SubtitleIdentity.ServerBurnIn(subtitle.index, media) - } else { - SubtitleIdentity.Embedded( - serverIndex = subtitle.index, - media = media, - ) - } - } - - val external = source == "external" || - catalogSource == "external" || - source == "server_artifact" || - subtitle.url.isNotBlank() - val mountableBitmapArtifact = subtitle.url.isNotBlank() && - isClientMountableBitmapCodecFamily(media.codecFamily) - return if ( - external && - isBitmapSubtitleCodecOrMime(media.codecFamily) && - !mountableBitmapArtifact - ) { - SubtitleIdentity.ServerBurnIn(subtitle.index, media) - } else { - SubtitleIdentity.ServerSidecar(subtitle.index, media) - } -} +internal fun mobileSubtitleIdentity(subtitle: PlayerSubtitleInfo): SubtitleIdentity = + playbackSubtitleIdentity(subtitle) internal fun resolveMobileSubtitleOrdinal( identity: SubtitleIdentity, @@ -92,13 +28,7 @@ internal fun resolveMobileSubtitleOrdinal( ): Int? { if (identity == SubtitleIdentity.Off) return -1 if (identity is SubtitleIdentity.Downloaded) { - return subtitles.indices - .filter { index -> - val row = subtitles[index] - row.downloadId == identity.downloadId && - mobileSubtitleIdentity(row) is SubtitleIdentity.Downloaded - } - .singleOrNull() + return resolveDownloadedSubtitlePreferenceOrdinal(identity, subtitles) } val exactMatches = subtitles.indices.filter { index -> @@ -121,85 +51,36 @@ internal fun resolveMobileSubtitleOrdinal( val rowIdentity = mobileSubtitleIdentity(row) identity.media.trackId != null && rowIdentity is SubtitleIdentity.Embedded && - rowIdentity.media.matchesMobileIdentity(identity.media) + rowIdentity.media.matchesSubtitleMediaIdentity(identity.media) } is SubtitleIdentity.Downloaded -> { val rowIdentity = mobileSubtitleIdentity(row) row.downloadId == identity.downloadId && rowIdentity is SubtitleIdentity.Downloaded && - rowIdentity.media.matchesMobileIdentity(identity.media) + rowIdentity.media.matchesSubtitleMediaIdentity(identity.media) } is SubtitleIdentity.LocalMedia3 -> { val rowIdentity = mobileSubtitleIdentity(row) identity.media.trackId != null && rowIdentity is SubtitleIdentity.LocalMedia3 && - rowIdentity.media.matchesMobileIdentity(identity.media) + rowIdentity.media.matchesSubtitleMediaIdentity(identity.media) } } } if (exactMatches.size == 1) return exactMatches.single() if (exactMatches.size > 1) return null - val targetMedia = identity.mediaIdentityForMobileFallback() ?: return null - if (!targetMedia.hasPositiveMobileDiscriminator()) return null + val targetMedia = identity.subtitleMediaIdentityOrNull() ?: return null + if (!targetMedia.hasPositiveSubtitleDiscriminator()) return null val typedMatches = subtitles.indices.filter { index -> val rowIdentity = mobileSubtitleIdentity(subtitles[index]) - val rowMedia = rowIdentity.mediaIdentityForMobileFallback() ?: return@filter false - identity::class == rowIdentity::class && rowMedia.matchesMobileIdentity(targetMedia) + val rowMedia = rowIdentity.subtitleMediaIdentityOrNull() ?: return@filter false + identity::class == rowIdentity::class && + rowMedia.matchesSubtitleMediaIdentity(targetMedia) } return typedMatches.singleOrNull() } -private fun SubtitleIdentity.mediaIdentityForMobileFallback(): SubtitleMediaIdentity? = when (this) { - is SubtitleIdentity.ServerSidecar -> media - is SubtitleIdentity.ServerBurnIn -> media - is SubtitleIdentity.Embedded -> media - is SubtitleIdentity.LocalMedia3 -> media - SubtitleIdentity.Off, - is SubtitleIdentity.Downloaded, - -> null -} - -private fun SubtitleMediaIdentity.matchesMobileIdentity(expected: SubtitleMediaIdentity): Boolean { - val expectedTrackId = expected.trackId?.trim()?.takeIf(String::isNotBlank) - if (expectedTrackId != null && trackId?.trim() != expectedTrackId) return false - val expectedLabel = expected.label.normalizedMobileLabel() - if (expectedLabel != null && label.normalizedMobileLabel() != expectedLabel) return false - val expectedLanguage = canonicalSubtitleLanguage(expected.language) - val expectedCodec = canonicalSubtitleCodecFamily(expected.codecFamily) - if ( - expectedLanguage != null && - canonicalSubtitleLanguage(language) != expectedLanguage - ) { - return false - } - if ( - expectedCodec != null && - canonicalSubtitleCodecFamily(codecFamily) != expectedCodec - ) { - return false - } - if (expected.forced != null && forced != expected.forced) return false - if ( - expected.hearingImpaired != null && - hearingImpaired != expected.hearingImpaired - ) { - return false - } - return true -} - -private fun SubtitleMediaIdentity.hasPositiveMobileDiscriminator(): Boolean = - !trackId.isNullOrBlank() || - !label.isNullOrBlank() || - canonicalSubtitleLanguage(language) != null || - !codecFamily.isNullOrBlank() || - forced == true || - hearingImpaired == true - -private fun String?.normalizedMobileLabel(): String? = - this?.trim()?.takeIf(String::isNotBlank)?.lowercase() - internal fun resolveMobileAutoSubtitleSelection( audioTracks: List, selectedAudioIndex: Int, @@ -229,9 +110,10 @@ internal fun resolveMobileAutoSubtitleSelection( return MobileSubtitleAutoSelection.NoChange } - val selectedAudioLanguage = audioTracks - .firstOrNull { it.index == selectedAudioIndex } - ?: audioTracks.getOrNull(selectedAudioIndex) + // An ORDINAL into audioTracks: audio carries no index on the wire, so the + // index search that used to come first matched nothing above row zero and + // only worked because of the ordinal fallback behind it. + val selectedAudioLanguage = audioTracks.getOrNull(selectedAudioIndex) val selectedAudioMatches = canonicalSubtitleLanguage(selectedAudioLanguage?.language) == targetLanguage if (mode == "auto" && selectedAudioMatches) { @@ -377,22 +259,12 @@ private fun bestForcedAutoSubtitleOrdinal( } private fun PlayerSubtitleInfo.isEffectivelyHearingImpaired(): Boolean = - label.indicatesHearingImpairedSubtitle() || - source.indicatesHearingImpairedSubtitle() || - url.indicatesHearingImpairedSubtitle() - -private fun String?.indicatesHearingImpairedSubtitle(): Boolean { - val value = this?.takeIf { it.isNotBlank() } ?: return false - val lower = value.lowercase() - return lower.contains("closed caption") || - lower.contains("hearing impaired") || - lower.contains("hearing-impaired") || - lower.contains("hearing") || - hearingImpairedSubtitleTokenRegex.containsMatchIn(value) -} + subtitleLabelIndicatesHearingImpaired(label) || + subtitleLabelIndicatesHearingImpaired(source) || + subtitleLabelIndicatesHearingImpaired(url) private fun PlayerSubtitleInfo.isBitmap(): Boolean = - isBitmapSubtitleCodecOrMime(codec ?: subtitleCodecFromUrl(url)) + isBitmapSubtitleCodecFamily(codec ?: subtitleCodecFromUrl(url)) private fun subtitleCodecFromUrl(url: String?): String? = url diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt index 5fd2eb439..5adaa1754 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt @@ -31,8 +31,10 @@ import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleTransitionEvent import org.prairieserver.prairie.model.playback.SubtitleTransitionState import org.prairieserver.prairie.model.playback.UpdateAudioPreference +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.model.playback.reduceSubtitleTransition +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.port.PlaybackWriteScope @@ -69,6 +71,8 @@ internal data class MobileStagedSubtitleCandidate( val subtitleMode: PlaybackSubtitleModeV3, val hasSidecar: Boolean, val subtitleTracks: List, + val effectiveMediaFileId: Int? = null, + val selectedSubtitleIdentity: SubtitleIdentity? = null, internal val managerHandle: StagedVideoReplan? = null, ) @@ -76,6 +80,7 @@ internal data class MobileSubtitleCommittedPlayback( val sessionId: String, val subtitleTracks: List, val ready: VideoSessionStartV3.Ready? = null, + val effectiveMediaFileId: Int? = null, ) internal interface MobileSubtitleStagedReplanPort { @@ -136,6 +141,7 @@ internal enum class MobileSubtitleAdoptionResult { internal class MobileSubtitlePlaybackAdoption internal constructor( val playback: MobileSubtitleCommittedPlayback, val committed: CommittedSubtitle, + val requestedSourcePositionSeconds: Double, private val currentOwner: () -> Boolean, private val currentPendingIdentity: () -> SubtitleIdentity?, ) { @@ -202,6 +208,7 @@ internal class MobileSubtitleTransactionAdapter( private var pendingLocalRestore: PendingLocalRestore? = null private var localMountGeneration = 0L private var localMountTimeout: Job? = null + private var lastSettledLocalMountMissSnapshotKey: String? = null private val queuedMutations = mutableListOf() private var commitInFlight = false private var resetDuringCommit = false @@ -364,6 +371,42 @@ internal class MobileSubtitleTransactionAdapter( mutate(UpdateAudioPreference(audioTrackIndex), explicit = true) } + /** + * Records audio that was switched on the player, with no server replan. + * + * The reducer's committed audio is what a later subtitle transaction stages + * and what teardown persists, so a locally-applied switch that only updated + * UI would be undone: the next subtitle replan would request the track the + * viewer just moved away from, and final persistence would overwrite the + * choice with the stale one. + * + * Deliberately NOT a mutation — there is nothing to stage, the player is + * already on the track. While a transaction is in flight the value is + * queued instead, so it cannot race that transaction's own commit. + */ + fun commitLocallyAppliedAudio(audioTrackIndex: Int) { + context = context?.copy(audioTrackIndex = audioTrackIndex) + if (commitInFlight) { + pendingLocalAudioCommit = audioTrackIndex + return + } + transition = transition.copy( + committed = transition.committed.copy(audioTrackIndex = audioTrackIndex), + ) + publish() + } + + /** Applied once an in-flight commit finishes; see [commitLocallyAppliedAudio]. */ + private var pendingLocalAudioCommit: Int? = null + + private fun drainPendingLocalAudioCommit() { + val queued = pendingLocalAudioCommit ?: return + pendingLocalAudioCommit = null + transition = transition.copy( + committed = transition.committed.copy(audioTrackIndex = queued), + ) + } + fun invalidate() { adoptionGeneration += 1 contentGeneration += 1 @@ -477,7 +520,7 @@ internal class MobileSubtitleTransactionAdapter( publish() persist(transition.committed, pendingSelection.context) } - } else if (settled && !snapshotKey.isNullOrBlank()) { + } else if (isStableLocalMountMiss(snapshotKey, settled)) { failLocalMount(pendingSelection.generation) } return @@ -490,11 +533,18 @@ internal class MobileSubtitleTransactionAdapter( failureMessage = null publish() persistence?.let { persist(it.committed, it.context) } - } else if (settled && !snapshotKey.isNullOrBlank()) { + } else if (isStableLocalMountMiss(snapshotKey, settled)) { failLocalMount(pendingRestore.generation) } } + private fun isStableLocalMountMiss(snapshotKey: String?, settled: Boolean): Boolean { + if (!settled || snapshotKey.isNullOrBlank()) return false + val stable = snapshotKey == lastSettledLocalMountMissSnapshotKey + lastSettledLocalMountMissSnapshotKey = snapshotKey + return stable + } + private fun mutate(event: SubtitleTransitionEvent, explicit: Boolean) { if (explicit) refreshGeneration += 1 subtitleIntentGeneration += 1 @@ -666,6 +716,7 @@ internal class MobileSubtitleTransactionAdapter( ) { val validationFailure = candidate.validationFailure( requested = requested, + requestedMediaFileId = request.mediaFileId, expectedSubtitleIndex = request.subtitleTrackIndex, ) if (validationFailure != null) { @@ -685,12 +736,18 @@ internal class MobileSubtitleTransactionAdapter( discardCandidateBestEffort(candidate) return } + val validatedState = candidate.authoritativeValidatedState( + requested = requested, + requestedMediaFileId = request.mediaFileId, + validated = validated.state, + ) commitInFlight = true val commitResult = try { stagedPort.commit(candidate) } catch (cancellation: CancellationException) { commitInFlight = false + drainPendingLocalAudioCommit() if (!currentCoroutineContext().isActive) throw cancellation ApiResult.NetworkError(cancellation) } catch (error: Exception) { @@ -708,6 +765,7 @@ internal class MobileSubtitleTransactionAdapter( val adoptionContext = context ?: run { abandonCommittedPlayback(committed.data) commitInFlight = false + drainPendingLocalAudioCommit() resetDuringCommit = false return } @@ -715,7 +773,8 @@ internal class MobileSubtitleTransactionAdapter( val ownerGeneration = adoptionGeneration val adoption = MobileSubtitlePlaybackAdoption( playback = playback, - committed = validated.state.committed, + committed = validatedState.committed, + requestedSourcePositionSeconds = adoptionContext.positionSeconds, currentOwner = { ownerGeneration == adoptionGeneration && !resetDuringCommit @@ -743,7 +802,7 @@ internal class MobileSubtitleTransactionAdapter( } when (adoptionOutcome) { AdoptionOutcome.Adopted -> finishSuccessfulAdoption( - validatedState = validated.state, + validatedState = validatedState, playback = playback, adoptionContext = adoptionContext, ) @@ -754,6 +813,7 @@ internal class MobileSubtitleTransactionAdapter( is AdoptionOutcome.Failed -> { abandonCommittedPlayback(playback) commitInFlight = false + drainPendingLocalAudioCommit() val message = "Subtitle playback adoption failed." finishFailedCommit(requested.generation, message) withContext(NonCancellable) { @@ -771,6 +831,7 @@ internal class MobileSubtitleTransactionAdapter( } is ApiResult.Error -> { commitInFlight = false + drainPendingLocalAudioCommit() finishFailedCommit( generation = requested.generation, message = committed.message, @@ -778,6 +839,7 @@ internal class MobileSubtitleTransactionAdapter( } is ApiResult.NetworkError -> { commitInFlight = false + drainPendingLocalAudioCommit() finishFailedCommit( generation = requested.generation, message = committed.exception.message ?: "Subtitle selection failed.", @@ -800,6 +862,11 @@ internal class MobileSubtitleTransactionAdapter( } ?: adoptionContext context = liveContext.copy( + mediaFileId = playback.effectiveMediaFileId ?: liveContext.mediaFileId, + versionId = playback.effectiveMediaFileId + ?.takeIf { it != liveContext.mediaFileId } + ?.let { "adapted:$it" } + ?: liveContext.versionId, sessionId = playback.sessionId, subtitleTracks = playback.subtitleTracks, audioTrackIndex = transition.committed.audioTrackIndex, @@ -808,6 +875,7 @@ internal class MobileSubtitleTransactionAdapter( refreshGeneration += 1 failureMessage = null commitInFlight = false + drainPendingLocalAudioCommit() resetDuringCommit = false if (queuedMutations.isEmpty()) { if (transition.committed.identity.requiresLocalMountConfirmation()) { @@ -829,6 +897,7 @@ internal class MobileSubtitleTransactionAdapter( private fun finishSupersededAdoption() { commitInFlight = false + drainPendingLocalAudioCommit() resetDuringCommit = false applyQueuedMutations() } @@ -995,6 +1064,7 @@ internal class MobileSubtitleTransactionAdapter( localMountGeneration += 1 pendingLocalSelection = null pendingLocalRestore = null + lastSettledLocalMountMissSnapshotKey = null localMountTimeout?.cancel() localMountTimeout = null } @@ -1113,10 +1183,14 @@ internal class PlaybackSessionManagerMobileSubtitleStagedReplanPort( id = handle.candidateSessionId, sessionId = handle.candidateSessionId, selectedAudioIndex = ready.plan.selectedTracks.audio?.index, - selectedSubtitleIndex = ready.plan.selectedTracks.subtitle?.index, + selectedSubtitleIndex = ready.plan.resolvedSelectedSubtitleIndex(), subtitleMode = ready.plan.subtitle.mode, hasSidecar = ready.plan.subtitle.artifact?.url?.isNotBlank() == true, subtitleTracks = ready.session.subtitleUrls.orEmpty(), + effectiveMediaFileId = ready.session.mediaFileId.takeIf { it > 0 } + ?: ready.plan.effectiveMediaFileId + ?: request.mediaFileId, + selectedSubtitleIdentity = ready.selectedMobileSubtitleIdentity(), managerHandle = handle, ), ) @@ -1139,6 +1213,8 @@ internal class PlaybackSessionManagerMobileSubtitleStagedReplanPort( sessionId = result.data.session.sessionId, subtitleTracks = result.data.session.subtitleUrls.orEmpty(), ready = result.data, + effectiveMediaFileId = result.data.session.mediaFileId.takeIf { it > 0 } + ?: result.data.plan.effectiveMediaFileId, ), ) is ApiResult.Error -> result @@ -1178,13 +1254,20 @@ private fun SubtitleIdentity.isClientOwnedSubtitle(): Boolean = private fun MobileStagedSubtitleCandidate.validationFailure( requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + requestedMediaFileId: Int, expectedSubtitleIndex: Int, ): String? { - if (requested.audioPreferenceSpecified && + val sameFile = effectiveMediaFileId == null || effectiveMediaFileId == requestedMediaFileId + if (sameFile && requested.audioPreferenceSpecified && selectedAudioIndex != requested.audioTrackIndex ) { return "The candidate did not select the requested audio track." } + if (!sameFile) { + val returnedIdentity = selectedSubtitleIdentity + ?: return "The adapted candidate omitted its selected subtitle identity." + return validationFailure(returnedIdentity) + } return when (requested.identity) { is SubtitleIdentity.Embedded, is SubtitleIdentity.Downloaded, @@ -1202,6 +1285,40 @@ private fun MobileStagedSubtitleCandidate.validationFailure( } } +private fun MobileStagedSubtitleCandidate.authoritativeValidatedState( + requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + requestedMediaFileId: Int, + validated: SubtitleTransitionState, +): SubtitleTransitionState { + val sameFile = effectiveMediaFileId == null || effectiveMediaFileId == requestedMediaFileId + val returnedIdentity = selectedSubtitleIdentity + val committedIdentity = if (requested.identity.isClientOwnedSubtitle()) { + validated.committed.identity + } else { + returnedIdentity ?: validated.committed.identity + } + return validated.copy( + committed = validated.committed.copy( + identity = committedIdentity, + audioTrackIndex = if (sameFile) { + validated.committed.audioTrackIndex + } else { + selectedAudioIndex ?: validated.committed.audioTrackIndex + }, + ), + ) +} + +private fun VideoSessionStartV3.Ready.selectedMobileSubtitleIdentity(): SubtitleIdentity? { + val selected = plan.selectedTracks.subtitle ?: return SubtitleIdentity.Off + return session.subtitleUrls.orEmpty() + .singleOrNull { row -> + row.serverTrackId == selected.id && + (selected.index == null || row.index == selected.index) + } + ?.let(::mobileSubtitleIdentity) +} + private fun MobileStagedSubtitleCandidate.validationFailure( identity: SubtitleIdentity, ): String? = when (identity) { @@ -1239,36 +1356,40 @@ private fun MobileStagedSubtitleCandidate.validationFailure( private fun MobileSubtitleCommittedPlayback.withRebasedDownloads( oldContext: MobileSubtitlePlaybackContext, ): MobileSubtitleCommittedPlayback { - val downloadedPredicate: (PlayerSubtitleInfo) -> Boolean = { - it.downloadId != null || it.source.equals("downloaded", ignoreCase = true) + val downloadedPredicate: (PlayerSubtitleInfo) -> Boolean = + PlayerSubtitleInfo::isLocalDownloadedSubtitle + val downloaded = if (effectiveMediaFileId == null || effectiveMediaFileId == oldContext.mediaFileId) { + oldContext.subtitleTracks + .filter(downloadedPredicate) + .map { track -> + track.copy(url = rebaseDownloadedSubtitleUrl(track.url, sessionId)) + } + } else { + emptyList() } - val downloaded = oldContext.subtitleTracks - .filter(downloadedPredicate) - .map { track -> - track.copy(url = rebaseDownloadedSubtitleUrl(track.url, sessionId)) - } - val candidateByIndex = subtitleTracks + val oldByIndex = oldContext.subtitleTracks .filterNot(downloadedPredicate) .associateBy(PlayerSubtitleInfo::index) - val retainedCatalog = oldContext.subtitleTracks + // The committed V3 inventory owns membership. Old rows may enrich an exact + // server ordinal, but an omitted old catalog row must stay omitted. Local + // device downloads are outside the server inventory and remain available. + val authoritative = subtitleTracks .filterNot(downloadedPredicate) - .map { old -> - candidateByIndex[old.index]?.let { candidate -> - candidate.copy( - language = candidate.language ?: old.language, - codec = candidate.codec ?: old.codec, - label = candidate.label ?: old.label, - forced = candidate.forced ?: old.forced, - catalogLabel = old.catalogLabel ?: candidate.catalogLabel, - catalogSource = old.catalogSource ?: candidate.catalogSource, - isDefault = old.isDefault ?: candidate.isDefault, - ) - } ?: old.copy(url = "") + .distinctBy(PlayerSubtitleInfo::index) + .map { candidate -> + val old = oldByIndex[candidate.index] ?: return@map candidate + candidate.copy( + language = candidate.language ?: old.language, + codec = candidate.codec ?: old.codec, + label = candidate.label ?: old.label, + forced = candidate.forced ?: old.forced, + catalogLabel = old.catalogLabel ?: candidate.catalogLabel, + catalogSource = old.catalogSource ?: candidate.catalogSource, + isDefault = old.isDefault ?: candidate.isDefault, + ) } - val retainedIndexes = retainedCatalog.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) - val additionalCandidates = subtitleTracks.filterNot(downloadedPredicate) - .filterNot { it.index in retainedIndexes } + val authoritativeIndexes = authoritative.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) return copy( - subtitleTracks = retainedCatalog + additionalCandidates + downloaded, + subtitleTracks = authoritative + downloaded.filterNot { it.index in authoritativeIndexes }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt index cb74aed8c..ff961480f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileVideoPlaybackStarter.kt @@ -17,16 +17,25 @@ import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot import org.prairieserver.prairie.android.BuildConfig import org.prairieserver.prairie.model.catalog.WatchDetail +import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes import org.prairieserver.prairie.model.playback.applyResumeRewind import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.orNullIfBlank +import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal +import org.prairieserver.prairie.playback.resolveCatalogSubtitlePreferenceOrdinal import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.port.LocalTrackSelection +import org.prairieserver.prairie.repository.port.UserItemStatePort import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first @@ -41,6 +50,8 @@ internal data class MobileVideoSessionAllocation( val subtitleTrackIndex: Int?, val qualityPreference: String?, val startPosition: Double?, + /** `playback.max_bitrate_kbps`; null is uncapped. */ + val maxBitrateKbps: Int? = null, ) internal fun interface MobileVideoSessionAllocator { @@ -51,6 +62,52 @@ internal fun interface MobileVideoSessionAdopter { suspend fun adopt(params: StartParams, session: PlaybackSessionResponse) } +internal data class MobileInitialTrackSelection( + val audioTrackIndex: Int?, + val subtitleTrackIndex: Int?, +) + +/** + * Resolves durable per-file choices before neutral-v3 allocates its first plan. + * + * Explicit request indexes are already playback-v3 indexes and must pass + * through unchanged, including internal recovery starts. Persisted subtitle + * choices are stable catalog identities, so resolve only those onto the + * server's combined external-then-embedded index space. This lets restore + * happen in the first plan without reinterpreting a recovery index twice. + * Local/downloaded subtitle identities deliberately resolve to null and stay + * on the Media3-only restore path after the server plan is mounted. + */ +internal fun resolveMobileInitialTrackSelection( + explicitAudioTrackIndex: Int?, + explicitSubtitleTrackIndex: Int?, + audioTracks: List, + subtitleTracks: List, + persisted: LocalTrackSelection?, +): MobileInitialTrackSelection { + val audioTrackIndex = explicitAudioTrackIndex + ?: resolveAudioTrackOrdinal(audioTracks, persisted?.audioFingerprint) + val persistedSubtitleOrdinal = if (explicitSubtitleTrackIndex == null) { + resolveCatalogSubtitlePreferenceOrdinal( + subtitleTracks, + persisted?.subtitleFingerprint, + ) + } else { + null + } + val subtitleTrackIndex = when { + explicitSubtitleTrackIndex != null -> explicitSubtitleTrackIndex + persistedSubtitleOrdinal == null -> null + persistedSubtitleOrdinal == -1 -> -1 + else -> combinedSubtitleSelectionIndexes(subtitleTracks) + .getOrNull(persistedSubtitleOrdinal) + } + return MobileInitialTrackSelection( + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + ) +} + internal class MobileVideoPlaybackStarter( private val catalogRepository: CatalogRepository, private val playbackSessionManager: PlaybackSessionManager, @@ -59,6 +116,7 @@ internal class MobileVideoPlaybackStarter( private val playerSettingsStore: PlayerSettingsStore, private val sessionLifecycle: PlaybackSessionLifecycle, private val reachabilityMonitor: ServerReachabilityMonitor, + private val userItemStatePort: UserItemStatePort? = null, private val sessionAllocator: MobileVideoSessionAllocator? = null, private val sessionAdopter: MobileVideoSessionAdopter? = null, ) : VideoPlaybackStarter { @@ -96,10 +154,30 @@ internal class MobileVideoPlaybackStarter( ) } + // The playback-focused /watch response currently omits artwork. + // Detail screens cache the full catalog item before playback in the + // normal flow. Keep this fallback cache-only so optional artwork can + // never add a network request to, or prevent, playback startup. + val cachedDetail = runCatching { + catalogRepository.getCachedItemDetail(request.contentId) + }.onFailure { error -> + Log.w(TAG, "Could not read cached playback artwork", error) + }.getOrNull() + val artworkUrl = watchDetail.backdropUrl?.takeIf { it.isNotBlank() } + ?: watchDetail.posterUrl?.takeIf { it.isNotBlank() } + ?: cachedDetail?.backdropUrl?.takeIf { it.isNotBlank() } + ?: cachedDetail?.posterUrl?.takeIf { it.isNotBlank() } + val serverUrl = playbackSessionManager.getServerUrl() val preferredQuality = request.preferredQualityOverride ?: playerSettingsStore.preferredQualityFlow.first() val playbackQualityIntent = request.playbackQualityIntent ?: preferredQuality + // The bandwidth half of the quality choice. Quality is two axes and + // the server applies the cap only from what the request carries — + // nothing on the playback path reads the stored setting — so + // sending the resolution alone lets a capped preset ("1080p Low") + // stream at the bandwidth the user explicitly declined. + val maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first() val preferredAudioLanguage = playerSettingsStore.audioLanguageFlow .first().ifBlank { null } val version = request.preferredFileId @@ -109,6 +187,21 @@ internal class MobileVideoPlaybackStarter( watchDetail.userData?.lastFileId, preferredQuality, ) + val persistedTrackSelection = if ( + userItemStatePort != null && + (request.audioTrackIndex == null || request.subtitleTrackIndex == null) + ) { + userItemStatePort.localTrackSelection(request.contentId, version.fileId) + } else { + null + } + val initialTracks = resolveMobileInitialTrackSelection( + explicitAudioTrackIndex = request.audioTrackIndex, + explicitSubtitleTrackIndex = request.subtitleTrackIndex, + audioTracks = version.audioTracks.orEmpty(), + subtitleTracks = version.subtitleTracks.orEmpty(), + persisted = persistedTrackSelection, + ) val activeProfile = profileRepository.getActiveProfile() val profileId = activeProfile?.id ?: profileRepository.getActiveProfileId() @@ -124,12 +217,15 @@ internal class MobileVideoPlaybackStarter( diagnosticsCode = PlaybackDiagnosticsCode.NOT_AUTHENTICATED, ) val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() - val capabilities = capabilityDetector.detect(dolbyVision = dolbyVision) - val playbackContext = capabilityDetector.detectPlaybackContext( - formFactor = "mobile", - appVersion = BuildConfig.VERSION_NAME, - dolbyVision = dolbyVision, - ) + val capabilities = request.recoveryStartParams?.capabilities + ?: capabilityDetector.detect(dolbyVision = dolbyVision) + val playbackContext = request.recoveryStartParams?.clientPlaybackContext + ?: capabilityDetector.detectPlaybackContext( + formFactor = "mobile", + appVersion = BuildConfig.VERSION_NAME, + dolbyVision = dolbyVision, + capabilities = capabilities, + ) // Skip-back-on-resume: nudge a genuine resume back a few seconds. // Suppressed for Start Over / retry (request flag) and Watch Together // (roomId — all participants must land on the synced anchor). The same @@ -160,20 +256,22 @@ internal class MobileVideoPlaybackStarter( profileId = profileId, capabilities = capabilities, clientPlaybackContext = playbackContext, - audioTrackIndex = request.audioTrackIndex, - subtitleTrackIndex = request.subtitleTrackIndex, + audioTrackIndex = initialTracks.audioTrackIndex, + subtitleTrackIndex = initialTracks.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + maxBitrateKbps = maxBitrateKbps, ), ) ?: playbackSessionManager.startVideoSessionV3( fileId = version.fileId, profileId = profileId, capabilities = capabilities, clientPlaybackContext = playbackContext, - audioTrackIndex = request.audioTrackIndex, - subtitleTrackIndex = request.subtitleTrackIndex, + audioTrackIndex = initialTracks.audioTrackIndex, + subtitleTrackIndex = initialTracks.subtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + maxBitrateKbps = maxBitrateKbps, ) ) { is ApiResult.Success -> r.data @@ -229,12 +327,13 @@ internal class MobileVideoPlaybackStarter( val startParams = StartParams( contentId = request.contentId, fileId = effectiveFileId, - capabilities = capabilities, - audioTrackIndex = request.audioTrackIndex ?: resolved.audioTrackIndex, - subtitleTrackIndex = request.subtitleTrackIndex, + capabilities = readyV3.capabilities, + audioTrackIndex = initialTracks.audioTrackIndex ?: resolved.audioTrackIndex, + subtitleTrackIndex = initialTracks.subtitleTrackIndex + ?: readyV3.plan.resolvedSelectedSubtitleIndex(), qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, - clientPlaybackContext = playbackContext, + clientPlaybackContext = readyV3.clientPlaybackContext, ) val adopted = if (sessionAdopter != null) { sessionAdopter.adopt(startParams, resolved) @@ -244,7 +343,6 @@ internal class MobileVideoPlaybackStarter( sessionLifecycle.adoptActiveSessionIfCurrent( params = startParams, session = resolved, - renewMissingSessionWithLegacyStart = false, expectedOwnershipEpoch = ownershipEpoch, ) } catch (cancellation: CancellationException) { @@ -279,23 +377,52 @@ internal class MobileVideoPlaybackStarter( container = readyV3.plan.stream.container ?: effectiveVersion?.container, title = watchDetail.title, subtitle = buildSubtitle(watchDetail).takeIf { it.isNotBlank() }, - artworkUrl = watchDetail.posterUrl?.takeIf { it.isNotBlank() } - ?: watchDetail.backdropUrl?.takeIf { it.isNotBlank() }, + // Android's system media controls give artwork a wide canvas. + // Prefer the title backdrop there; portrait posters remain the + // fallback for catalog entries that do not have one. + artworkUrl = artworkUrl, startPositionSeconds = playerStartPos, sourceStartPositionSeconds = sourceStartPos, serverUrl = serverUrl, accessToken = accessToken, mediaFileId = effectiveFileId, audioTrackIndex = resolved.audioTrackIndex, - durationSeconds = resolved.durationSeconds ?: effectiveVersion?.duration ?: 0.0, - subtitleUrls = buildPlaybackSubtitleChoices( + // Protocol v3 source duration is authoritative. Unknown stays + // unknown; catalog/player runtimes must not fill this field. + durationSeconds = resolved.durationSeconds, + subtitleUrls = enrichAuthoritativePlaybackSubtitleChoices( catalogTracks = effectiveVersion?.subtitleTracks.orEmpty(), plannedTracks = resolved.subtitleUrls.orEmpty(), ), preferredAudioLanguage = preferredAudioLanguage ?: activeProfile?.language, - preferredTextLanguage = activeProfile?.subtitleLanguage, + // Server-resolved first, exactly as TvVideoPlaybackStarter does. + // The settings screens write these three canonically now + // (`PUT /settings/values/{key}?scope=profile`) and nothing + // mirrors a canonical write back into `user_profiles`, so the + // profile columns go stale the moment the user changes a + // subtitle preference. `effective_*` is what the server would + // resolve for this item; the columns stay only as the fallback + // for a server too old to send them. + // + // Blank is normalized to null on every rung, matching the audio + // language above. A canonical row holding JSON null (the + // contract's spelling of "no preference") unmarshals to "" on + // the server and arrives here as a present-but-empty string, and + // `resolveMobileAutoSubtitleSelection` reads a non-null blank + // language as an explicit "subtitles off" — so passing it + // through would turn auto-selection off for a user who never + // chose a language. + preferredTextLanguage = watchDetail.effectiveSubtitleLanguage.orNullIfBlank() + ?: activeProfile?.subtitleLanguage.orNullIfBlank(), + preferredSubtitleMode = watchDetail.effectiveSubtitleMode.orNullIfBlank() + ?: activeProfile?.subtitleMode.orNullIfBlank(), + showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles + ?: activeProfile?.showForcedSubtitles + ?: true, intro = watchDetail.intro, credits = watchDetail.credits, + recap = watchDetail.recap, + preview = watchDetail.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seasonNumber = watchDetail.seasonNumber, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt index e9c69caf6..a1f8744d1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackRealtimeController.kt @@ -4,6 +4,7 @@ import org.prairieserver.prairie.network.PlaybackRealtimeClient import org.prairieserver.prairie.network.PlaybackRealtimeEvent import org.prairieserver.prairie.playback.PlaybackAction import org.prairieserver.prairie.playback.decodeMarkersUpdate +import org.prairieserver.prairie.playback.decodePlaybackSubtitleReady import org.prairieserver.prairie.playback.decidePlaybackAction import org.prairieserver.prairie.playback.isTransport import kotlinx.coroutines.CancellationException @@ -89,10 +90,10 @@ class PlaybackRealtimeController( private fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { - "subtitle_ready" -> viewModel.refreshSubtitles() + "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits) + viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) } // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt index fae3882d7..72860e55c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt @@ -8,12 +8,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -25,9 +23,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.launch import org.prairieserver.prairie.common.player.PlayerStatsSnapshot @OptIn(ExperimentalMaterial3Api::class) @@ -39,31 +37,29 @@ fun PlaybackStatsSheet( // Gear-submenu back affordance: dismisses this sheet and reopens the // parent settings sheet (wired in PlayerOverlay). onBack: (() -> Unit)? = null, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Box( modifier = Modifier .fillMaxWidth() // Cap below the top edge + keep content flings from // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) + .playerSheetContent(tabletopPaneHeight) .nestedScroll(PlayerSheetFlingGuard) .background( brush = Brush.verticalGradient( @@ -83,11 +79,9 @@ fun PlaybackStatsSheet( PlayerSheetHeader( title = "Playback Stats", onBack = onBack?.let { back -> - { - scope.launch { sheetState.hide() } - back() - } + { scope.dismissPlayerSheet(sheetState, back) } }, + onDismiss = dismissSheet, ) Spacer(modifier = Modifier.height(6.dp)) Text( @@ -150,7 +144,10 @@ internal fun PlayerStatsSnapshot.mobileStatsRows(): List> = videoDecoderName?.let { add("Video decoder" to it) } audioCodec?.let { add("Audio codec" to it) } audioDecoderName?.let { add("Audio decoder" to it) } - bitrateBps?.let { add("Bitrate" to formatStatsBitrate(it)) } + // Media3's onBandwidthEstimate value — measured network throughput, not the + // media bitrate. Labelling it "Bitrate" reads as a ~19 Mbps stream claiming + // 151 Mbps on a fast LAN. + bitrateBps?.let { add("Estimated bandwidth" to formatStatsBitrate(it)) } if (droppedFrames > 0) add("Dropped frames" to droppedFrames.toString()) if (audioUnderruns > 0) add("Audio underruns" to audioUnderruns.toString()) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt index b9f5ceebf..78fb89cb3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerControls.kt @@ -1,6 +1,13 @@ package org.prairieserver.prairie.android.ui.screens.player +import android.content.Context +import android.database.ContentObserver +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import android.provider.Settings import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -8,15 +15,24 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Brightness6 import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.HighQuality import androidx.compose.material.icons.filled.MoreVert @@ -25,21 +41,31 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.ScreenLockRotation import androidx.compose.material.icons.filled.ScreenRotation +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.automirrored.filled.SpeakerNotes import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -50,7 +76,11 @@ import org.prairieserver.prairie.android.ui.layout.useCompactPlayerToolbar * mirrors iOS phone's `MobilePlayerControls` (lock | chapters | tracks | * settings) — see `iosApp/Screens/Player/iOS/MobilePlayerControls.swift:73`. * - * Three-row layout: + * Fullscreen uses the existing three-row HUD; tabletop groups the same shared + * controls into the lower pane, keeps brightness available at every size, and + * adds volume, speed, and next-episode shortcuts when the pane is large enough. + * + * Core rows: * - Top: Back (chevron) · title · orientation lock toggle · chapters (when * present) · tracks (audio + subs) · quality (when multiple versions) · * settings (gear) @@ -73,7 +103,15 @@ fun PlayerControls( hasMultipleVersions: Boolean, chapters: List = emptyList(), intro: org.prairieserver.prairie.model.catalog.TimeRange? = null, + credits: org.prairieserver.prairie.model.catalog.TimeRange? = null, + recap: org.prairieserver.prairie.model.catalog.TimeRange? = null, + preview: org.prairieserver.prairie.model.catalog.TimeRange? = null, isOrientationLocked: Boolean, + orientationLockSupported: Boolean = true, + tabletopMode: Boolean = false, + playbackSpeed: Double = 1.0, + nextEpisode: PlayerViewModel.NextEpisodeInfo? = null, + brightnessFraction: Float = 0.5f, // Watch Together guest gate: when false the scrubber + skip buttons are // inert and dimmed (seek is host-only, so disabled for all guests). // Defaults true for solo playback. @@ -91,6 +129,9 @@ fun PlayerControls( onOpenTracks: () -> Unit, onOpenQuality: () -> Unit, onOpenSettings: () -> Unit, + onSetPlaybackSpeed: (Double) -> Unit = {}, + onPlayNextEpisode: () -> Unit = {}, + onSetBrightness: (Float) -> Unit = {}, // Google Cast (Chromecast) button — sits in the top bar alongside the other // controls. Provided by PlayerScreen; empty by default so this stateless // composable stays test-friendly and decoupled from the Cast SDK. @@ -105,131 +146,66 @@ fun PlayerControls( .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f)), ) { - Column( - modifier = Modifier + val contentModifier = if (tabletopMode) { + Modifier .fillMaxSize() - // Keep every HUD control clear of the display cutout and any - // transient system bars (QA: portrait cutouts cropped the - // top-right buttons); the 16dp is extra padding on top of the - // safe insets, mirroring iOS's safe-area + 16pt edge padding. - .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(16.dp), - ) { - // Top bar — iOS HStack(spacing: 16): back · spacer · title · spacer · - // lock · chapters · tracks · settings. Title is centered between the - // two spacers, single-line, `.subheadline`, no subtitle. - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val trailingActionCount = 4 + - (if (hasChapters) 1 else 0) + - (if (hasMultipleVersions) 1 else 0) - val compact = useCompactPlayerToolbar( - availableWidthDp = maxWidth.value, - trailingActionCount = trailingActionCount, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - ControlButton( - icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = "Back", - onClick = onBack, - ) - - PlayerToolbarTitle( - title = title, - modifier = Modifier.weight(1f), - ) - - if (compact) { - // Keep Cast directly reachable; the SDK route button - // cannot be represented as a regular menu callback. - castSlot() - PlayerToolbarOverflow( - isOrientationLocked = isOrientationLocked, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, - ) - } else { - PlayerToolbarActions( - isOrientationLocked = isOrientationLocked, - hasChapters = hasChapters, - hasTracks = hasTracks, - hasMultipleVersions = hasMultipleVersions, - onToggleOrientationLock = onToggleOrientationLock, - onOpenChapters = onOpenChapters, - onOpenTracks = onOpenTracks, - onOpenQuality = onOpenQuality, - onOpenSettings = onOpenSettings, - castSlot = castSlot, - ) - } - } + // The controls pane begins halfway down the window, so a status + // bar inset here would create a fake gap below the hinge. Only + // reserve the real bottom navigation/gesture inset. + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 24.dp, vertical = 12.dp) + } else { + // In landscape the safe-drawing insets are lopsided (camera cutout + // on one edge, nothing on the other), so padding by them directly + // pushes the toolbar and progress bar off the device's centre line. + // Apply the larger horizontal inset to BOTH sides: the controls stay + // clear of the camera and remain centred on the display. + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val safeDrawing = WindowInsets.safeDrawing + val horizontalInset = with(density) { + maxOf( + safeDrawing.getLeft(this, layoutDirection), + safeDrawing.getRight(this, layoutDirection), + ).toDp() } + Modifier + .fillMaxSize() + .windowInsetsPadding(safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(horizontal = horizontalInset) + .padding(16.dp) + } - Spacer(modifier = Modifier.weight(1f)) - - // Center controls — iOS HStack(spacing: 48): skip back (32) · - // play/pause (48, no background) · skip forward (32). While - // buffering, iOS swaps the play glyph for a spinner. - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onSkipBackward, - enabled = seekEnabled, - ) { - Icon( - imageVector = Icons.Default.Replay10, - contentDescription = "Skip back 10 seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), - ) - } - - IconButton( - onClick = onPlayPause, - enabled = playPauseEnabled, - ) { - Icon( - imageVector = if (isPaused || !isPlaying) { - Icons.Default.PlayArrow - } else { - Icons.Default.Pause - }, - contentDescription = if (isPaused || !isPlaying) "Play" else "Pause", - tint = if (playPauseEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(48.dp), - ) - } - - IconButton( - onClick = onSkipForward, - enabled = seekEnabled, - ) { - Icon( - imageVector = Icons.Default.Forward10, - contentDescription = "Skip forward 10 seconds", - tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), - modifier = Modifier.size(32.dp), - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // Bottom bar — iOS VStack(spacing: 8): progress slider then a time - // row. No gradient (the flat dim handles contrast). + val toolbar: @Composable () -> Unit = { + PlayerToolbar( + title = title, + subtitle = subtitle, + isOrientationLocked = isOrientationLocked, + orientationLockSupported = orientationLockSupported, + hasChapters = hasChapters, + hasTracks = hasTracks, + hasMultipleVersions = hasMultipleVersions, + onBack = onBack, + onToggleOrientationLock = onToggleOrientationLock, + onOpenChapters = onOpenChapters, + onOpenTracks = onOpenTracks, + onOpenQuality = onOpenQuality, + onOpenSettings = onOpenSettings, + castSlot = castSlot, + ) + } + val transportControls: @Composable () -> Unit = { + PlayerTransportControls( + isPlaying = isPlaying, + isPaused = isPaused, + seekEnabled = seekEnabled, + playPauseEnabled = playPauseEnabled, + onPlayPause = onPlayPause, + onSkipForward = onSkipForward, + onSkipBackward = onSkipBackward, + ) + } + val progressBar: @Composable () -> Unit = { PlayerProgressBar( position = position, duration = duration, @@ -238,6 +214,187 @@ fun PlayerControls( enabled = seekEnabled, chapters = chapters, intro = intro, + credits = credits, + recap = recap, + preview = preview, + ) + } + + if (tabletopMode) { + BoxWithConstraints(modifier = contentModifier) { + // Compact/asymmetric foldables may expose a shallower lower + // pane. Keep brightness reachable everywhere, and add the + // wider volume/speed/next controls when they fit comfortably. + val showFullUtilityRow = maxWidth >= 600.dp && maxHeight >= 300.dp + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + toolbar() + transportControls() + progressBar() + TabletopUtilityRow( + playbackSpeed = playbackSpeed, + nextEpisode = nextEpisode, + compact = !showFullUtilityRow, + brightnessFraction = brightnessFraction, + onSetPlaybackSpeed = onSetPlaybackSpeed, + onPlayNextEpisode = onPlayNextEpisode, + onSetBrightness = onSetBrightness, + ) + } + } + } else { + // The toolbar and progress bar respect the safe-drawing insets, but + // those insets are asymmetric in landscape (cutout on one side, + // navigation bar on the other), so a transport row inside the same + // padded column lands visibly off-center. Anchor the transport + // cluster to the true center of the overlay instead. + Column(modifier = contentModifier) { + toolbar() + Spacer(modifier = Modifier.weight(1f)) + progressBar() + } + Box( + modifier = Modifier.align(Alignment.Center), + contentAlignment = Alignment.Center, + ) { + transportControls() + } + } + } +} + +@Composable +private fun PlayerToolbar( + title: String, + subtitle: String, + isOrientationLocked: Boolean, + orientationLockSupported: Boolean, + hasChapters: Boolean, + hasTracks: Boolean, + hasMultipleVersions: Boolean, + onBack: () -> Unit, + onToggleOrientationLock: () -> Unit, + onOpenChapters: () -> Unit, + onOpenTracks: () -> Unit, + onOpenQuality: () -> Unit, + onOpenSettings: () -> Unit, + castSlot: @Composable () -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val trailingActionCount = 3 + + (if (orientationLockSupported) 1 else 0) + + (if (hasChapters) 1 else 0) + + (if (hasMultipleVersions) 1 else 0) + val compact = useCompactPlayerToolbar( + availableWidthDp = maxWidth.value, + trailingActionCount = trailingActionCount, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ControlButton( + icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Back", + onClick = onBack, + ) + PlayerToolbarTitle( + title = title, + subtitle = subtitle, + modifier = Modifier.weight(1f), + ) + if (compact) { + castSlot() + PlayerToolbarOverflow( + isOrientationLocked = isOrientationLocked, + orientationLockSupported = orientationLockSupported, + hasChapters = hasChapters, + hasTracks = hasTracks, + hasMultipleVersions = hasMultipleVersions, + onToggleOrientationLock = onToggleOrientationLock, + onOpenChapters = onOpenChapters, + onOpenTracks = onOpenTracks, + onOpenQuality = onOpenQuality, + onOpenSettings = onOpenSettings, + ) + } else { + PlayerToolbarActions( + isOrientationLocked = isOrientationLocked, + orientationLockSupported = orientationLockSupported, + hasChapters = hasChapters, + hasTracks = hasTracks, + hasMultipleVersions = hasMultipleVersions, + onToggleOrientationLock = onToggleOrientationLock, + onOpenChapters = onOpenChapters, + onOpenTracks = onOpenTracks, + onOpenQuality = onOpenQuality, + onOpenSettings = onOpenSettings, + castSlot = castSlot, + ) + } + } + } +} + +@Composable +private fun PlayerTransportControls( + isPlaying: Boolean, + isPaused: Boolean, + seekEnabled: Boolean, + playPauseEnabled: Boolean, + onPlayPause: () -> Unit, + onSkipForward: () -> Unit, + onSkipBackward: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(48.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onSkipBackward, + enabled = seekEnabled, + modifier = Modifier.size(52.dp), + ) { + Icon( + imageVector = Icons.Default.Replay10, + contentDescription = "Skip back 10 seconds", + tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), + modifier = Modifier.size(32.dp), + ) + } + IconButton( + onClick = onPlayPause, + enabled = playPauseEnabled, + modifier = Modifier + .size(64.dp) + .background(Color.White.copy(alpha = 0.14f), CircleShape), + ) { + Icon( + imageVector = if (isPaused || !isPlaying) { + Icons.Default.PlayArrow + } else { + Icons.Default.Pause + }, + contentDescription = if (isPaused || !isPlaying) "Play" else "Pause", + tint = if (playPauseEnabled) Color.White else Color.White.copy(alpha = 0.3f), + modifier = Modifier.size(38.dp), + ) + } + IconButton( + onClick = onSkipForward, + enabled = seekEnabled, + modifier = Modifier.size(52.dp), + ) { + Icon( + imageVector = Icons.Default.Forward10, + contentDescription = "Skip forward 10 seconds", + tint = if (seekEnabled) Color.White else Color.White.copy(alpha = 0.3f), + modifier = Modifier.size(32.dp), ) } } @@ -246,11 +403,12 @@ fun PlayerControls( @Composable private fun PlayerToolbarTitle( title: String, + subtitle: String, modifier: Modifier = Modifier, ) { - Box( + Column( modifier = modifier, - contentAlignment = Alignment.Center, + verticalArrangement = Arrangement.Center, ) { Text( text = title, @@ -259,12 +417,178 @@ private fun PlayerToolbarTitle( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.64f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } +@Composable +private fun TabletopUtilityRow( + playbackSpeed: Double, + nextEpisode: PlayerViewModel.NextEpisodeInfo?, + compact: Boolean, + brightnessFraction: Float, + onSetPlaybackSpeed: (Double) -> Unit, + onPlayNextEpisode: () -> Unit, + onSetBrightness: (Float) -> Unit, +) { + val context = LocalContext.current + + val brightnessControl: @Composable (Modifier) -> Unit = { modifier -> + TabletopSliderControl( + icon = Icons.Default.Brightness6, + contentDescription = "Player brightness", + value = brightnessFraction, + onValueChange = onSetBrightness, + modifier = modifier, + ) + } + + if (compact) { + brightnessControl(Modifier.fillMaxWidth()) + return + } + + val audioManager = remember { + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + val maxVolume = remember(audioManager) { + audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC).coerceAtLeast(1) + } + var volumeFraction by remember(audioManager, maxVolume) { + mutableFloatStateOf( + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume, + ) + } + val contentResolver = context.contentResolver + DisposableEffect(audioManager, maxVolume, contentResolver) { + val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + volumeFraction = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume + } + } + contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, observer) + onDispose { contentResolver.unregisterContentObserver(observer) } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TabletopSliderControl( + icon = Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = "Media volume", + value = volumeFraction, + onValueChange = { fraction -> + volumeFraction = fraction + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + (fraction * maxVolume).toInt().coerceIn(0, maxVolume), + 0, + ) + }, + modifier = Modifier.weight(1f), + ) + brightnessControl(Modifier.weight(1f)) + TabletopActionButton( + icon = Icons.Default.Speed, + label = playbackSpeedLabel(playbackSpeed), + onClick = { onSetPlaybackSpeed(nextTabletopPlaybackSpeed(playbackSpeed)) }, + ) + nextEpisode?.let { episode -> + TabletopActionButton( + icon = Icons.Default.SkipNext, + label = "Next S${episode.seasonNumber}·E${episode.episodeNumber}", + onClick = onPlayNextEpisode, + ) + } + } +} + +@Composable +private fun TabletopSliderControl( + icon: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String, + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(48.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White.copy(alpha = 0.82f), + modifier = Modifier.size(20.dp), + ) + Slider( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun TabletopActionButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .height(48.dp) + .widthIn(min = 112.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.10f)) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(19.dp), + ) + Text( + text = label, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } +} + +internal fun nextTabletopPlaybackSpeed(current: Double): Double = + listOf(1.0, 1.25, 1.5, 2.0).firstOrNull { it > current + 0.001 } ?: 1.0 + +internal fun playbackSpeedLabel(speed: Double): String { + return "${formatPlaybackSpeed(speed)}× Speed" +} + @Composable private fun PlayerToolbarActions( isOrientationLocked: Boolean, + orientationLockSupported: Boolean, hasChapters: Boolean, hasTracks: Boolean, hasMultipleVersions: Boolean, @@ -275,11 +599,17 @@ private fun PlayerToolbarActions( onOpenSettings: () -> Unit, castSlot: @Composable () -> Unit, ) { - ControlButton( - icon = if (isOrientationLocked) Icons.Default.ScreenLockRotation else Icons.Default.ScreenRotation, - contentDescription = if (isOrientationLocked) "Landscape Locked" else "Rotate Freely", - onClick = onToggleOrientationLock, - ) + if (orientationLockSupported) { + ControlButton( + icon = if (isOrientationLocked) { + Icons.Default.ScreenLockRotation + } else { + Icons.Default.ScreenRotation + }, + contentDescription = if (isOrientationLocked) "Landscape Locked" else "Rotate Freely", + onClick = onToggleOrientationLock, + ) + } if (hasChapters) { ControlButton( icon = Icons.AutoMirrored.Filled.List, @@ -311,6 +641,7 @@ private fun PlayerToolbarActions( @Composable private fun PlayerToolbarOverflow( isOrientationLocked: Boolean, + orientationLockSupported: Boolean, hasChapters: Boolean, hasTracks: Boolean, hasMultipleVersions: Boolean, @@ -332,15 +663,17 @@ private fun PlayerToolbarOverflow( expanded = expanded, onDismissRequest = { expanded = false }, ) { - DropdownMenuItem( - text = { - Text(if (isOrientationLocked) "Unlock orientation" else "Lock orientation") - }, - onClick = { - expanded = false - onToggleOrientationLock() - }, - ) + if (orientationLockSupported) { + DropdownMenuItem( + text = { + Text(if (isOrientationLocked) "Unlock orientation" else "Lock orientation") + }, + onClick = { + expanded = false + onToggleOrientationLock() + }, + ) + } if (hasChapters) { DropdownMenuItem( text = { Text("Chapters") }, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt index a4fda929f..c50528208 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt @@ -2,8 +2,6 @@ package org.prairieserver.prairie.android.ui.screens.player import android.content.Context import android.media.AudioManager -import android.view.Window -import android.view.WindowManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -56,7 +54,7 @@ import kotlin.math.hypot * - Hold: temporary 2x playback while held * - Two-finger pinch: step video gravity — pinch-out steps Fit -> Fill -> * Stretch, pinch-in steps back, clamped at both ends (iOS parity) - * - Vertical swipe in the left edge zone: brightness adjustment + * - Vertical swipe in the left edge zone: reserved; system brightness remains authoritative * - Vertical swipe in the right edge zone: volume adjustment * - Vertical swipe down in the center: dismiss the player (iOS * MobilePlayerGestureLayer parity — evaluated on release, mostly-vertical @@ -81,8 +79,8 @@ fun PlayerGestureHandler( onPinchVideoGravity: (Boolean) -> Unit = {}, onDismiss: () -> Unit = {}, // Swipe-down-to-dismiss is suppressed until playback is actually established. - // During the initial open the media is still loading, and a downward volume/ - // brightness swipe that drifts inward would otherwise be read as "close the + // During the initial open the media is still loading, and a downward edge + // swipe that drifts inward would otherwise be read as "close the // player" — which then strands the user (Jim, Fold). dismissEnabled: Boolean = true, modifier: Modifier = Modifier, @@ -204,20 +202,21 @@ fun PlayerGestureHandler( ) } .pointerInput(Unit) { - // iOS edgeAndDismissDrag: the start x picks the mode once — - // left 88dp edge = brightness, right 88dp edge = volume, and a - // center drag becomes a dismiss candidate judged on release. + // The start x picks the mode once: the left 88dp edge is + // reserved so Android brightness stays authoritative, the + // right edge controls volume, and the center is a dismiss + // candidate judged on release. var mode = VerticalDragMode.None var totalDrag = Offset.Zero val edgeZonePx = EdgeZoneWidthDp.dp.toPx() detectVerticalDragGestures( onDragStart = { start -> totalDrag = Offset.Zero - mode = when { - start.x < edgeZonePx -> VerticalDragMode.Brightness - start.x > size.width - edgeZonePx -> VerticalDragMode.Volume - else -> VerticalDragMode.DismissCandidate - } + mode = verticalDragMode( + startX = start.x, + width = size.width.toFloat(), + edgeZonePx = edgeZonePx, + ) }, onDragEnd = { if (currentDismissEnabled && mode == VerticalDragMode.DismissCandidate) { @@ -236,8 +235,6 @@ fun PlayerGestureHandler( totalDrag += change.position - change.previousPosition val sensitivity = 0.01f when (mode) { - VerticalDragMode.Brightness -> - adjustBrightness(context, -dragAmount * sensitivity) VerticalDragMode.Volume -> adjustVolume(audioManager, -dragAmount * sensitivity) else -> Unit @@ -305,31 +302,27 @@ private const val SkipFlashHoldMs = 700L private data class SkipFlash(val forward: Boolean, val nonce: Long) -private enum class VerticalDragMode { None, Brightness, Volume, DismissCandidate } +internal enum class VerticalDragMode { None, Volume, DismissCandidate } -/** iOS edge-zone width (88pt) for brightness/volume vertical drags. */ +/** Edge-zone width for the reserved left edge and right-edge volume drag. */ private const val EdgeZoneWidthDp = 88 +internal fun verticalDragMode( + startX: Float, + width: Float, + edgeZonePx: Float, +): VerticalDragMode = when { + startX < edgeZonePx -> VerticalDragMode.None + startX > width - edgeZonePx -> VerticalDragMode.Volume + else -> VerticalDragMode.DismissCandidate +} + /** iOS dismiss threshold: a mostly-vertical downward drag over 140pt. */ private const val DismissDragThresholdDp = 140 private fun pointerDistance(first: Offset, second: Offset): Float = hypot(first.x - second.x, first.y - second.y) -/** - * Adjusts the screen brightness. Values are clamped to [0.01, 1.0]. - * Uses the window's layout params for per-activity brightness control. - */ -private fun adjustBrightness(context: Context, delta: Float) { - val activity = context as? android.app.Activity ?: return - val window: Window = activity.window - val layoutParams = window.attributes - val currentBrightness = if (layoutParams.screenBrightness < 0) 0.5f else layoutParams.screenBrightness - val newBrightness = (currentBrightness + delta).coerceIn(0.01f, 1.0f) - layoutParams.screenBrightness = newBrightness - window.attributes = layoutParams -} - /** * Adjusts the media volume. Delta is normalized, so we scale to the max volume. */ diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt index d2353b17e..d50528773 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerNextUpScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -56,6 +58,7 @@ import org.prairieserver.prairie.common.ui.components.ThumbhashImage * Watching / Back actions, an auto-play countdown ring, the auto-play * toggle, and an On Deck carousel of other in-progress items. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun PlayerNextUpScreen( nextEpisode: PlayerViewModel.NextEpisodeInfo?, @@ -69,6 +72,7 @@ fun PlayerNextUpScreen( onToggleAutoPlay: () -> Unit, onPlayOnDeckItem: (String) -> Unit, onBack: () -> Unit, + compactTabletop: Boolean = false, modifier: Modifier = Modifier, ) { Box( @@ -86,25 +90,30 @@ fun PlayerNextUpScreen( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp, vertical = 24.dp), + .padding( + horizontal = 24.dp, + vertical = if (compactTabletop) 12.dp else 24.dp, + ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(if (compactTabletop) 12.dp else 20.dp), ) { // Mini-player frame: the live video shows through the lighter top // band of the scrim; this is just the bordered frame over it. - Box( - modifier = Modifier - .widthIn(max = 620.dp) - .fillMaxWidth() - .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = 0.10f)) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.16f), - shape = RoundedCornerShape(8.dp), - ), - ) + if (!compactTabletop) { + Box( + modifier = Modifier + .widthIn(max = 620.dp) + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Black.copy(alpha = 0.10f)) + .border( + width = 1.dp, + color = Color.White.copy(alpha = 0.16f), + shape = RoundedCornerShape(8.dp), + ), + ) + } // Next-episode panel. Column( @@ -150,55 +159,19 @@ fun PlayerNextUpScreen( ) } - // Action column — iOS uses vertical buttons, maxWidth 280. - Column( - modifier = Modifier.widthIn(max = 280.dp).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (nextEpisode != null) { - Button( - onClick = onPlayNow, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - contentColor = Color.Black, - ), - ) { - Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Play Now") - } - } - if (!videoEnded) { - OutlinedButton( - onClick = onKeepWatching, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Keep Watching", color = Color.White) - } - } - OutlinedButton( - onClick = onBack, - modifier = Modifier.fillMaxWidth(), + if (compactTabletop) { + FlowRow( + modifier = Modifier.widthIn(max = 620.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(18.dp), + NextUpActionButtons( + hasNextEpisode = nextEpisode != null, + videoEnded = videoEnded, + onPlayNow = onPlayNow, + onKeepWatching = onKeepWatching, + onBack = onBack, ) - Text("Back", color = Color.White) } if (countdownSeconds != null) { CountdownRing( @@ -206,6 +179,28 @@ fun PlayerNextUpScreen( totalSeconds = countdownTotalSeconds, ) } + } else { + // Fullscreen/iOS-parity action column. + Column( + modifier = Modifier.widthIn(max = 280.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + NextUpActionButtons( + hasNextEpisode = nextEpisode != null, + videoEnded = videoEnded, + onPlayNow = onPlayNow, + onKeepWatching = onKeepWatching, + onBack = onBack, + modifier = Modifier.fillMaxWidth(), + ) + if (countdownSeconds != null) { + CountdownRing( + seconds = countdownSeconds, + totalSeconds = countdownTotalSeconds, + ) + } + } } Text( @@ -220,7 +215,7 @@ fun PlayerNextUpScreen( } // On Deck carousel (iOS-only feature; TV doesn't have it). - if (onDeckItems.isNotEmpty()) { + if (!compactTabletop && onDeckItems.isNotEmpty()) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -245,6 +240,55 @@ fun PlayerNextUpScreen( } } +@Composable +private fun NextUpActionButtons( + hasNextEpisode: Boolean, + videoEnded: Boolean, + onPlayNow: () -> Unit, + onKeepWatching: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + if (hasNextEpisode) { + Button( + onClick = onPlayNow, + modifier = modifier, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black, + ), + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Play Now") + } + } + if (!videoEnded) { + OutlinedButton( + onClick = onKeepWatching, + modifier = modifier, + ) { + Text("Keep Watching", color = Color.White) + } + } + OutlinedButton( + onClick = onBack, + modifier = modifier, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Text("Back", color = Color.White) + } +} + @Composable private fun OnDeckCard( item: PlayerViewModel.OnDeckItem, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicy.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicy.kt new file mode 100644 index 000000000..08c1b78c8 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicy.kt @@ -0,0 +1,15 @@ +package org.prairieserver.prairie.android.ui.screens.player + +private const val ANDROID_16_API_LEVEL = 36 +private const val LARGE_SCREEN_SMALLEST_WIDTH_DP = 600 + +/** + * Android 16 ignores requested orientation on displays whose smallest width is + * at least 600dp for apps targeting API 36. Keep the lock available everywhere + * the platform can honor it, and let large screens remain adaptive. + */ +internal fun supportsPlayerOrientationLock( + sdkInt: Int, + smallestScreenWidthDp: Int, +): Boolean = + sdkInt < ANDROID_16_API_LEVEL || smallestScreenWidthDp < LARGE_SCREEN_SMALLEST_WIDTH_DP diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt index 39d5e44a7..d1d2b5fd0 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.player import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -37,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex @@ -62,6 +64,13 @@ fun PlayerOverlay( viewModel: PlayerViewModel, roomSnapshot: RoomSnapshot? = null, isFastForwardHoldActive: Boolean = false, + orientationLockSupported: Boolean = true, + alwaysShowControls: Boolean = false, + tabletopMode: Boolean = false, + tabletopPaneHeight: Dp? = null, + brightnessFraction: Float, + onSetBrightness: (Float) -> Unit, + showBufferingIndicator: Boolean = true, onBack: () -> Unit, onPlayPause: () -> Unit, onSeek: (Double) -> Unit, @@ -128,14 +137,23 @@ fun PlayerOverlay( // Orientation lock — toggled from the top-bar lock icon (iOS parity). // Persisted via the orientation-mode setting (default landscape-locked, // like iOS's PlayerOrientationCoordinator); PlayerScreen applies the - // matching requestedOrientation whenever the setting changes. + // matching requestedOrientation whenever the setting changes. Android 16 + // large screens keep the preference but disable this no-op affordance. val isOrientationLocked by viewModel.orientationLocked.collectAsState() val context = LocalContext.current val introSkipState by viewModel.introSkipState.collectAsState() + val introSkipCountdownRun by viewModel.introSkipCountdownRun.collectAsState() + val introSkipTimerRunning by viewModel.introSkipTimerRunning.collectAsState() + // Back while the pill is up dismisses it and is consumed; a second Back + // behaves normally, because by then no pill is showing and this handler is + // disabled. The player has no other BackHandler of its own — sheets live in + // their own dialog windows, so an open sheet's Back never reaches here. + BackHandler(enabled = introSkipState.isVisible) { viewModel.onDismissIntroPrompt() } val sleepTimerState by viewModel.sleepTimerState.collectAsState() val sleepTimerDefault by viewModel.sleepTimerDefaultMinutes.collectAsState() val videoGravity by viewModel.videoGravity.collectAsState() + val playbackSpeed by viewModel.playbackSpeed.collectAsState() val notice by viewModel.notice.collectAsState() val sessionState by viewModel.sessionState.collectAsState() val subtitleTools by viewModel.subtitleTools.collectAsState() @@ -174,7 +192,7 @@ fun PlayerOverlay( Box(modifier = modifier.fillMaxSize()) { // Gesture layer stays out of the tree while controls are visible so // full-screen pointer handlers cannot consume taps meant for buttons. - if (!state.showControls && !state.showUpNext) { + if (!alwaysShowControls && !state.showControls && !state.showUpNext) { PlayerGestureHandler( onToggleControls = onToggleControls, onSkipForward = gatedSkipForward, @@ -194,7 +212,9 @@ fun PlayerOverlay( // Buffering indicator. Shown during ExoPlayer buffering AND during outage // recovery — the lifecycle's Reconnecting state isn't visible to the player, // so we surface the spinner ourselves so the screen doesn't appear frozen. - if (state.isBuffering || sessionState is SessionState.Reconnecting) { + if (showBufferingIndicator && + (state.isBuffering || sessionState is SessionState.Reconnecting) + ) { CircularProgressIndicator( modifier = Modifier .size(56.dp) @@ -312,7 +332,7 @@ fun PlayerOverlay( // Transport controls (shown/hidden with animation) AnimatedVisibility( - visible = state.showControls && !state.showUpNext, + visible = (alwaysShowControls || state.showControls) && !state.showUpNext, enter = fadeIn(), exit = fadeOut(), modifier = Modifier @@ -329,10 +349,18 @@ fun PlayerOverlay( bufferedPosition = state.bufferedPosition, chapters = state.chapters, intro = state.intro, + credits = state.credits, + recap = state.recap, + preview = state.preview, hasChapters = state.chapters.isNotEmpty(), hasTracks = state.subtitleTracks.isNotEmpty() || state.audioTracks.isNotEmpty(), hasMultipleVersions = state.versions.size > 1, isOrientationLocked = isOrientationLocked, + orientationLockSupported = orientationLockSupported, + tabletopMode = tabletopMode, + playbackSpeed = playbackSpeed, + nextEpisode = state.nextEpisode.takeUnless { inRoom }, + brightnessFraction = brightnessFraction, seekEnabled = seekEnabled, playPauseEnabled = playPauseEnabled, onBack = handleBack, @@ -341,12 +369,17 @@ fun PlayerOverlay( onSkipForward = gatedSkipForward, onSkipBackward = gatedSkipBackward, onToggleOrientationLock = { - viewModel.onSetOrientationLocked(!isOrientationLocked) + if (orientationLockSupported) { + viewModel.onSetOrientationLocked(!isOrientationLocked) + } }, onOpenChapters = { chaptersSheetVisible = true }, onOpenTracks = { tracksSheetVisible = true }, onOpenQuality = { showQualitySelector = true }, onOpenSettings = { settingsSheetVisible = true }, + onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, + onPlayNextEpisode = viewModel::playUpNextNow, + onSetBrightness = onSetBrightness, castSlot = castSlot, ) } @@ -357,7 +390,7 @@ fun PlayerOverlay( .padding(bottom = 120.dp, end = 24.dp) .zIndex(2f) - // Intro auto-skip banner (Hidden / ShowingButton / CountingDown). + // Intro skip pill (Hidden / Asking / Skipped). // Shares the bottom-end slot with the Up Next card; intro and credits // never overlap in practice, but the card wins the slot if both could show. if (!state.showUpNext) { @@ -367,8 +400,10 @@ fun PlayerOverlay( ) { IntroAutoSkipBanner( state = introSkipState, - onSkipNow = viewModel::onSkipIntroNow, - onCancelCountdown = viewModel::onCancelIntroAutoSkip, + onSelect = viewModel::onSelectIntroPrompt, + totalSeconds = viewModel.introSkipTotalSeconds, + countdownRun = introSkipCountdownRun, + timerRunning = introSkipTimerRunning, ) } } @@ -416,13 +451,18 @@ fun PlayerOverlay( }, onPlayOnDeckItem = viewModel::playOnDeckItemNow, onBack = handleBack, + compactTabletop = tabletopMode, ) } // Sleep timer chip — top-right, fades in only while a timer is active. // The chip stays visible regardless of `state.showControls` so users // know a sleep timer is still running even when the controls have - // auto-hidden. + // auto-hidden. When the HUD is visible, move it below the 48dp toolbar + // controls (16dp edge padding + 48dp target + 8dp gap) so it cannot + // cover Cast or playback settings in landscape. + val controlsVisible = (alwaysShowControls || state.showControls) && !state.showUpNext + val sleepTimerTopPadding = if (controlsVisible) 72.dp else 16.dp AnimatedVisibility( visible = sleepTimerState is SleepTimerState.Active, enter = fadeIn(), @@ -430,7 +470,7 @@ fun PlayerOverlay( modifier = Modifier .align(Alignment.TopEnd) .windowInsetsPadding(WindowInsets.safeDrawing) - .padding(top = 16.dp, end = 16.dp) + .padding(top = sleepTimerTopPadding, end = 16.dp) .zIndex(2f), ) { val active = sleepTimerState as? SleepTimerState.Active @@ -490,6 +530,7 @@ fun PlayerOverlay( tracksSheetVisible = false aiTranslateVisible = true }, + tabletopPaneHeight = tabletopPaneHeight, ) if (subtitleSearchVisible) { @@ -507,6 +548,7 @@ fun PlayerOverlay( tracksSheetVisible = true viewModel.onSearchSheetClosed() }, + tabletopPaneHeight = tabletopPaneHeight, ) } @@ -528,6 +570,7 @@ fun PlayerOverlay( tracksSheetVisible = true viewModel.onTranslateSheetClosed() }, + tabletopPaneHeight = tabletopPaneHeight, ) } @@ -537,6 +580,7 @@ fun PlayerOverlay( selectedIndex = state.selectedVersionIndex, onSelect = onSelectVersion, onDismiss = { showQualitySelector = false }, + tabletopPaneHeight = tabletopPaneHeight, ) } @@ -544,12 +588,14 @@ fun PlayerOverlay( PlayerSettingsSheet( isVisible = settingsSheetVisible, onDismiss = { settingsSheetVisible = false }, - playbackSpeed = viewModel.playbackSpeed.collectAsState().value, + playbackSpeed = playbackSpeed, onSetPlaybackSpeed = viewModel::onSetPlaybackSpeed, videoGravity = videoGravity, onSetVideoGravity = viewModel::onSetVideoGravity, - autoSkipIntroEnabled = viewModel.autoSkipIntroEnabled.collectAsState().value, - onSetAutoSkipIntro = viewModel::onSetAutoSkipIntro, + letterboxExpansion = viewModel.letterboxExpansion.collectAsState().value, + onSetLetterboxExpansion = viewModel::onSetLetterboxExpansion, + introSkipMode = viewModel.introSkipMode.collectAsState().value, + onSetIntroSkipMode = viewModel::onSetIntroSkipMode, autoPlayNextEnabled = viewModel.autoPlayNextEnabled.collectAsState().value, onSetAutoPlayNext = viewModel::onSetAutoPlayNext, hdrEnabled = viewModel.hdrEnabled.collectAsState().value, @@ -575,6 +621,7 @@ fun PlayerOverlay( subtitleDelayMs = viewModel.subtitleDelayMs.collectAsState().value, onSetSubtitleDelay = viewModel::onSetSubtitleDelay, sleepTimerState = sleepTimerState, + tabletopPaneHeight = tabletopPaneHeight, ) PlaybackStatsSheet( @@ -585,6 +632,7 @@ fun PlayerOverlay( statsSheetVisible = false settingsSheetVisible = true }, + tabletopPaneHeight = tabletopPaneHeight, ) // Chapters picker — opened from the HUD chapters button (HUD product @@ -600,6 +648,7 @@ fun PlayerOverlay( viewModel.onSeekToChapter(idx)?.let { sec -> viewModel.onSeek(sec) } }, onDismiss = { chaptersSheetVisible = false }, + tabletopPaneHeight = tabletopPaneHeight, ) // Subtitle styling sheet — opened from the "Subtitle Style" row in @@ -614,6 +663,7 @@ fun PlayerOverlay( subtitleStyleVisible = false settingsSheetVisible = true }, + tabletopPaneHeight = tabletopPaneHeight, ) // Sleep timer picker — opened from the "Sleep Timer" row in @@ -629,6 +679,7 @@ fun PlayerOverlay( sleepTimerVisible = false settingsSheetVisible = true }, + tabletopPaneHeight = tabletopPaneHeight, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt index 8adc98d12..0969f7c6c 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape @@ -47,8 +48,8 @@ import org.prairieserver.prairie.model.catalog.VersionChapter * iOS `MobilePlayerControls.progressSlider`: * * - three track regions — played, buffered (safe to seek into), base; - * - the intro range tinted cyan (credits is deliberately NOT drawn — iOS - * only tints the intro); + * - detected marker ranges tinted as bands (intro cyan, recap green, + * credits orange, preview purple); * - a 2dp chapter tick per chapter, drawn under the played fill; * - while scrubbing, a preview bubble above the thumb with the target time * and the chapter title at that point (text only — iOS has no thumbnail @@ -65,16 +66,30 @@ fun PlayerProgressBar( enabled: Boolean = true, chapters: List = emptyList(), intro: TimeRange? = null, + credits: TimeRange? = null, + recap: TimeRange? = null, + preview: TimeRange? = null, ) { var isSeeking by remember { mutableStateOf(false) } var seekPosition by remember { mutableFloatStateOf(0f) } var barWidthPx by remember { mutableFloatStateOf(0f) } - val maxDuration = duration.toFloat().coerceAtLeast(1f) - val displayPosition = (if (isSeeking) seekPosition else position.toFloat()).coerceIn(0f, maxDuration) - val playedFraction = displayPosition / maxDuration - val bufferedFraction = (bufferedPosition.toFloat().coerceIn(0f, maxDuration) / maxDuration) - .coerceIn(playedFraction, 1f) + val hasKnownDuration = duration.isFinite() && duration > 0.0 + val maxDuration = if (hasKnownDuration) duration.toFloat() else 1f + val rawDisplayPosition = if (isSeeking) seekPosition else position.toFloat() + val displayPosition = if (hasKnownDuration) { + rawDisplayPosition.coerceIn(0f, maxDuration) + } else { + rawDisplayPosition.coerceAtLeast(0f) + } + val sliderPosition = if (hasKnownDuration) displayPosition else 0f + val playedFraction = if (hasKnownDuration) displayPosition / maxDuration else 0f + val bufferedFraction = if (hasKnownDuration) { + (bufferedPosition.toFloat().coerceIn(0f, maxDuration) / maxDuration) + .coerceIn(playedFraction, 1f) + } else { + 0f + } // iOS bottom bar is VStack(spacing: 8): progress slider, then the time row. Column( @@ -97,8 +112,8 @@ fun PlayerProgressBar( .offset(x = bubbleX - clampPad) .padding(bottom = 4.dp) .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = 0.6f)) - .padding(horizontal = 10.dp, vertical = 4.dp), + .background(Color.Black.copy(alpha = 0.82f)) + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text( text = formatClockTime(seekPosition.toDouble()), @@ -123,8 +138,8 @@ fun PlayerProgressBar( } Slider( - value = displayPosition, - enabled = enabled, + value = sliderPosition, + enabled = enabled && hasKnownDuration, onValueChange = { value -> isSeeking = true seekPosition = value @@ -145,7 +160,7 @@ fun PlayerProgressBar( thumb = { Box( modifier = Modifier - .size(if (isSeeking) 16.dp else 11.dp) + .size(if (isSeeking) 20.dp else 14.dp) .background(MaterialTheme.colorScheme.primary, CircleShape), ) }, @@ -153,9 +168,9 @@ fun PlayerProgressBar( Box( modifier = Modifier .fillMaxWidth() - .height(4.dp) + .height(6.dp) .clip(CircleShape) - .background(Color.White.copy(alpha = 0.24f)) + .background(Color.White.copy(alpha = 0.16f)) .onSizeChanged { barWidthPx = it.width.toFloat() }, ) { // Buffered-ahead: downloaded and safe to seek into. @@ -163,27 +178,37 @@ fun PlayerProgressBar( modifier = Modifier .fillMaxWidth(bufferedFraction) .fillMaxHeight() - .background(Color.White.copy(alpha = 0.45f)), + .background(Color.White.copy(alpha = 0.52f)), ) - // Intro tint — iOS draws the intro range cyan at 0.4. - intro?.let { range -> - val startFraction = (range.start / maxDuration).toFloat().coerceIn(0f, 1f) - val endFraction = (range.end / maxDuration).toFloat().coerceIn(startFraction, 1f) - if (endFraction > startFraction) { - val density = LocalDensity.current - val barWidthDp = with(density) { barWidthPx.toDp() } - Box( - modifier = Modifier - .offset(x = barWidthDp * startFraction) - .width(barWidthDp * (endFraction - startFraction)) - .fillMaxHeight() - .background(Color.Cyan.copy(alpha = 0.4f)), - ) + // Marker bands — tinted segments for each detected marker + // kind (intro/recap/credits/preview), drawn under the played + // fill so the playhead still reads clearly over them. + if (hasKnownDuration) { + val density = LocalDensity.current + val barWidthDp = with(density) { barWidthPx.toDp() } + val markers = listOfNotNull( + intro?.let { it to Color.Cyan }, + recap?.let { it to Color(0xFF8BC34A) }, + credits?.let { it to Color(0xFFFFB74D) }, + preview?.let { it to Color(0xFFBA68C8) }, + ) + markers.forEach { (range, color) -> + val startFraction = (range.start / maxDuration).toFloat().coerceIn(0f, 1f) + val endFraction = (range.end / maxDuration).toFloat().coerceIn(startFraction, 1f) + if (endFraction > startFraction) { + Box( + modifier = Modifier + .offset(x = barWidthDp * startFraction) + .width(barWidthDp * (endFraction - startFraction)) + .fillMaxHeight() + .background(color.copy(alpha = 0.4f)), + ) + } } } // Chapter ticks, under the played fill (iOS: the fill // covers ticks in played territory). - if (chapters.isNotEmpty()) { + if (hasKnownDuration && chapters.isNotEmpty()) { val density = LocalDensity.current val barWidthDp = with(density) { barWidthPx.toDp() } chapters.forEach { chapter -> @@ -194,7 +219,7 @@ fun PlayerProgressBar( .offset(x = barWidthDp * fraction - 1.dp) .width(2.dp) .fillMaxHeight() - .background(Color.White.copy(alpha = 0.6f)), + .background(Color.White.copy(alpha = 0.72f)), ) } } @@ -208,31 +233,44 @@ fun PlayerProgressBar( ) } }, - modifier = Modifier.fillMaxWidth(), + // Keep a generous invisible touch target around the visual track. + // This makes fine seeking practical on a phone without turning the + // timeline itself into a chunky Material slider. + modifier = Modifier + .fillMaxWidth() + .requiredHeight(48.dp), ) - // iOS time row: current time left, duration right, `.caption` (~12sp) at - // 0.8 white opacity, monospaced digits. + // Current + remaining is more useful during playback than current + + // total, especially when the controls are separated from the video in + // tabletop posture. Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = formatClockTime(displayPosition.toDouble()), - fontSize = 12.sp, + fontSize = 13.sp, fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.8f), + color = Color.White.copy(alpha = 0.86f), ) Text( - text = formatClockTime(duration), - fontSize = 12.sp, + text = remainingTimeLabel(displayPosition.toDouble(), duration), + fontSize = 13.sp, fontFamily = FontFamily.Monospace, - color = Color.White.copy(alpha = 0.8f), + color = Color.White.copy(alpha = 0.86f), ) } } } +internal fun remainingTimeLabel(position: Double, duration: Double): String = + if (duration.isFinite() && duration > 0.0) { + "−${formatClockTime((duration - position).coerceAtLeast(0.0))}" + } else { + "−−:−−" + } + /** iOS `chapterTitle(at:)`: the last chapter starting at or before [seconds], * falling back to "Chapter N" when the chapter is untitled. */ internal fun chapterTitleAt(chapters: List, seconds: Double): String? { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt index 67f66a3ab..237b3295e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt @@ -4,7 +4,9 @@ import android.app.Activity import android.content.ComponentName import android.content.pm.ActivityInfo import android.graphics.Rect +import android.os.Build import android.os.SystemClock +import android.provider.Settings import android.util.Log import android.view.ViewGroup import android.view.WindowManager @@ -14,9 +16,16 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -26,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -35,7 +45,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowCompat @@ -60,14 +73,19 @@ import org.prairieserver.prairie.common.player.DisplayHdrProbe import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackPreflightListener import org.prairieserver.prairie.common.player.RefreshRateMatcher +import org.prairieserver.prairie.common.player.SessionState import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.player.VideoPlayerMediaSpec +import org.prairieserver.prairie.common.player.subtitlesForVideoMediaMount import org.prairieserver.prairie.common.player.validatedColorRangeFallback import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator import org.prairieserver.prairie.common.pip.PrairiePictureInPicturePlaybackState import org.prairieserver.prairie.common.pip.PrairiePictureInPictureSurface +import org.prairieserver.prairie.common.settings.LetterboxExpansion import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendRequest +import org.prairieserver.prairie.common.player.video.mountedAudioTracks +import org.prairieserver.prairie.common.player.video.selectedMountedAudioOrdinal import org.prairieserver.prairie.common.player.video.PlaybackStartupStallDetector import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionMetrics import org.prairieserver.prairie.common.player.video.PostResumeVideoStallDetector @@ -99,6 +117,10 @@ import androidx.compose.ui.unit.sp private const val TAG = "PlayerScreen" +/** See the TV screen's copy: a capability change waits this long before track + * presets are re-applied, so a sink being rebuilt is not asked to reselect. */ +private const val TrackSelectionSettleMs = 1_500L + internal fun shouldClearPlaybackOnControllerDispose(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations @@ -142,7 +164,8 @@ private fun media3TextTrackSnapshotKey(tracks: androidx.media3.common.Tracks): S } private fun SubtitleIdentity.requiresMountedMobileSelection(): Boolean = - this is SubtitleIdentity.LocalMedia3 || + this is SubtitleIdentity.ServerSidecar || + this is SubtitleIdentity.LocalMedia3 || this is SubtitleIdentity.Downloaded || this is SubtitleIdentity.Embedded @@ -180,11 +203,14 @@ fun PlayerScreen( ) { val context = LocalContext.current val activity = context as? Activity + val density = LocalDensity.current + val tabletopPosture = rememberTabletopPlayerPosture(activity) val lifecycleOwner = LocalLifecycleOwner.current val activePlayerHolder: ActivePlayerHolder = koinInject() val pictureInPictureCoordinator: PrairiePictureInPictureCoordinator = koinInject() val playerSettingsStore: org.prairieserver.prairie.common.settings.PlayerSettingsStore = koinInject() val uiState by viewModel.presentationState.collectAsState() + val sessionState by viewModel.sessionState.collectAsState() val pictureInPictureEnabled by playerSettingsStore.pictureInPictureEnabledFlow.collectAsState(initial = true) val isInPictureInPictureMode by pictureInPictureCoordinator.isInPictureInPictureMode.collectAsState() val backendFactory: VideoPlaybackBackendFactory = koinInject() @@ -200,8 +226,40 @@ fun PlayerScreen( var dvSanitizerReported by remember { mutableStateOf(false) } var pictureInPictureVideoWidth by remember { mutableStateOf(16) } var pictureInPictureVideoHeight by remember { mutableStateOf(9) } + // Display aspect of the decoded frame — coded size corrected for anamorphic + // pixels, which is what AspectRatioFrameLayout actually fits. Deliberately 0 + // until the first video-size callback, so the letterbox probe measures + // against the real frame rather than the 16:9 placeholder above. + var codedVideoAspect by remember { mutableFloatStateOf(0f) } var pictureInPictureSourceRect by remember { mutableStateOf(null) } + var playerRootBounds by remember { mutableStateOf(null) } var fastForwardHoldActive by remember { mutableStateOf(false) } + val originalWindowBrightness = remember(activity) { + activity?.window?.attributes?.screenBrightness + } + var playerBrightnessFraction by remember(activity, originalWindowBrightness) { + mutableFloatStateOf( + ( + originalWindowBrightness + ?.takeIf { it >= 0f } + ?: runCatching { + Settings.System.getInt( + context.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + ) / 255f + }.getOrDefault(0.5f) + ).coerceIn(0f, 1f), + ) + } + + DisposableEffect(activity, originalWindowBrightness) { + onDispose { + val window = activity?.window ?: return@onDispose + val attributes = window.attributes + attributes.screenBrightness = originalWindowBrightness ?: -1f + window.attributes = attributes + } + } // Google Cast (Chromecast). Distinct from the NSD/mDNS PrairieCast device // remote. When a Cast session connects, local Media3 is paused and a @@ -211,6 +269,19 @@ fun PlayerScreen( val castState by castManager.castState.collectAsState() val castScope = rememberCoroutineScope() var wasCasting by remember { mutableStateOf(false) } + val tabletopPaneLayout = remember(tabletopPosture, playerRootBounds, density.density) { + val posture = tabletopPosture ?: return@remember null + val rootBounds = playerRootBounds ?: return@remember null + calculateTabletopPlayerPaneLayout( + rootTopPx = rootBounds.top, + rootBottomPx = rootBounds.bottom, + foldTopPx = posture.foldBounds.top, + foldBottomPx = posture.foldBounds.bottom, + foldGuardPx = with(density) { 8.dp.roundToPx() }, + ) + } + val useTabletopPlayerLayout = + tabletopPaneLayout != null && !isInPictureInPictureMode && !castState.isConnected // Watch Together binding. Built once per roomId; null for solo playback. // The process RoomSession owns the WS; this controller owns only the @@ -321,27 +392,40 @@ fun PlayerScreen( // (surfaceCreated/Changed/Destroyed) and recovers across seek/recreate/rotation // underlying player). Re-binds automatically when the engine swaps. val sessionPlayer by activePlayerHolder.player.collectAsState() - val videoBackend = remember( - sessionPlayer, - mediaController, - backendFactory, - contentId, - initialFileId, - uiState.playMethod, - uiState.playbackPlan, - uiState.delivery, - uiState.container, - uiState.streamUrl, - ) { - val plan = uiState.playbackPlan - val delivery = plan?.delivery ?: uiState.delivery - (sessionPlayer ?: mediaController)?.let { player -> + val backendPlayer = sessionPlayer ?: mediaController + val videoBackend = remember(backendPlayer, backendFactory) { + backendPlayer?.let { player -> backendFactory.create( player = player, request = VideoPlaybackBackendRequest(), ) } } + // A neutral-v3 replan publishes replacement route state before the + // corresponding Compose mount effect runs. Subtitle restoration must wait + // for that exact media generation rather than racing a newly mounted route. + var mountedMediaGeneration by remember(videoBackend) { mutableStateOf(null) } + // False until presets have been applied once for the current backend, so + // only later capability changes wait for the route to settle. + var trackPresetsApplied by remember(videoBackend) { mutableStateOf(false) } + + // Applies a local audio switch. The ViewModel does not commit on this call: + // AudioTrackManager returns Unit and does nothing silently when the group is + // absent, so it waits for a snapshot showing the target selected. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.pendingLocalAudioSelection.collect { request -> + request ?: return@collect + backend.selectAudioTrack( + VideoPlayerTrackEntry( + index = request.targetOrdinal, + label = "", + language = null, + isSelected = true, + ), + ) + } + } LaunchedEffect(videoBackend) { videoBackend?.let { backend -> @@ -452,13 +536,22 @@ fun PlayerScreen( hdrEnabled, ) { val backend = videoBackend ?: return@LaunchedEffect - backend.applyTrackSelection( - audioCaps = audioCaps, - displayHdr = if (hdrEnabled) displayHdr else org.prairieserver.prairie.model.playback.HdrCapabilities(), - preferredAudioLanguage = uiState.preferredAudioLanguage, - preferredTextLanguage = uiState.preferredTextLanguage, - hdrEnabled = hdrEnabled, - ) + // Let the audio route settle first — see the TV screen's copy of this. + // Here the route change is a headphone unplug or a Bluetooth drop + // rather than an HDMI switch, but the failure is the same: a + // reselection during a sink rebuild has no media period to seek. + if (trackPresetsApplied) delay(TrackSelectionSettleMs) + // Only a REAL application counts — see the TV screen's copy. + if (backend.applyTrackSelection( + audioCaps = audioCaps, + displayHdr = if (hdrEnabled) displayHdr else org.prairieserver.prairie.model.playback.HdrCapabilities(), + preferredAudioLanguage = uiState.preferredAudioLanguage, + preferredTextLanguage = uiState.preferredTextLanguage, + hdrEnabled = hdrEnabled, + ) + ) { + trackPresetsApplied = true + } } // Mirror the user's preferred playback speed onto the live MediaController, @@ -484,6 +577,7 @@ fun PlayerScreen( initialAudioTrackIndex = initialAudioTrackIndex, initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, + routeResumePositionSeconds = resumePositionOverride, // Watch Together's synced anchor must land exactly — don't nudge it back. suppressResumeRewind = !roomId.isNullOrBlank(), ) @@ -534,17 +628,33 @@ fun PlayerScreen( // Orientation policy (iOS PlayerOrientationCoordinator parity): entering // the player locks to landscape by default; the persisted "rotateFreely" // opt-out (HUD lock toggle / synced setting) falls back to USER so the - // system rotation preference stays in charge. Released on exit by the - // immersive effect's originalOrientation restore above. + // system rotation preference stays in charge. Android 16 ignores requested + // orientation on 600dp+ displays, so those layouts stay adaptive and the + // overlay disables the lock affordance instead of claiming a no-op lock. + // Released on exit by the immersive effect's UNSPECIFIED restore above. // Wait for the persisted preference before touching the activity: the // resolved flow is null until it arrives, and applying the eager locked // default on the first frame would snap rotateFreely users back to // landscape on every player entry. + val smallestScreenWidthDp = LocalConfiguration.current.smallestScreenWidthDp + val orientationLockSupported = supportsPlayerOrientationLock( + sdkInt = Build.VERSION.SDK_INT, + smallestScreenWidthDp = smallestScreenWidthDp, + ) val orientationLockedResolved by viewModel.orientationLockedResolved.collectAsState() - LaunchedEffect(activity, orientationLockedResolved, castState.isConnected) { + LaunchedEffect( + activity, + orientationLockedResolved, + castState.isConnected, + orientationLockSupported, + tabletopPosture, + ) { // While casting, the screen shows the cast takeover panel, not video — // no reason to force landscape (and it must unlock if already forced). - if (castState.isConnected) { + // Large Android 16 displays likewise own their orientation by platform + // policy. Tabletop posture also owns its physical orientation; forcing + // landscape can rotate a horizontal hinge back into book posture. + if (castState.isConnected || !orientationLockSupported || tabletopPosture != null) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED return@LaunchedEffect } @@ -597,7 +707,12 @@ fun PlayerScreen( delivery = delivery, serverUrl = serverUrl, container = uiState.container, - subtitles = uiState.subtitleTracks, + subtitles = subtitlesForVideoMediaMount( + subtitles = uiState.subtitleTracks, + playbackPlan = plan, + subtitleIdentity = uiState.localSubtitleMountIdentity + ?: uiState.committedSubtitleIdentity, + ), title = uiState.title.ifBlank { null }, subtitle = uiState.subtitle.ifBlank { null }, artworkUrl = uiState.artworkUrl, @@ -620,6 +735,7 @@ fun PlayerScreen( playMethod = playMethod, startPositionMs = mediaSpec.startPositionMs, nowMs = SystemClock.elapsedRealtime(), + clientTransformations = mediaSpec.transformations, ) postResumeStallDetector.onMounted( "${uiState.sessionId}:$effectiveStreamUrl:${plan?.planId.orEmpty()}:" + @@ -627,9 +743,21 @@ fun PlayerScreen( ) } backend.mount(mediaSpec, playWhenReady = !viewModel.uiState.value.isPaused) + mountedMediaGeneration = uiState.mediaMountGeneration viewModel.onMediaMountApplied(uiState.mediaMountGeneration) } + // A new mount leaves the previous item's frame in the SurfaceView until the + // new stream decodes its own, and its geometry describes that stale frame. + // Forgetting it gates the letterbox probe off until Media3 re-reports a + // video size — which it does as the new stream produces its first output — + // so the outgoing episode's matte can never settle, or be cached, under the + // incoming one's key. The PiP dimensions above are deliberately kept: they + // size a window that must not collapse mid-transition. + LaunchedEffect(uiState.mediaMountGeneration) { + codedVideoAspect = 0f + } + // Mid-playback subtitle refresh (downloaded / AI-generated tracks). // Subtitle configs are baked into the MediaItem at build time, so when // refreshSubtitles merges new tracks it bumps subtitleRefreshNonce and we @@ -656,7 +784,12 @@ fun PlayerScreen( delivery = delivery, serverUrl = uiState.serverUrl, container = uiState.container, - subtitles = uiState.subtitleTracks, + subtitles = subtitlesForVideoMediaMount( + subtitles = uiState.subtitleTracks, + playbackPlan = plan, + subtitleIdentity = uiState.localSubtitleMountIdentity + ?: uiState.committedSubtitleIdentity, + ), title = uiState.title.ifBlank { null }, subtitle = uiState.subtitle.ifBlank { null }, artworkUrl = uiState.artworkUrl, @@ -712,7 +845,7 @@ fun PlayerScreen( } // Player event listener to feed state back to ViewModel + track video size for PiP - DisposableEffect(mediaController, playWhenReadyReconciliationGate) { + DisposableEffect(mediaController, videoBackend, playWhenReadyReconciliationGate) { val controller = mediaController if (controller == null) { onDispose { } @@ -792,6 +925,8 @@ fun PlayerScreen( if (size.width > 0 && size.height > 0) { pictureInPictureVideoWidth = size.width pictureInPictureVideoHeight = size.height + val pixelAspect = size.pixelWidthHeightRatio.takeIf { it > 0f } ?: 1f + codedVideoAspect = size.width.toFloat() / size.height * pixelAspect // Pull frame rate off the selected video track; phone // panels with multiple refresh rates switch to // content-matching (seamless only — see ExoPlayer's @@ -811,6 +946,13 @@ fun PlayerScreen( } override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + // Audio was never published here, so a direct-play file with + // several audio tracks could show the chosen row while the + // renderer stayed on Media3's default. + viewModel.onMountedAudioChanged( + mounted = mountedAudioTracks(tracks), + selectedOrdinal = selectedMountedAudioOrdinal(tracks), + ) // Re-apply the subtitle selection once track groups resolve: // after the subtitle-refresh rebuild the selection effect has // already fired (against the OLD tracks), so without this the @@ -979,8 +1121,11 @@ fun PlayerScreen( uiState.selectedSubtitleIndex, uiState.committedSubtitleIdentity, uiState.localSubtitleMountIdentity, + uiState.mediaMountGeneration, + mountedMediaGeneration, ) { val backend = videoBackend ?: return@LaunchedEffect + if (mountedMediaGeneration != uiState.mediaMountGeneration) return@LaunchedEffect val pendingIdentity = uiState.localSubtitleMountIdentity val targetIdentity = pendingIdentity ?: uiState.committedSubtitleIdentity val selectedIndex = resolveMobileSubtitleOrdinal(targetIdentity, uiState.subtitleTracks) @@ -997,7 +1142,11 @@ fun PlayerScreen( identity = pendingIdentity, selected = selected, snapshotKey = media3TextTrackSnapshotKey(backend.player.currentTracks), - settled = backend.player.playbackState == Player.STATE_READY, + // This composition-side attempt can race Media3's first + // text-track publication. Only onTracksChanged callbacks + // provide settlement evidence; the adapter then requires + // the same non-empty snapshot twice before failing. + settled = false, ) } } else { @@ -1042,7 +1191,17 @@ fun PlayerScreen( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black), + .background(Color.Black) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + val next = Rect( + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + if (playerRootBounds != next) playerRootBounds = next + }, ) { if (uiState.isLoading) { Box( @@ -1083,12 +1242,76 @@ fun PlayerScreen( } else { val controller = mediaController val videoGravity by viewModel.videoGravity.collectAsState() - val resizeMode = when (videoGravity) { - "fill" -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - "stretch" -> AspectRatioFrameLayout.RESIZE_MODE_FILL + var playerViewRef by remember { mutableStateOf(null) } + val letterboxExpansion by viewModel.letterboxExpansion.collectAsState() + // The camera only reaches the picture once expansion pushes it out + // to the edges — at FIT's 2560px the pillarbox already swallows it. + // Read the platform's resolved cutout rather than deriving it from + // rotation: it already knows which edge the camera is on in THIS + // rotation (they are opposite edges in the two landscapes), and it + // accounts for waterfall edges and multiple cutouts too. + val layoutDirection = LocalLayoutDirection.current + // Fill and Stretch are the user asking for the whole display, camera + // and all, exactly as they are excluded from expansion below — so + // they are not insetted either. + val explicitFullScreenGravity = videoGravity == "fill" || videoGravity == "stretch" + val cutoutSideInsetPx = if ( + letterboxExpansion == LetterboxExpansion.ClearOfCamera && !explicitFullScreenGravity + ) { + val cutout = WindowInsets.displayCutout + cutoutSafeHorizontalInset( + cutoutLeftPx = cutout.getLeft(density, layoutDirection), + cutoutRightPx = cutout.getRight(density, layoutDirection), + ) + } else { + 0 + } + // Scope films ship as a 2.39:1 image inside a 16:9 frame, and a + // 1.90:1 title ships the same way, so a plain fit fits the encoded + // black too. This measures that matte and reports the aspect of the + // picture hiding inside the frame — the coded aspect itself when + // there is nothing to discount. Off wherever the video is not what + // is on screen, and off for the gravities the user has already + // decided for themselves. + val letterboxContentAspect = rememberLetterboxContentAspect( + playerView = playerViewRef, + enabled = letterboxExpansion != LetterboxExpansion.Off && + !explicitFullScreenGravity && + !isInPictureInPictureMode && + !castState.isConnected && + !useTabletopPlayerLayout, + videoAspect = codedVideoAspect, + mediaKey = uiState.mediaMountGeneration, + cacheKey = letterboxMatteCacheKey( + // Downloads carry no server URL by design, and content and + // media-file ids are server-scoped, so keying them on the + // rest of the tuple alone would let two servers' downloads + // share an entry. The local URI names those stored bytes + // exactly, and is stable across plays of the download. + origin = uiState.serverUrl.ifBlank { + uiState.streamUrl + ?.takeIf { it.startsWith("file://") || it.startsWith("content://") } + .orEmpty() + }, + contentId = uiState.contentId, + mediaFileId = uiState.mediaFileId, + codedWidth = pictureInPictureVideoWidth, + codedHeight = pictureInPictureVideoHeight, + ), + ) + // Expanding means giving the surface the shape of the PICTURE rather + // than of the coded frame, and letting the frame overflow it. The + // surface box below is that shape; ZOOM then scales the frame to + // cover it, which lands the clip inside the encoded matte by + // construction rather than by a threshold. + val letterboxExpanding = codedVideoAspect > 0f && + letterboxContentAspect > codedVideoAspect + val resizeMode = when { + videoGravity == "fill" -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + videoGravity == "stretch" -> AspectRatioFrameLayout.RESIZE_MODE_FILL + letterboxExpanding -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM else -> AspectRatioFrameLayout.RESIZE_MODE_FIT } - var playerViewRef by remember { mutableStateOf(null) } val subtitleAppearance by viewModel.subtitleAppearance.collectAsState() // Re-apply user subtitle styling whenever the PlayerView mounts or the @@ -1100,6 +1323,32 @@ fun PlayerScreen( subtitleManager.applyAppearance(pv, subtitleAppearance) } + val activeTabletopPaneLayout = tabletopPaneLayout.takeIf { + useTabletopPlayerLayout + } + val cutoutInsetDp = with(density) { cutoutSideInsetPx.toDp() } + val videoSurfaceModifier = when { + activeTabletopPaneLayout != null -> + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(with(density) { activeTabletopPaneLayout.videoHeightPx.toDp() }) + // Expanding means giving the surface the shape of the PICTURE + // rather than of the coded frame. `aspectRatio` IS the fit: it + // takes the full width when the picture is wider than what is + // available and the full height when it is narrower, which is + // exactly the rule, both cases, no branch. It must NOT be + // preceded by fillMaxSize, which would pin the constraints and + // leave it nothing to choose between. + letterboxExpanding -> + Modifier + .align(Alignment.Center) + .padding(horizontal = cutoutInsetDp) + .aspectRatio(letterboxContentAspect) + // Shrinking the available area is what keeps the camera off a + // picture that reaches the edges on its own. + else -> Modifier.fillMaxSize().padding(horizontal = cutoutInsetDp) + } if (controller != null) { AndroidView( @@ -1121,8 +1370,7 @@ fun PlayerScreen( view.resizeMode = resizeMode subtitleManager.syncSubtitleVideoBounds(view) }, - modifier = Modifier - .fillMaxSize() + modifier = videoSurfaceModifier .onGloballyPositioned { coordinates -> val bounds = coordinates.boundsInWindow() val next = Rect( @@ -1138,6 +1386,25 @@ fun PlayerScreen( ) } + // In tabletop posture the regular PlayerOverlay is deliberately + // constrained to the controls pane. Keep playback/reconnection + // feedback on the video itself instead of showing a spinner below + // the hinge among the transport controls. + if (activeTabletopPaneLayout != null && + (uiState.isBuffering || sessionState is SessionState.Reconnecting) + ) { + Box( + modifier = videoSurfaceModifier, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(56.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + } + } + // Cast takeover surface — replaces the video AND the local player // controls while a Cast session is live. The local controls must not // render on top of it: their seek bar / gestures drive the paused @@ -1165,11 +1432,37 @@ fun PlayerScreen( } if (!isInPictureInPictureMode && !castState.isConnected) { + val playerOverlayModifier = if (activeTabletopPaneLayout != null) { + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(with(density) { activeTabletopPaneLayout.controlsHeightPx.toDp() }) + } else { + Modifier.fillMaxSize() + } PlayerClockScope(viewModel) { clock -> PlayerOverlay( state = uiState.withPlaybackClock(clock), viewModel = viewModel, roomSnapshot = roomSnapshot, + orientationLockSupported = + orientationLockSupported && activeTabletopPaneLayout == null, + alwaysShowControls = activeTabletopPaneLayout != null, + tabletopMode = activeTabletopPaneLayout != null, + tabletopPaneHeight = activeTabletopPaneLayout?.let { layout -> + with(density) { layout.controlsHeightPx.toDp() } + }, + brightnessFraction = playerBrightnessFraction, + onSetBrightness = { fraction -> + val appliedBrightness = fraction.coerceIn(0.02f, 1f) + playerBrightnessFraction = appliedBrightness + activity?.window?.let { window -> + val attributes = window.attributes + attributes.screenBrightness = appliedBrightness + window.attributes = attributes + } + }, + showBufferingIndicator = activeTabletopPaneLayout == null, castSlot = { PrairieCastButton( castManager = castManager, @@ -1215,6 +1508,7 @@ fun PlayerScreen( onSelectSubtitle = { viewModel.onSelectSubtitle(it) }, onSelectAudio = { viewModel.onSelectAudio(it) }, onSelectVersion = { viewModel.onSelectVersion(it) }, + modifier = playerOverlayModifier, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt index 7f2e7c3dd..853daba7d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt @@ -3,52 +3,85 @@ package org.prairieserver.prairie.android.ui.screens.player import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.res.stringResource +import org.prairieserver.prairie.android.R import org.prairieserver.prairie.common.player.PlayerStatsSnapshot +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.common.settings.LetterboxExpansion import org.prairieserver.prairie.common.player.SleepTimerState -import java.util.Locale -import kotlinx.coroutines.launch -/** - * Glass-style player settings bottom sheet — Phase 1 subset: - * speed, aspect (video gravity), HDR, auto-skip intro, auto-play next. - * - * Mirrors iOS `PlayerSettingsSheet.swift` for the rows it covers; subtitle - * styling, sleep timer, sync, and route-diagnostic rows arrive in later phases. - */ -@OptIn(ExperimentalMaterial3Api::class) +private enum class SettingsCategory( + val label: String, + val description: String, + val icon: ImageVector, +) { + Playback("Playback", "Speed and picture sizing", Icons.Filled.PlayArrow), + Episodes("Episodes", "Automatic episode behavior", Icons.Filled.SkipNext), + Sync("Sync", "Audio and subtitle timing", Icons.Filled.Sync), + More("More", "Subtitles, timer, and video", Icons.Filled.MoreHoriz), +} + +/** Adaptive playback settings menu shared by regular phones and tabletop mode. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun PlayerSettingsSheet( isVisible: Boolean, @@ -57,8 +90,10 @@ fun PlayerSettingsSheet( onSetPlaybackSpeed: (Double) -> Unit, videoGravity: String, onSetVideoGravity: (String) -> Unit, - autoSkipIntroEnabled: Boolean, - onSetAutoSkipIntro: (Boolean) -> Unit, + letterboxExpansion: String = LetterboxExpansion.Default, + onSetLetterboxExpansion: (String) -> Unit = {}, + introSkipMode: IntroSkipMode, + onSetIntroSkipMode: (IntroSkipMode) -> Unit, autoPlayNextEnabled: Boolean, onSetAutoPlayNext: (Boolean) -> Unit, hdrEnabled: Boolean, @@ -75,214 +110,387 @@ fun PlayerSettingsSheet( subtitleDelayMs: Int = 0, onSetSubtitleDelay: (Int) -> Unit = {}, sleepTimerState: SleepTimerState = SleepTimerState.Idle, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + var selectedCategoryIndex by rememberSaveable { mutableIntStateOf(0) } + val selectedCategory = SettingsCategory.entries[selectedCategoryIndex] + val useSideRail = LocalConfiguration.current.screenWidthDp >= 600 + val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val openSubtitleStyle = { + scope.dismissPlayerSheet(sheetState, onDismiss, onOpenSubtitleStyle) + } + val openSleepTimer = { + scope.dismissPlayerSheet(sheetState, onDismiss, onOpenSleepTimer) + } + val openPlaybackStats = { + scope.dismissPlayerSheet(sheetState, onDismiss, onOpenPlaybackStats) + } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + val categoryContent: @Composable (Modifier, Boolean) -> Unit = { modifier, showHeader -> + SettingsCategoryContent( + category = selectedCategory, + playbackSpeed = playbackSpeed, + onSetPlaybackSpeed = onSetPlaybackSpeed, + videoGravity = videoGravity, + onSetVideoGravity = onSetVideoGravity, + letterboxExpansion = letterboxExpansion, + onSetLetterboxExpansion = onSetLetterboxExpansion, + introSkipMode = introSkipMode, + onSetIntroSkipMode = onSetIntroSkipMode, + autoPlayNextEnabled = autoPlayNextEnabled, + onSetAutoPlayNext = onSetAutoPlayNext, + audioDelayMs = audioDelayMs, + audioDelayEnabled = audioDelayEnabled, + onSetAudioDelay = onSetAudioDelay, + subtitleDelayMs = subtitleDelayMs, + onSetSubtitleDelay = onSetSubtitleDelay, + onOpenSubtitleStyle = openSubtitleStyle, + sleepTimerState = sleepTimerState, + onOpenSleepTimer = openSleepTimer, + hdrEnabled = hdrEnabled, + onSetHdrEnabled = onSetHdrEnabled, + dolbyVisionEnabled = dolbyVisionEnabled, + onSetDolbyVisionEnabled = onSetDolbyVisionEnabled, + stats = stats, + onOpenPlaybackStats = openPlaybackStats, + showHeader = showHeader, + modifier = modifier, + ) + } + + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { - Box( + Column( modifier = Modifier .fillMaxWidth() - // Keep the sheet (and its drag handle) below the top screen - // edge, and keep content flings from dismissing the sheet — - // see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ), + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard), ) { - // Vertically scroll the inner content. With Playback + Episodes + - // Sync + Subtitles + Timers sections, the sheet overflows on - // smaller phones — scrolling lets every row stay reachable. - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - ) { - Text( - text = "Playback Settings", - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 8.dp), - ) - - SectionHeader(text = "Playback") - - SpeedRow( - selected = playbackSpeed, - onSelect = onSetPlaybackSpeed, - ) - - AspectRow( - selected = videoGravity, - onSelect = onSetVideoGravity, - ) - - // HDR / Dolby Vision / Playback Stats are demoted to the - // Advanced section at the bottom (tester feedback: keep the - // primary menu to things people change regularly). - // Quality and Chapters intentionally have NO rows here — they - // are HUD buttons (product decision: HUD = chapters + tracks + - // quality; the gear keeps the long-tail settings). - - SectionHeader(text = "Episodes") - - ToggleRow( - label = "Auto-skip Intro", - subtitle = "Skip intro after a 5-second countdown.", - checked = autoSkipIntroEnabled, - onCheckedChange = onSetAutoSkipIntro, - ) - - ToggleRow( - label = "Auto-play Next Episode", - subtitle = null, - checked = autoPlayNextEnabled, - onCheckedChange = onSetAutoPlayNext, - ) - - SectionHeader(text = "Sync") - - DelaySpinnerRow( - label = "Audio delay (PCM only)", - valueMs = audioDelayMs, - enabled = audioDelayEnabled, - stepMs = 50, - minMs = -5000, - maxMs = 5000, - onChange = onSetAudioDelay, - ) - - DelaySpinnerRow( - label = "Subtitle delay", - valueMs = subtitleDelayMs, - stepMs = 50, - minMs = -10000, - maxMs = 10000, - onChange = onSetSubtitleDelay, - ) - - SectionHeader(text = "Subtitles") - - TapRow( - label = "Subtitle Style", - subtitle = "Font, color, background, position", - onClick = { - scope.launch { sheetState.hide() } - onDismiss() - onOpenSubtitleStyle() - }, - ) - - SectionHeader(text = "Timers") - - TapRow( - label = "Sleep Timer", - subtitle = formatSleepTimerSubtitle(sleepTimerState), - onClick = { - scope.launch { sheetState.hide() } - onDismiss() - onOpenSleepTimer() - }, - ) - - SectionHeader(text = "Advanced") + PlayerSheetHeader( + title = "Playback Settings", + subtitle = selectedCategory.description, + onDismiss = dismissSheet, + ) - ToggleRow( - label = "HDR", - subtitle = null, - checked = hdrEnabled, - onCheckedChange = onSetHdrEnabled, + if (useSideRail) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingsCategoryRail( + selected = selectedCategory, + onSelect = { selectedCategoryIndex = it.ordinal }, + modifier = Modifier + .width(212.dp) + .fillMaxHeight(), + ) + categoryContent( + Modifier + .weight(1f) + .fillMaxHeight(), + false, + ) + } + } else { + SettingsCategoryTabs( + selected = selectedCategory, + onSelect = { selectedCategoryIndex = it.ordinal }, ) - - // Off plays DV sources as their base layer (HDR10); profile 5 - // always plays as DV (no watchable base layer). Applies from - // the next playback start. Apple parity (prairie-apple e9bd775). - ToggleRow( - label = "Dolby Vision", - subtitle = "Off plays the HDR10 base layer", - checked = dolbyVisionEnabled, - onCheckedChange = onSetDolbyVisionEnabled, + categoryContent( + Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 24.dp), + true, ) + } + } + } +} - TapRow( - label = "Playback Stats", - subtitle = stats.summaryLabel(), - onClick = { - scope.launch { sheetState.hide() } - onDismiss() - onOpenPlaybackStats() - }, - ) +@Composable +private fun SettingsCategoryRail( + selected: SettingsCategory, + onSelect: (SettingsCategory) -> Unit, + modifier: Modifier = Modifier, +) { + PlayerSheetCard(modifier = modifier) { + PlayerSheetSectionLabel("Settings") + SettingsCategory.entries.forEach { category -> + CategoryButton( + category = category, + isSelected = category == selected, + onClick = { onSelect(category) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } +} - Spacer(modifier = Modifier.height(16.dp)) - } +@Composable +private fun SettingsCategoryTabs( + selected: SettingsCategory, + onSelect: (SettingsCategory) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(SettingsCategory.entries) { category -> + CategoryButton( + category = category, + isSelected = category == selected, + onClick = { onSelect(category) }, + ) } } } @Composable -private fun SectionHeader(text: String) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), - ) +private fun CategoryButton( + category: SettingsCategory, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background(if (isSelected) PlayerSheetSelectedColor else Color.Transparent) + .clickable(onClick = onClick) + .heightIn(min = 48.dp) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.58f), + modifier = Modifier.size(20.dp), + ) + Text( + text = category.label, + color = if (isSelected) Color.White else Color.White.copy(alpha = 0.72f), + fontSize = 14.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) + } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun SpeedRow( - selected: Double, - onSelect: (Double) -> Unit, +private fun SettingsCategoryContent( + category: SettingsCategory, + playbackSpeed: Double, + onSetPlaybackSpeed: (Double) -> Unit, + videoGravity: String, + onSetVideoGravity: (String) -> Unit, + letterboxExpansion: String, + onSetLetterboxExpansion: (String) -> Unit, + introSkipMode: IntroSkipMode, + onSetIntroSkipMode: (IntroSkipMode) -> Unit, + autoPlayNextEnabled: Boolean, + onSetAutoPlayNext: (Boolean) -> Unit, + audioDelayMs: Int, + audioDelayEnabled: Boolean, + onSetAudioDelay: (Int) -> Unit, + subtitleDelayMs: Int, + onSetSubtitleDelay: (Int) -> Unit, + onOpenSubtitleStyle: () -> Unit, + sleepTimerState: SleepTimerState, + onOpenSleepTimer: () -> Unit, + hdrEnabled: Boolean, + onSetHdrEnabled: (Boolean) -> Unit, + dolbyVisionEnabled: Boolean, + onSetDolbyVisionEnabled: (Boolean) -> Unit, + stats: PlayerStatsSnapshot, + onOpenPlaybackStats: () -> Unit, + showHeader: Boolean, + modifier: Modifier = Modifier, ) { - val options = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) - Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + PlayerSheetCard(modifier = modifier) { + if (showHeader) { + CategoryContentHeader(category) + PlayerSheetDivider() + } + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + when (category) { + SettingsCategory.Playback -> { + SpeedSetting( + selected = playbackSpeed, + onSelect = onSetPlaybackSpeed, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + AspectSetting( + selected = videoGravity, + onSelect = onSetVideoGravity, + ) + // Modulates Fit only. Fill and Stretch are explicit + // decisions about cropping and are left alone. + LetterboxExpansionSetting( + selected = letterboxExpansion, + onSelect = onSetLetterboxExpansion, + ) + } + + SettingsCategory.Episodes -> { + IntroSkipModeSetting( + selected = introSkipMode, + onSelect = onSetIntroSkipMode, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ToggleRow( + label = "Auto-play next episode", + subtitle = "Continue without returning to the series page", + checked = autoPlayNextEnabled, + onCheckedChange = onSetAutoPlayNext, + ) + } + + SettingsCategory.Sync -> { + DelaySpinnerRow( + label = "Audio delay", + subtitle = "PCM audio only", + valueMs = audioDelayMs, + enabled = audioDelayEnabled, + stepMs = 50, + minMs = -5000, + maxMs = 5000, + onChange = onSetAudioDelay, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + DelaySpinnerRow( + label = "Subtitle delay", + subtitle = "Move captions earlier or later", + valueMs = subtitleDelayMs, + stepMs = 50, + minMs = -10000, + maxMs = 10000, + onChange = onSetSubtitleDelay, + ) + } + + SettingsCategory.More -> { + TapRow( + label = "Subtitle style", + subtitle = "Font, color, background, and position", + onClick = onOpenSubtitleStyle, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + TapRow( + label = "Sleep timer", + subtitle = formatSleepTimerSubtitle(sleepTimerState), + onClick = onOpenSleepTimer, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ToggleRow( + label = "HDR", + subtitle = "Allow high dynamic range playback", + checked = hdrEnabled, + onCheckedChange = onSetHdrEnabled, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + ToggleRow( + label = "Dolby Vision", + subtitle = "Turn off to use the HDR10 base layer", + checked = dolbyVisionEnabled, + onCheckedChange = onSetDolbyVisionEnabled, + ) + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 16.dp)) + TapRow( + label = "Playback stats", + subtitle = stats.summaryLabel(), + onClick = onOpenPlaybackStats, + ) + } + } + } + } +} + +@Composable +private fun CategoryContentHeader(category: SettingsCategory) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + modifier = Modifier.size(38.dp), ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(21.dp), + ) + } + } + Column(modifier = Modifier.weight(1f)) { Text( - text = "Speed", + text = category.label, color = Color.White, fontSize = 16.sp, - modifier = Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, ) Text( - text = "${formatPlaybackSpeed(selected)}×", - color = Color.White.copy(alpha = 0.8f), - fontSize = 14.sp, + text = category.description, + color = Color.White.copy(alpha = 0.48f), + fontSize = 12.sp, ) } - Spacer(modifier = Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(options) { value -> - SpeedPill( - value = value, + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SpeedSetting( + selected: Double, + onSelect: (Double) -> Unit, +) { + val options = listOf(0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + SettingTitle( + title = "Speed", + value = "${formatPlaybackSpeed(selected)}×", + ) + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { value -> + SelectionPill( + label = "${formatPlaybackSpeed(value)}×", isSelected = isSameSpeed(value, selected), onClick = { onSelect(value) }, ) @@ -292,88 +500,231 @@ private fun SpeedRow( } @Composable -private fun SpeedPill( - value: Double, - isSelected: Boolean, - onClick: () -> Unit, +private fun AspectSetting( + selected: String, + onSelect: (String) -> Unit, ) { - val shape = RoundedCornerShape(16.dp) - val baseModifier = Modifier - .clickable(onClick = onClick) - .padding(horizontal = 1.dp) - Box( - modifier = if (isSelected) { - baseModifier.background(color = Color.White, shape = shape) - } else { - baseModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White, shape = shape) - }, - ) { - Text( - text = "${formatPlaybackSpeed(value)}×", - color = if (isSelected) Color.Black else Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), - ) + val options = listOf("fit" to "Fit", "fill" to "Fill", "stretch" to "Stretch") + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + SettingTitle(title = "Picture size") + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.22f)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEach { (value, label) -> + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background( + if (selected == value) MaterialTheme.colorScheme.primary else Color.Transparent, + ) + .clickable { onSelect(value) } + .heightIn(min = 42.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected == value) Color.Black else Color.White.copy(alpha = 0.72f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } } } +/** + * What to do about a film whose black bars are baked into the file. + * + * The copy promises only what the measurement can deliver: bars the FILE + * carries are eaten, the picture never is. Content shot without bars has + * nothing to eat and stays exactly as it is — said plainly here so enabling + * this and then playing a TV episode is not a puzzle. + */ @Composable -private fun AspectRow( +private fun LetterboxExpansionSetting( selected: String, onSelect: (String) -> Unit, ) { - val options = listOf("fit" to "Fit", "fill" to "Fill", "stretch" to "Stretch") + val options = listOf( + LetterboxExpansion.ClearOfCamera to "Clear of camera", + LetterboxExpansion.FullWidth to "Full width", + LetterboxExpansion.Off to "Off", + ) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + SettingTitle(title = "Fill the screen") + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Widescreen films are expanded past the black bars stored in the " + + "file, never into the picture itself. Full width uses the whole " + + "display and lets the camera sit on the image. Video without stored " + + "bars already fits and does not change.", + color = Color.White.copy(alpha = 0.6f), + fontSize = 12.sp, + lineHeight = 16.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.22f)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEach { (value, label) -> + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background( + if (selected == value) MaterialTheme.colorScheme.primary else Color.Transparent, + ) + .clickable { onSelect(value) } + .heightIn(min = 42.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected == value) Color.Black else Color.White.copy(alpha = 0.72f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +/** + * The three-way `playback.intro_skip_mode` control — the schema recommends a + * select, and this app has a segmented control, so it uses one (same shape as + * [LetterboxExpansionSetting]). Copy is fixed by the contract. + */ +@Composable +private fun IntroSkipModeSetting( + selected: IntroSkipMode, + onSelect: (IntroSkipMode) -> Unit, +) { + val options = listOf( + IntroSkipMode.NEVER to stringResource(R.string.settings_intro_skip_never), + IntroSkipMode.ASK to stringResource(R.string.settings_intro_skip_ask), + IntroSkipMode.ALWAYS to stringResource(R.string.settings_intro_skip_always), + ) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + SettingTitle(title = stringResource(R.string.settings_intro_skip_title)) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "What happens when a detected intro starts: leave it alone, " + + "offer a Skip Intro button, or skip it and offer an undo.", + color = Color.White.copy(alpha = 0.6f), + fontSize = 12.sp, + lineHeight = 16.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.22f)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEach { (value, label) -> + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(11.dp)) + .background( + if (selected == value) MaterialTheme.colorScheme.primary else Color.Transparent, + ) + .clickable { onSelect(value) } + .heightIn(min = 42.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected == value) Color.Black else Color.White.copy(alpha = 0.72f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun SettingTitle(title: String, value: String? = null) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Aspect", + text = title, color = Color.White, - fontSize = 16.sp, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { (value, label) -> - AspectPill( - label = label, - isSelected = selected == value, - onClick = { onSelect(value) }, - ) - } + if (value != null) { + Text( + text = value, + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) } } } @Composable -private fun AspectPill( +private fun SelectionPill( label: String, isSelected: Boolean, onClick: () -> Unit, ) { - val shape = RoundedCornerShape(14.dp) - val baseModifier = Modifier - .clickable(onClick = onClick) - Box( - modifier = if (isSelected) { - baseModifier.background(color = Color.White, shape = shape) - } else { - baseModifier - .background(color = Color.Transparent, shape = shape) - .border(width = 1.dp, color = Color.White, shape = shape) - }, + Row( + modifier = Modifier + .clip(RoundedCornerShape(13.dp)) + .background( + if (isSelected) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.06f), + ) + .then( + if (isSelected) Modifier else Modifier.border( + width = 1.dp, + color = Color.White.copy(alpha = 0.10f), + shape = RoundedCornerShape(13.dp), + ), + ) + .clickable(onClick = onClick) + .heightIn(min = 40.dp) + .padding(horizontal = 13.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { + if (isSelected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(16.dp), + ) + } Text( text = label, - color = if (isSelected) Color.Black else Color.White, + color = if (isSelected) Color.Black else Color.White.copy(alpha = 0.78f), fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + fontWeight = FontWeight.SemiBold, ) } } @@ -388,29 +739,17 @@ private fun TapRow( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 12.dp), + .heightIn(min = 68.dp) + .padding(horizontal = 16.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "›", - color = Color.White.copy(alpha = 0.6f), - fontSize = 22.sp, + RowLabel(label = label, subtitle = subtitle, modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = Color.White.copy(alpha = 0.38f), + modifier = Modifier.size(22.dp), ) } } @@ -425,82 +764,62 @@ private fun ToggleRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp), + .toggleable( + value = checked, + onValueChange = onCheckedChange, + role = Role.Switch, + ) + .heightIn(min = 68.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = Color.White, - fontSize = 16.sp, - ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - ) - } - } - Spacer(modifier = Modifier.width(12.dp)) + RowLabel(label = label, subtitle = subtitle, modifier = Modifier.weight(1f)) Switch( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = null, colors = SwitchDefaults.colors( checkedThumbColor = Color.White, - checkedTrackColor = Color(0xFF06B6D4), + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = Color.White.copy(alpha = 0.72f), + uncheckedTrackColor = Color.White.copy(alpha = 0.15f), + uncheckedBorderColor = Color.Transparent, ), ) } } -/** - * Subtitle text for the Sleep Timer row — shows "Off" when idle and the - * remaining countdown when active. Reuses [formatRemaining] for parity with - * the on-screen chip. - */ -private fun formatSleepTimerSubtitle(state: SleepTimerState): String { - return when (state) { - is SleepTimerState.Idle -> "Off" - is SleepTimerState.Active -> "Pausing in ${formatRemaining(state.remainingSeconds)}" +@Composable +private fun RowLabel( + label: String, + subtitle: String?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = label, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + color = Color.White.copy(alpha = 0.48f), + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } } -private fun PlayerStatsSnapshot.summaryLabel(): String { - val route = backendRoute ?: backendDisplayName - return listOfNotNull(resolution, route, bitrateBps?.let(::formatStatsBitrate)) - .take(2) - .joinToString(" - ") - .ifBlank { "Waiting for player data" } -} - -/** - * Format the playback speed for display: trim trailing zeros / decimal point so - * 1.0 → "1", 1.25 → "1.25", 1.50 → "1.5". - */ -private fun formatPlaybackSpeed(speed: Double): String { - if (speed % 1.0 == 0.0) return speed.toInt().toString() - val formatted = String.format(Locale.US, "%.2f", speed) - return formatted.trimEnd('0').trimEnd('.') -} - -/** - * Compare doubles loosely so 1.0 from a flow matches 1.0 from the option list - * across float-rounding hiccups. - */ -private fun isSameSpeed(a: Double, b: Double): Boolean = kotlin.math.abs(a - b) < 0.001 - -/** - * iOS-style range spinner row: label on the left, [− value +] on the right. - * Tap − / + to step by [stepMs]; value clamps to [[minMs], [maxMs]]. Mirrors - * iOS phone's `RangeSpinner` (Sync section of `PlayerSettingsSheet.swift`). - * Android uses a 50 ms step for BOTH audio (±5000) and subtitle (±10000) - * delay — finer than iOS's 100 ms subtitle step — and the TV client matches. - */ @Composable private fun DelaySpinnerRow( label: String, + subtitle: String, valueMs: Int, stepMs: Int, minMs: Int, @@ -508,63 +827,78 @@ private fun DelaySpinnerRow( enabled: Boolean = true, onChange: (Int) -> Unit, ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = label, - color = if (enabled) Color.White else Color.White.copy(alpha = 0.45f), - fontSize = 15.sp, - modifier = Modifier.weight(1f), - ) - SpinnerButton( - label = "−", - enabled = enabled, - onClick = { onChange((valueMs - stepMs).coerceIn(minMs, maxMs)) }, - ) - Text( - text = formatDelayMs(valueMs), - color = if (enabled) Color.White else Color.White.copy(alpha = 0.45f), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier - .width(72.dp) - .padding(horizontal = 8.dp), - ) - SpinnerButton( - label = "+", - enabled = enabled, - onClick = { onChange((valueMs + stepMs).coerceIn(minMs, maxMs)) }, - ) + RowLabel(label = label, subtitle = subtitle) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + SpinnerButton( + label = "−", + enabled = enabled, + onClick = { onChange((valueMs - stepMs).coerceIn(minMs, maxMs)) }, + ) + Text( + text = formatDelayMs(valueMs), + color = if (enabled) Color.White else Color.White.copy(alpha = 0.38f), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + modifier = Modifier.width(96.dp), + ) + SpinnerButton( + label = "+", + enabled = enabled, + onClick = { onChange((valueMs + stepMs).coerceIn(minMs, maxMs)) }, + ) + } } } @Composable -private fun SpinnerButton(label: String, enabled: Boolean = true, onClick: () -> Unit) { - val shape = RoundedCornerShape(8.dp) +private fun SpinnerButton( + label: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { Box( modifier = Modifier - .background(color = Color.White.copy(alpha = if (enabled) 0.10f else 0.04f), shape = shape) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 6.dp), + .size(44.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = if (enabled) 0.10f else 0.04f)) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, ) { Text( text = label, - color = if (enabled) Color.White else Color.White.copy(alpha = 0.35f), - fontSize = 18.sp, + color = if (enabled) Color.White else Color.White.copy(alpha = 0.30f), + fontSize = 20.sp, fontWeight = FontWeight.Medium, ) } } -/** - * Format a delay value as a signed millisecond string. Zero shows as "0 ms"; - * positive values get a leading "+"; negative values get a leading "−" - * (true minus sign, not hyphen — matches iOS). - */ +private fun formatSleepTimerSubtitle(state: SleepTimerState): String = when (state) { + is SleepTimerState.Idle -> "Off" + is SleepTimerState.Active -> "Pausing in ${formatRemaining(state.remainingSeconds)}" +} + +private fun PlayerStatsSnapshot.summaryLabel(): String { + val route = backendRoute ?: backendDisplayName + return listOfNotNull(resolution, route, bitrateBps?.let(::formatStatsBitrate)) + .take(2) + .joinToString(" - ") + .ifBlank { "Waiting for player data" } +} + +private fun isSameSpeed(a: Double, b: Double): Boolean = kotlin.math.abs(a - b) < 0.001 + private fun formatDelayMs(ms: Int): String = when { ms == 0 -> "0 ms" ms > 0 -> "+$ms ms" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt index 80282a638..84e02fcc1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSheetSupport.kt @@ -1,23 +1,51 @@ package org.prairieserver.prairie.android.ui.screens.player +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * Shared behavior for the player's modal bottom sheets. @@ -38,7 +66,134 @@ import androidx.compose.ui.unit.sp * top) because drag deltas pass through untouched. */ @Composable -internal fun playerSheetMaxHeight(): Dp = (LocalConfiguration.current.screenHeightDp * 0.8f).dp +internal fun playerSheetMaxHeight(tabletopPaneHeight: Dp? = null): Dp = + tabletopPaneHeight + ?.let { (it - 32.dp).coerceAtLeast(160.dp) } + ?: (LocalConfiguration.current.screenHeightDp * 0.8f).dp + +/** + * Material bottom sheets are window-level, so a sheet opened from the + * lower-pane player overlay would otherwise expand back across the hinge and + * dim the video. Cap the entire sheet to the measured controls pane and leave + * the upper video pane visually untouched in tabletop posture. + */ +@Composable +internal fun Modifier.tabletopPlayerSheet(tabletopPaneHeight: Dp?): Modifier { + if (tabletopPaneHeight == null) return this + + // ModalBottomSheet is hosted at the window level rather than inside the + // lower-pane PlayerOverlay. Constraining its height alone leaves that host + // at the top of the window on physical foldables, so translate the fixed + // height region to the bottom pane explicitly. This placement is verified + // on-device; Material's internal sheet anchor only moves content within + // the constrained host and does not position the host below the hinge. + val paneTop = (LocalConfiguration.current.screenHeightDp.dp - tabletopPaneHeight) + .coerceAtLeast(0.dp) + return height(tabletopPaneHeight).offset(y = paneTop) +} + +@OptIn(ExperimentalMaterial3Api::class) +internal fun CoroutineScope.dismissPlayerSheet( + sheetState: SheetState, + onDismiss: () -> Unit, + afterDismiss: (() -> Unit)? = null, +) { + launch { + sheetState.hide() + onDismiss() + afterDismiss?.invoke() + } +} + +@Composable +internal fun Modifier.playerSheetContent(tabletopPaneHeight: Dp?): Modifier = + if (tabletopPaneHeight == null) { + heightIn(max = playerSheetMaxHeight()) + } else { + fillMaxHeight() + } + +internal fun playerSheetScrimColor(tabletopPaneHeight: Dp?): Color = + if (tabletopPaneHeight == null) Color.Black.copy(alpha = 0.32f) else Color.Transparent + +internal val PlayerSheetBackground = Color(0xFF090D12) +internal val PlayerSheetCardColor = Color(0xFF151B24) +internal val PlayerSheetSelectedColor = Color(0xFF10333D) +internal val PlayerSheetDividerColor = Color.White.copy(alpha = 0.08f) + +internal fun playerSheetShape(tabletopPaneHeight: Dp?): Shape = + if (tabletopPaneHeight == null) { + RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + } else { + RectangleShape + } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun playerSheetDragHandle(tabletopPaneHeight: Dp?): (@Composable () -> Unit)? = + if (tabletopPaneHeight == null) { + { + BottomSheetDefaults.DragHandle( + color = Color.White.copy(alpha = 0.28f), + ) + } + } else { + null + } + +internal fun playerSheetHorizontalPadding(tabletopPaneHeight: Dp?): Dp = + if (tabletopPaneHeight == null) 20.dp else 24.dp + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun PlayerModalBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + tabletopPaneHeight: Dp?, + content: @Composable ColumnScope.() -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = Modifier.tabletopPlayerSheet(tabletopPaneHeight), + shape = playerSheetShape(tabletopPaneHeight), + dragHandle = playerSheetDragHandle(tabletopPaneHeight), + containerColor = PlayerSheetBackground, + contentColor = Color.White, + scrimColor = playerSheetScrimColor(tabletopPaneHeight), + ) { + KeepPlayerSheetImmersive() + content() + } +} + +/** + * Material hosts [ModalBottomSheet] in its own dialog window. Hiding system + * bars on the player activity therefore does not cover the window that owns + * an open sheet, which lets Samsung restore the status and navigation bars + * while a player menu has focus. Apply the same immersive policy directly to + * the sheet window so every shared player menu behaves like the player. + */ +@Composable +private fun KeepPlayerSheetImmersive() { + val view = LocalView.current + LaunchedEffect(view) { + val sheetWindow = (view as? DialogWindowProvider)?.window + ?: (view.parent as? DialogWindowProvider)?.window + ?: return@LaunchedEffect + WindowCompat.setDecorFitsSystemWindows(sheetWindow, false) + WindowCompat.getInsetsController(sheetWindow, sheetWindow.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } +} + +internal fun formatPlaybackSpeed(speed: Double): String { + if (speed % 1.0 == 0.0) return speed.toInt().toString() + return String.format(Locale.US, "%.2f", speed).trimEnd('0').trimEnd('.') +} internal val PlayerSheetFlingGuard = object : NestedScrollConnection { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = available @@ -53,15 +208,20 @@ internal val PlayerSheetFlingGuard = object : NestedScrollConnection { internal fun PlayerSheetHeader( title: String, onBack: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, + subtitle: String? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding( - start = if (onBack != null) 8.dp else 20.dp, - end = 20.dp, - top = if (onBack != null) 12.dp else 20.dp, - bottom = 8.dp, - ), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 64.dp) + .padding( + start = if (onBack != null) 4.dp else 20.dp, + end = if (onDismiss != null) 4.dp else 20.dp, + top = 6.dp, + bottom = 6.dp, + ), ) { if (onBack != null) { IconButton(onClick = onBack) { @@ -72,11 +232,71 @@ internal fun PlayerSheetHeader( ) } } - Text( - text = title, - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + color = Color.White.copy(alpha = 0.56f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onDismiss != null) { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close menu", + tint = Color.White.copy(alpha = 0.82f), + ) + } + } } } + +@Composable +internal fun PlayerSheetCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(18.dp)) + .background(PlayerSheetCardColor), + content = content, + ) +} + +@Composable +internal fun PlayerSheetSectionLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text.uppercase(), + color = Color.White.copy(alpha = 0.52f), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.8.sp, + modifier = modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp), + ) +} + +@Composable +internal fun PlayerSheetDivider(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .height(1.dp) + .background(PlayerSheetDividerColor), + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt index 36eb5bff3..a9a84228a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.player import org.prairieserver.prairie.common.player.dolbyVisionTransformClassification +import org.prairieserver.prairie.common.player.failureDiagnostics import android.os.SystemClock import android.util.Log @@ -17,6 +18,7 @@ import org.prairieserver.prairie.common.player.FinalPlaybackPositionWriter import org.prairieserver.prairie.common.player.Playability import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.player.PlaybackSessionManager +import org.prairieserver.prairie.common.player.PlaybackTeardownGate import org.prairieserver.prairie.common.player.VideoSessionStartV3 import org.prairieserver.prairie.common.player.cast.CastMediaSpec import org.prairieserver.prairie.common.player.cast.CastPrepareRequest @@ -36,16 +38,21 @@ import org.prairieserver.prairie.common.player.seek.SeekPositionDecision import org.prairieserver.prairie.common.player.seek.decideSeek import org.prairieserver.prairie.common.player.seek.isSameRouteSeekReanchorCandidate import org.prairieserver.prairie.common.player.seek.playerPositionForSource +import org.prairieserver.prairie.common.player.seek.replanMountPositionForSource import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest +import org.prairieserver.prairie.common.player.video.VideoPlayerRouteArgs import org.prairieserver.prairie.common.player.video.VideoPlayerUiState import org.prairieserver.prairie.common.player.video.canPlayResolvedStreamDirectly import org.prairieserver.prairie.common.player.video.resolvedPlaybackDelivery +import org.prairieserver.prairie.common.settings.LetterboxExpansion import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot import org.prairieserver.prairie.domain.player.IntroAutoSkipController import org.prairieserver.prairie.domain.player.IntroAutoSkipState +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.domain.player.settlingFalseEdges import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.VersionChapter @@ -59,9 +66,16 @@ import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.CommittedSubtitle import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle +import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices import org.prairieserver.prairie.model.playback.mergeDownloadedSubtitles import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.model.playback.resolvePlaybackStartPosition +import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes +import org.prairieserver.prairie.playback.PlaybackSubtitleReady +import org.prairieserver.prairie.playback.applyAuthoritativeSubtitleReadyTrack +import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.model.subtitles.SubtitleAiJob import org.prairieserver.prairie.model.subtitles.SubtitleAiQuota import org.prairieserver.prairie.model.subtitles.SubtitleAiStatus @@ -77,6 +91,12 @@ import org.prairieserver.prairie.playback.audioTrackFingerprint import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference import org.prairieserver.prairie.playback.nextEpisodeAfter import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal +import org.prairieserver.prairie.common.player.video.AudioReconcileAction +import org.prairieserver.prairie.common.player.video.DesiredAudio +import org.prairieserver.prairie.common.player.video.LocalAudioSelection +import org.prairieserver.prairie.common.player.video.MountedAudioTrack +import org.prairieserver.prairie.common.player.video.matchMountedAudioTrack +import org.prairieserver.prairie.common.player.video.reconcileDesiredAudioAction import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository @@ -94,6 +114,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -120,8 +141,9 @@ import java.util.concurrent.atomic.AtomicBoolean * * Phase 1: progress reporting + 404/outage recovery is now delegated to * [PlaybackSessionLifecycle]. Per-profile playback preferences are read from - * [PlayerSettingsStore]. Intro auto-skip behavior (countdown ring, cancel, - * one-shot fire) is owned by [IntroAutoSkipController]. + * [PlayerSettingsStore]. The intro-skip prompt (never / ask / always, its + * timer, and which intros the viewer has already decided) is owned by + * [IntroAutoSkipController]. */ /** A transient remote "display_message"; [id] makes repeats re-trigger the toast. */ data class RemoteMessage(val id: Long, val text: String) @@ -177,18 +199,45 @@ internal fun PlayerViewModel.PlayerUiState.withPlaybackClock(clock: PlaybackCloc bufferedPosition = clock.bufferedPosition, ) +/** + * The audio ordinal to send the server, from the picker row that was chosen. + * + * Audio is addressed by ORDINAL into `audio_tracks`. Unlike subtitles, audio + * tracks carry no index on the wire — a probe of the server returns + * `{"title":"English DTS 5.1","language":"en","codec":"dts",...}` with no + * `index`, so [AudioTrack.index] deserialises to its `0` default on every row. + * + * This used to read `audioTracks.getOrNull(ordinal).index`, which therefore + * evaluated to 0 for every track: every explicit audio pick asked the server + * for track 0, so choosing the second language played the first. + */ +/** + * The durable fingerprint for a committed audio choice. + * + * [committedAudioTrackIndex] is an ORDINAL into [audioTracks]. Resolving it + * against `AudioTrack.index` matched nothing for any ordinal above zero -- the + * wire carries no audio index -- so the chosen track was silently never + * persisted and reopening the item lost it. + */ +internal fun mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex: Int?, + audioTracks: List, +): TrackSelectionFingerprintUpdate = committedAudioTrackIndex + ?.let(audioTracks::getOrNull) + ?.let(::audioTrackFingerprint) + ?.let(TrackSelectionFingerprintUpdate::Set) + ?: TrackSelectionFingerprintUpdate.Preserve + internal fun selectedServerAudioTrackIndex( selectedOrdinal: Int, audioTracks: List, -): Int? = audioTracks.getOrNull(selectedOrdinal)?.index +): Int? = selectedOrdinal.takeIf { it in audioTracks.indices } +/** Inverse of [selectedServerAudioTrackIndex]: both are the same ordinal. */ internal fun selectedAudioTrackOrdinal( selectedServerIndex: Int, audioTracks: List, -): Int = audioTracks.indexOfFirst { it.index == selectedServerIndex } - .takeIf { it >= 0 } - ?: selectedServerIndex.takeIf { it in audioTracks.indices } - ?: 0 +): Int = selectedServerIndex.takeIf { it in audioTracks.indices } ?: 0 private fun SubtitleIdentity.serverTrackIndexForMobile(): Int = when (this) { SubtitleIdentity.Off -> -1 @@ -232,6 +281,12 @@ class PlayerViewModel( // Last load request, replayed by the "Can't reach server" Retry / Try Anyway. private var lastLoadArgs: LoadArgs? = null + // Route semantics are separate from resolved playback state. In particular, + // a null file/track means automatic selection and must remain null after the + // first successful resolution; recovery reloads must not turn it explicit. + private val routeIntentState = MobilePlayerRouteIntentState() + private var pendingAuthoritativeSubtitleDownloadId: Int? = null + private val authoritativeSubtitleReadyRows = mutableMapOf, PlayerSubtitleInfo>() private data class LoadArgs( val contentId: String, @@ -243,6 +298,9 @@ class PlayerViewModel( val suppressResumeRewind: Boolean, ) + internal fun currentExternalRouteTarget(): MobilePlayerRouteTarget? = + mobilePlayerRouteTarget(routeIntentState.current, _uiState.value) + companion object { private const val TAG = "PlayerViewModel" const val SERVER_UNREACHABLE_MESSAGE = @@ -312,8 +370,8 @@ class PlayerViewModel( val subtitle: String = "", /** * Artwork URL used for the Now Playing lock-screen / Bluetooth / - * notification surface. Sourced from `WatchDetail.posterUrl` with - * `backdropUrl` fallback. Threaded into MediaItem.MediaMetadata so + * notification surface. Sourced from `WatchDetail.backdropUrl` with + * `posterUrl` fallback. Threaded into MediaItem.MediaMetadata so * the MediaSession publishes it to the OS. Mirrors iOS phone's * `NowPlayingController.setArtworkURL`. */ @@ -354,6 +412,8 @@ class PlayerViewModel( val subtitleApplying: Boolean = false, val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, /** * Chapters from the selected FileVersion (server-extracted via FFprobe * at ingest). Empty list when the file has no embedded chapters. The @@ -429,14 +489,10 @@ class PlayerViewModel( context: MobileSubtitlePlaybackContext, ): Boolean { val writeScope = context.writeScope ?: return false - val audioFingerprint = committed.audioTrackIndex - ?.let { serverIndex -> - context.audioTracks.firstOrNull { it.index == serverIndex } - } - ?.let(::audioTrackFingerprint) - val audioUpdate = audioFingerprint - ?.let(TrackSelectionFingerprintUpdate::Set) - ?: TrackSelectionFingerprintUpdate.Preserve + val audioUpdate = mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = committed.audioTrackIndex, + audioTracks = context.audioTracks, + ) return userItemStatePort.recordTrackSelection( scope = writeScope, contentId = context.contentId, @@ -489,9 +545,18 @@ class PlayerViewModel( /** A server "display_message" to surface transiently; null = nothing. */ val remoteMessage: StateFlow = _remoteMessage.asStateFlow() - /** Intro auto-skip banner state. UI consumes this directly. */ + /** Intro skip pill state. UI consumes this directly. */ val introSkipState: StateFlow = introAutoSkipController.state + /** Bumps whenever the pill's timer (re)starts, so the fill can re-anchor. */ + val introSkipCountdownRun: StateFlow = introAutoSkipController.countdownRun + + /** False while the pill is up but its timer is frozen by a pause. */ + val introSkipTimerRunning: StateFlow = introAutoSkipController.timerRunning + + /** Total seconds a fresh intro prompt runs for, for the fill's arithmetic. */ + val introSkipTotalSeconds: Int = introAutoSkipController.totalCountdownSeconds + /** * Transient player notice (server reconnecting, suspend warnings, etc.) emitted by * [PlaybackSessionLifecycle]. `null` means show nothing. UI consumes this directly. @@ -509,6 +574,11 @@ class PlayerViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, 1.0) val videoGravity: StateFlow = playerSettingsStore.videoGravityFlow .stateIn(viewModelScope, SharingStarted.Eagerly, "fit") + /** Modulates "fit" only: expand past a letterbox that is encoded into the + * picture. Never changes what [videoGravity] itself stores or means. */ + val letterboxExpansion: StateFlow = + playerSettingsStore.letterboxExpansionFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, LetterboxExpansion.Default) // iOS parity (PlayerOrientationCoordinator): the phone player defaults to // landscape-locked; "rotateFreely" is the persisted opt-out written by the // HUD lock toggle. Any other stored value (including the legacy "auto" @@ -526,8 +596,8 @@ class PlayerViewModel( val orientationLockedResolved: StateFlow = playerSettingsStore.orientationModeFlow .map { it != ORIENTATION_MODE_ROTATE_FREELY } .stateIn(viewModelScope, SharingStarted.Eagerly, null) - val autoSkipIntroEnabled: StateFlow = playerSettingsStore.autoSkipIntroFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val introSkipMode: StateFlow = playerSettingsStore.introSkipModeFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, IntroSkipMode.Default) val autoPlayNextEnabled: StateFlow = playerSettingsStore.autoPlayNextFlow .stateIn(viewModelScope, SharingStarted.Eagerly, true) // Seconds before end to surface the Up Next card when no credits marker @@ -621,7 +691,7 @@ class PlayerViewModel( val subtitleAppearance: StateFlow = playerSettingsStore.effectiveSubtitleAppearanceFlow .stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleAppearance.DEFAULT) /** - * Per-profile audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / + * Per-device audio/subtitle delay in ms. Mirrors iOS phone's `audioSyncMs` / * `subtitleSyncMs` (`iosApp/Screens/Player/Sheets/PlayerSettingsSheet.swift:265-285`). * Applied by PrairiePlaybackService via DelayAudioProcessor (audio) and * OffsetSubtitleParserFactory (subtitle); the settings sheet rows write @@ -676,6 +746,29 @@ class PlayerViewModel( private var lifecycleObserverJob: Job? = null private var resolveNextEpisodeJob: Job? = null private val exitPrepared = AtomicBoolean(false) + + /** + * The session this view model owns, kept past the point UI state is cleared. + * + * Teardown happens in two stages — onExit() then onCleared() — and the first + * clears sessionId. Reading ownership from UI state in the second therefore + * yields null, and null means "stop whatever is playing", which after a + * player-to-player navigation is somebody else's session. + */ + private var retainedOwnedSessionId: String? = null + + /** + * Makes this screen's teardown of the process-scoped lifecycle one-shot. + * + * Naming the session is necessary but not sufficient. onExit() runs an + * ordered stop and onCleared() then schedules a detached one for the same + * id; the second passes the lifecycle's ownership guard because the first + * already cleared the owner, and bumps `stopEpoch` on its way through. A + * screen that acquired its start epoch between the two — but has not yet + * adopted its session — is then rejected as superseded. TV has been behind + * this gate since auto-advance broke on exactly that race; phone was not. + */ + private val lifecycleTeardown = PlaybackTeardownGate(sessionLifecycle) private var finalPositionScope: PlaybackWriteScope? = null private val initialPlayerLoadGate = InitialPlayerLoadGate() @@ -729,16 +822,23 @@ class PlayerViewModel( } } viewModelScope.launch { - sessionLifecycle.missingSessionEvents.collect { position -> + sessionLifecycle.missingSessionEvents.collect { renewal -> val state = _uiState.value - if (state.sessionId != null) { + val params = renewal.startParams + if ( + state.sessionId == renewal.staleSessionId && + state.contentId == params.contentId + ) { loadContent( - contentId = state.contentId, - preferredFileId = state.versions.getOrNull(state.selectedVersionIndex)?.fileId, - initialAudioTrackIndex = state.selectedAudioIndex, - initialSubtitleTrackIndex = state.selectedSubtitleIndex, - resumePositionOverride = position, + contentId = params.contentId, + preferredFileId = params.fileId, + preferredQuality = params.qualityPreference, + initialAudioTrackIndex = params.audioTrackIndex, + initialSubtitleTrackIndex = params.subtitleTrackIndex, + resumePositionOverride = renewal.positionSeconds, suppressResumeRewind = true, + preserveRouteIntent = true, + recoveryStartParams = params, ) } } @@ -813,6 +913,11 @@ class PlayerViewModel( initialAudioTrackIndex: Int? = null, initialSubtitleTrackIndex: Int? = null, resumePositionOverride: Double? = null, + // Route provenance is separate from an operational seek/restart + // position. Only PlayerScreen's initial route load supplies this; + // internal auto-advance and recovery positions must not become route + // intent. + routeResumePositionSeconds: Double? = null, // True for Watch Together (the synced anchor must land exactly — no // skip-back nudge). The request's roomId is always null on mobile, so WT // can't be inferred from it the way the TV starter does. @@ -820,19 +925,41 @@ class PlayerViewModel( // Try Anyway escape hatch (issue #33): bypass the pre-play reachability // gate and attempt the server even while it reports unreachable. force: Boolean = false, + // Recovery restarts resolved media in place, but they do not change the + // route-level auto/explicit choices used for deep-link idempotence. + preserveRouteIntent: Boolean = false, + // Exact capability/context snapshot used only for a 404 renewal. + recoveryStartParams: StartParams? = null, ) { + val normalizedPreferredQuality = VideoPlayerRouteArgs.normalizeQuality(preferredQuality) + routeIntentState.beginLoad( + contentId = contentId, + fileId = preferredFileId, + quality = normalizedPreferredQuality, + audioTrackIndex = initialAudioTrackIndex, + subtitleTrackIndex = initialSubtitleTrackIndex, + resumePositionSeconds = VideoPlayerRouteArgs.parseResumePosition( + routeResumePositionSeconds?.toString(), + ), + preserveCurrent = preserveRouteIntent, + ) + val effectivePreferredQuality = routeIntentState.qualityForLoad( + contentId = contentId, + normalizedRequestedQuality = normalizedPreferredQuality, + preserveCurrent = preserveRouteIntent, + ) loadJob?.cancel() val loadOwner = loadOwners.begin( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = preferredQuality, + preferredQuality = effectivePreferredQuality, ) // Remember the exact request so a "Can't reach server" Retry / Try Anyway // can replay it faithfully (this screen has no other retry entry point). lastLoadArgs = LoadArgs( contentId = contentId, preferredFileId = preferredFileId, - preferredQuality = preferredQuality, + preferredQuality = effectivePreferredQuality, initialAudioTrackIndex = initialAudioTrackIndex, initialSubtitleTrackIndex = initialSubtitleTrackIndex, resumePositionOverride = resumePositionOverride, @@ -895,17 +1022,26 @@ class PlayerViewModel( VideoPlaybackStartRequest( contentId = contentId, preferredFileId = preferredFileId, - preferredQualityOverride = preferredQuality, + preferredQualityOverride = effectivePreferredQuality, roomId = null, resumePositionOverride = resumePositionOverride, audioTrackIndex = initialAudioTrackIndex, subtitleTrackIndex = initialSubtitleTrackIndex, suppressResumeRewind = suppressResumeRewind, force = force, + recoveryStartParams = recoveryStartParams, ), )) { is VideoPlayerUiState.Ready -> { unpublishedReadySessionId = playbackState.sessionId + // Retained BEFORE the suspending UI application below. + // The starter has already installed the lifecycle owner + // and started its reporter by this point, so an exit + // during that suspension would otherwise find neither a + // published session nor a retained one — and skipping + // teardown there strands the lifecycle and its reporter + // running for a screen nobody is on. + playbackState.sessionId?.let { retainedOwnedSessionId = it } if (!ownsLoad(loadOwner)) { stopStaleReadySession(playbackState.sessionId) unpublishedReadySessionId = null @@ -916,6 +1052,7 @@ class PlayerViewModel( preferredFileId = preferredFileId, initialAudioTrackIndex = initialAudioTrackIndex, initialSubtitleTrackIndex = initialSubtitleTrackIndex, + isSessionRenewal = recoveryStartParams != null, loadOwner = loadOwner, ) unpublishedReadySessionId = null @@ -990,6 +1127,7 @@ class PlayerViewModel( resumePositionOverride = args.resumePositionOverride, suppressResumeRewind = args.suppressResumeRewind, force = force, + preserveRouteIntent = true, ) } @@ -1011,6 +1149,7 @@ class PlayerViewModel( preferredFileId: Int?, initialAudioTrackIndex: Int?, initialSubtitleTrackIndex: Int?, + isSessionRenewal: Boolean, loadOwner: MobilePlayerLoadOwner, ) { val watchDetail = when (val r = catalogRepository.getWatchDetail(playbackState.contentId)) { @@ -1027,7 +1166,7 @@ class PlayerViewModel( listOf( FileVersion( fileId = fileId, - duration = playbackState.durationSeconds, + duration = playbackState.durationSeconds ?: 0.0, chapters = playbackState.chapters.takeIf { it.isNotEmpty() }, ), ) @@ -1053,19 +1192,26 @@ class PlayerViewModel( // pick can't be matched to the mounted list; it maps an explicit -1 // (deliberate Off from the detail page) to -1, a resolved pick that is // honored and persisted like any other explicit choice. - val requestedSubtitleIndex = initialSubtitleTrackIndex?.let { requested -> - resolveInitialMobileSubtitleOrdinal( - requestedOrdinal = requested, - catalogTracks = version?.subtitleTracks.orEmpty(), - mountedSubtitles = playbackState.subtitleUrls, + val requestedSubtitleIndex = if (isSessionRenewal) { + authoritativePlaybackSubtitleOrdinal( + serverIndex = playbackState.playbackPlan?.selectedTracks?.subtitleIndex, + playbackTracks = playbackState.subtitleUrls, ) + } else { + initialSubtitleTrackIndex?.let { requested -> + resolveInitialMobileSubtitleOrdinal( + requestedOrdinal = requested, + catalogTracks = version?.subtitleTracks.orEmpty(), + mountedSubtitles = playbackState.subtitleUrls, + ) + } } // A RESOLVED explicit pick (including an explicit -1 Off) wins over the // persisted/auto chain. A pick that failed to resolve (null) does NOT // suppress it — otherwise one unmatchable pick would strand playback on // Off and (via onSubtitleSelectionApplied) persist that Off for every // future playback. - val explicitSubtitlePickResolved = requestedSubtitleIndex != null + val explicitSubtitlePickResolved = !isSessionRenewal && requestedSubtitleIndex != null val localTrackSelection = version?.fileId ?.takeIf { initialAudioTrackIndex == null || !explicitSubtitlePickResolved } ?.let { fileId -> userItemStatePort.localTrackSelection(playbackState.contentId, fileId) } @@ -1086,7 +1232,8 @@ class PlayerViewModel( serverUrl = playbackState.serverUrl, persistedPreference = localTrackSelection ?.subtitleFingerprint - ?.takeUnless { explicitSubtitlePickResolved }, + ?.takeUnless { explicitSubtitlePickResolved || isSessionRenewal }, + authoritativeInventory = playbackState.playbackPlan != null, loadDownloadedSubtitles = subtitlesRepository::list, ) if (!ownsLoad(loadOwner)) { @@ -1096,6 +1243,7 @@ class PlayerViewModel( val mountedSubtitles = freshSubtitleRestore.subtitleTracks val persistedSubtitleIndex = freshSubtitleRestore.persistedSelectionOrdinal val autoSubtitleSelection = if ( + !isSessionRenewal && !explicitSubtitlePickResolved && !freshSubtitleRestore.persistedPreferencePresent && persistedSubtitleIndex == null @@ -1122,7 +1270,7 @@ class PlayerViewModel( } ?: -1 val resolvedSubtitleIndex = requestedCommittedSubtitleIndex ?: serverCommittedSubtitleIndex - val deferredSubtitleIdentity = if (requestedCommittedSubtitleIndex == null) { + val deferredSubtitleIdentity = if (!isSessionRenewal && requestedCommittedSubtitleIndex == null) { freshSubtitleRestore.persistedSelectionIdentity ?: when (autoSubtitleSelection) { is MobileSubtitleAutoSelection.Select -> @@ -1146,7 +1294,8 @@ class PlayerViewModel( title = watchDetail?.title ?: playbackState.title, subtitle = watchDetail?.let { detail -> buildSubtitle(detail) } ?: playbackState.subtitle.orEmpty(), artworkUrl = playbackState.artworkUrl, - sessionId = playbackState.sessionId, + sessionId = playbackState.sessionId + ?.also { retainedOwnedSessionId = it }, playMethod = playbackState.playMethod, playbackPlan = playbackState.playbackPlan, requestHeaders = playbackState.requestHeaders, @@ -1158,24 +1307,10 @@ class PlayerViewModel( startPosition = playbackState.startPositionSeconds, mediaMountGeneration = mountGeneration, position = playbackState.sourceStartPositionSeconds, - // Full source runtime is both the scrubber total and the clamp - // ceiling used in onPositionChanged. A server transcode reports - // a SHORT durationSeconds (the seek-to-end window), but player - // positions map into FULL source time — so preferring that short - // value froze the progress bar and squashed chapter offsets on - // transcoded content (e.g. Pixel 9 / Android 16 pushed to server - // transcode where another device direct-plays). Take the LARGER - // of the catalog runtime and the session value so the ceiling is - // never shorter than the real runtime; unchanged for direct play - // where the two already match. - duration = maxOf( - version?.duration ?: 0.0, - playbackState.durationSeconds.takeIf { it > 0.0 } ?: 0.0, - ), - serverDuration = maxOf( - version?.duration ?: 0.0, - playbackState.durationSeconds.takeIf { it > 0.0 } ?: 0.0, - ), + // V3 source duration is authoritative. Zero means the plan did + // not declare one; neither catalog nor Media3 may substitute it. + duration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, + serverDuration = playbackState.durationSeconds?.takeIf { it > 0.0 } ?: 0.0, isPlaying = true, isPaused = false, subtitleTracks = mountedSubtitles, @@ -1184,6 +1319,8 @@ class PlayerViewModel( selectedSubtitleIndex = resolvedSubtitleIndex, intro = playbackState.intro, credits = playbackState.credits, + recap = playbackState.recap, + preview = playbackState.preview, chapters = playbackState.chapters.ifEmpty { version?.chapters.orEmpty() }, versions = versions, selectedVersionIndex = versionIndex, @@ -1225,12 +1362,18 @@ class PlayerViewModel( ?.takeIf { it != committedIdentity } ?.let(mobileSubtitleTransactions::select) - if ( - persistedAudioIndex != null && - persistedAudioIndex != selectedAudioOrdinal && - persistedAudioIndex in _uiState.value.audioTracks.indices - ) { - onSelectAudio(persistedAudioIndex) + // Seeded whether or not it differs from what the server reported. + // Equality with the plan is not evidence the RENDERER is on that + // track: a direct-play file mounts every track and Media3 picks its + // own default, which is precisely the case this exists for. + val restoreOrdinal = persistedAudioIndex + ?: initialAudioTrackIndex + ?: selectedAudioOrdinal + if (restoreOrdinal in _uiState.value.audioTracks.indices) { + setDesiredAudio(restoreOrdinal, explicit = false) + if (restoreOrdinal != selectedAudioOrdinal) { + selectAudio(restoreOrdinal, userInitiated = false) + } } } if (!published) { @@ -1257,7 +1400,7 @@ class PlayerViewModel( introRange = _uiState .map { it.intro } .distinctUntilChanged(), - autoSkipEnabled = playerSettingsStore.autoSkipIntroFlow, + mode = playerSettingsStore.introSkipModeFlow, introKey = _uiState .map { state -> state.intro?.let { intro -> @@ -1266,7 +1409,17 @@ class PlayerViewModel( } } .distinctUntilChanged(), - onAutoSkipFire = { seekToSec -> onSeek(seekToSec) }, + onSeek = { seekToSec -> onSeek(seekToSec) }, + // Filtered, not raw: isPlaying dips for a rebuffer exactly as it + // does for a deliberate pause, and a pause that reaches the + // controller freezes the timer. isPaused is the viewer's own press + // and needs no filtering, so it freezes on the frame of the press. + playbackActive = _uiState + .map { it.isPlaying && !it.isLoading } + .settlingFalseEdges( + graceMillis = PLAYBACK_PAUSE_GRACE_MS, + deliberatelyInactive = _uiState.map { it.isPaused }, + ), ) } @@ -1319,7 +1472,12 @@ class PlayerViewModel( return } - startProtocolV3Replan(reason.failureClassification(), notice, state) + startProtocolV3Replan( + classification = reason.failureClassification(), + notice = notice, + state = state, + diagnostics = reason.failureDiagnostics(), + ) } /** @@ -1523,6 +1681,7 @@ class PlayerViewModel( formFactor = "mobile", appVersion = BuildConfig.VERSION_NAME, dolbyVision = dolbyVision, + capabilities = capabilities, ) val result = playbackSessionManager.replanActiveVideoSession( classification = classification, @@ -1534,29 +1693,56 @@ class PlayerViewModel( capabilities = capabilities, clientPlaybackContext = playbackContext, ) + // Returning on a stale generation is not enough on its own. By the + // time this call returns, the manager has already committed and + // taken ownership of the replacement session — so dropping the + // result quietly leaves a transcode running on the server that + // nothing will ever stop. The viewer sees playback exit; the server + // holds the stream slot until it times out. Release it when the + // generation moved on, the way TV already does; the adoption below + // owns the cancellation windows past this point. + val abandonedSessionId = (result as? ApiResult.Success) + ?.data + ?.let { it as? VideoSessionStartV3.Ready } + ?.session + ?.sessionId + if (!isActive || recoveryGeneration != playbackRecoveryGeneration) { + // Released on the manager's own scope, which outlives this + // screen: the whole point is to run after the reason for + // abandoning, and this ViewModel's scope may already be gone. + abandonedSessionId?.let(playbackSessionManager::abandonActiveVideoSessionAsync) + } + currentCoroutineContext().ensureActive() if (recoveryGeneration != playbackRecoveryGeneration) return@launch when (result) { is ApiResult.Success -> when (val decision = result.data) { is VideoSessionStartV3.Ready -> { - sessionLifecycle.adoptActiveSession( - params = StartParams( - contentId = state.contentId, - fileId = fileId, - capabilities = capabilities, - audioTrackIndex = decision.session.audioTrackIndex, - subtitleTrackIndex = selectedSubtitleTrackIndex, - startPosition = decision.session.position, - ), - session = decision.session, - renewMissingSessionWithLegacyStart = false, + val remountPosition = decision.plan.timeline + .replanMountPositionForSource(state.position) + val effectiveFileId = decision.session.mediaFileId.takeIf { it > 0 } + ?: decision.plan.effectiveMediaFileId + ?: fileId + val catalogVersionIndex = state.versions + .indexOfFirst { it.fileId == effectiveFileId } + val effectiveVersions = if (catalogVersionIndex >= 0) { + state.versions + } else { + state.versions + FileVersion(fileId = effectiveFileId) + } + val effectiveVersionIndex = catalogVersionIndex + .takeIf { it >= 0 } + ?: effectiveVersions.lastIndex + val effectiveVersion = effectiveVersions[effectiveVersionIndex] + val returnedSubtitleIndex = decision.plan.resolvedSelectedSubtitleIndex() + val authoritativeSubtitles = enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks = effectiveVersion?.subtitleTracks.orEmpty(), + plannedTracks = decision.session.subtitleUrls.orEmpty(), ) - if (recoveryGeneration != playbackRecoveryGeneration) return@launch - val mountGeneration = expectNextMediaMount() - _uiState.update { current -> - val downloaded = current.subtitleTracks - .filter { - it.downloadId != null || - it.source.equals("downloaded", ignoreCase = true) + val downloaded = if (effectiveFileId == fileId) { + state.subtitleTracks + .filter(PlayerSubtitleInfo::isLocalDownloadedSubtitle) + .filterNot { local -> + authoritativeSubtitles.any { it.index == local.index } } .map { track -> track.copy( @@ -1566,41 +1752,145 @@ class PlayerViewModel( ), ) } - val recoveredSubtitles = decision.session.subtitleUrls - .orEmpty() - .filterNot { - it.downloadId != null || - it.source.equals("downloaded", ignoreCase = true) - } + downloaded + } else { + emptyList() + } + val recoveredSubtitles = authoritativeSubtitles + downloaded + val returnedSubtitleOrdinal = returnedSubtitleIndex?.let { serverIndex -> + recoveredSubtitles.indexOfFirst { it.index == serverIndex }.takeIf { it >= 0 } + } ?: -1 + val returnedSubtitleIdentity = recoveredSubtitles + .getOrNull(returnedSubtitleOrdinal) + ?.let(::mobileSubtitleIdentity) + ?: SubtitleIdentity.Off + val returnedAudioIndex = decision.plan.selectedTracks.audio?.index + ?: decision.session.audioTrackIndex + // Conditional adoption, evaluated inside the lifecycle + // lock: an unconditional adopt can hand the lifecycle a + // session this screen has already stopped owning, and + // then the manager owns the replacement while the + // lifecycle still owns its predecessor and the UI owns + // neither. + var adopted = false + try { + adopted = sessionLifecycle.adoptActiveSessionIfCurrent( + params = StartParams( + contentId = state.contentId, + fileId = effectiveFileId, + capabilities = decision.capabilities, + audioTrackIndex = returnedAudioIndex, + subtitleTrackIndex = returnedSubtitleIndex ?: -1, + qualityPreference = currentMobileQualityPreference(), + startPosition = decision.session.position, + clientPlaybackContext = decision.clientPlaybackContext, + ), + session = decision.session, + isCurrent = { + recoveryGeneration == playbackRecoveryGeneration && isActive + }, + ) + } finally { + // Covers refusal AND cancellation while waiting for + // the lifecycle mutex, which throws before isCurrent + // ever runs. NonCancellable because the usual reason + // for being here is that this coroutine was + // cancelled, and a cancelled coroutine cannot make + // the call that releases the server's stream slot — + // runCatching would only swallow the failure. + if (!adopted) { + withContext(NonCancellable) { + runCatching { + playbackSessionManager.stopSession( + decision.session.sessionId, + ) + } + } + } + } + if (!adopted) return@launch + // Ownership moved at adoption, so the exit token moves + // with it — and both exit routes read this token ahead + // of UI state precisely so this write wins. Publishing + // it only in the UI update below leaves a cancellation + // in between with the lifecycle owning the replacement + // while exit still names the predecessor, and the + // lifecycle then correctly refuses to stop it. + retainedOwnedSessionId = decision.session.sessionId + currentCoroutineContext().ensureActive() + if (recoveryGeneration != playbackRecoveryGeneration) return@launch + val mountGeneration = expectNextMediaMount() + _uiState.update { current -> current.copy( error = null, - sessionId = decision.session.sessionId, + sessionId = decision.session.sessionId + .also { retainedOwnedSessionId = it }, playMethod = decision.session.playMethod, playbackPlan = decision.session.playbackPlan, delivery = decision.plan.delivery, streamUrl = decision.plan.stream.url, requestHeaders = decision.plan.stream.headers, - container = decision.plan.stream.container ?: current.container, - startPosition = decision.plan.timeline.playerStartSeconds, + container = decision.plan.stream.container + ?: effectiveVersion?.container + ?: current.container.takeIf { effectiveFileId == fileId }, + startPosition = remountPosition.playerPositionSeconds, mediaMountGeneration = mountGeneration, + versions = effectiveVersions, + selectedVersionIndex = effectiveVersionIndex, subtitleTracks = recoveredSubtitles, - position = decision.plan.timeline.sourceStartSeconds - .takeIf { it.isFinite() && it >= 0.0 } - ?: current.position, + selectedSubtitleIndex = returnedSubtitleOrdinal, + committedSubtitleIdentity = returnedSubtitleIdentity, + audioTracks = effectiveVersion?.audioTracks.orEmpty(), + selectedAudioIndex = selectedAudioTrackOrdinal( + returnedAudioIndex, + effectiveVersion?.audioTracks.orEmpty(), + ), + duration = decision.session.durationSeconds ?: 0.0, + serverDuration = decision.session.durationSeconds ?: 0.0, + chapters = effectiveVersion?.chapters.orEmpty(), + position = remountPosition.sourcePositionSeconds, ) } + Log.i( + TAG, + "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + + "player_seconds=${remountPosition.playerPositionSeconds}", + ) val recoveredState = _uiState.value - mobileSubtitleTransactions.updatePlaybackContext( - mobileSubtitleContext(recoveredState), + mobileSubtitleTransactions.resetContent( + context = mobileSubtitleContext(recoveredState), + committedIdentity = returnedSubtitleIdentity, ) mobileSubtitleTransactions.restoreCommittedLocalMount() } - is VideoSessionStartV3.Terminal -> _uiState.update { - it.copy( - error = "Playback unavailable (${decision.reason}): ${decision.message}", - isLoading = false, - isBuffering = false, + is VideoSessionStartV3.Terminal -> { + val failedSessionId = state.sessionId ?: return@launch + val terminalMessage = + "Playback unavailable (${decision.reason}): ${decision.message}" + val terminalStillCurrent = sessionLifecycle.stopTerminalSessionIfCurrent( + expectedSessionId = failedSessionId, + isCurrent = { + recoveryGeneration == playbackRecoveryGeneration && + _uiState.value.sessionId == failedSessionId + }, ) + if (!terminalStillCurrent) { + return@launch + } + retainedOwnedSessionId = null + _uiState.update { + it.copy( + error = terminalMessage, + isLoading = false, + isBuffering = false, + isPlaying = false, + isPaused = true, + sessionId = null, + playMethod = null, + playbackPlan = null, + delivery = null, + streamUrl = null, + ) + } } VideoSessionStartV3.ServerUpgradeRequired -> _uiState.update { it.copy( @@ -1780,11 +2070,15 @@ class PlayerViewModel( val rawBufferedSec = bufferedPositionMs / 1000.0 val mappedPositionSec = (timeline?.sourcePositionForPlayer(rawPositionSec) ?: rawPositionSec) .let { position -> serverDuration?.let { position.coerceAtMost(it) } ?: position } - val mappedDurationSec = if (durationMs > 0) { + val mappedDurationSec = if (currentState.playbackPlan != null) { + // V3 forbids substituting a stream-local engine duration when the + // plan omitted source.duration_seconds. + serverDuration ?: 0.0 + } else if (durationMs > 0) { timeline?.sourcePositionForPlayer(rawDurationSec) ?: rawDurationSec } else { 0.0 - }.let { duration -> serverDuration?.let { duration.coerceAtMost(it) } ?: duration } + } val mappedBufferedSec = (timeline?.sourcePositionForPlayer(rawBufferedSec) ?: rawBufferedSec) .let { position -> serverDuration?.let { position.coerceAtMost(it) } ?: position } val nowMs = SystemClock.elapsedRealtime() @@ -1827,8 +2121,8 @@ class PlayerViewModel( _uiState.update { state -> state.copy( position = positionSec, - // Grow-only: an engine report may extend an unknown runtime (a - // growing transcode window) but never shrink a known one. + // Offline playback may learn a runtime from Media3. V3's value + // above is always the server-declared duration or unknown (0). duration = maxOf(state.duration, durationSec), bufferedPosition = bufferedSec, ) @@ -1858,6 +2152,7 @@ class PlayerViewModel( positionSec = positionSec, durationSec = _uiState.value.duration, isPaused = _uiState.value.isPaused, + expectedSessionId = _uiState.value.sessionId, ) // Track B: durably record the position (local resume + outbox sync) for @@ -2442,21 +2737,52 @@ class PlayerViewModel( val sourcePosition = decision.plan.timeline.sourceStartSeconds .takeIf { it.isFinite() && it >= 0.0 } ?: request.targetSourceSec + val catalogVersion = before.versions.getOrNull(before.selectedVersionIndex) + val authoritativeSubtitles = enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks = catalogVersion?.subtitleTracks.orEmpty(), + plannedTracks = decision.session.subtitleUrls.orEmpty(), + ) + val returnedSubtitleIndex = decision.plan.resolvedSelectedSubtitleIndex() + val returnedSubtitleOrdinal = returnedSubtitleIndex?.let { serverIndex -> + authoritativeSubtitles.indexOfFirst { it.index == serverIndex }.takeIf { it >= 0 } + } ?: -1 + val returnedSubtitleIdentity = authoritativeSubtitles + .getOrNull(returnedSubtitleOrdinal) + ?.let(::mobileSubtitleIdentity) + ?: SubtitleIdentity.Off + val returnedAudioIndex = decision.plan.selectedTracks.audio?.index + ?: decision.session.audioTrackIndex seekRecoveryRollbackInvalidated = false - sessionLifecycle.adoptActiveSession( + // Conditional, evaluated inside the lifecycle lock. An unconditional + // adopt only checks currency before and after, so a seek superseded + // while this awaited the lock still handed the lifecycle a session this + // screen had stopped owning. + val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = before.contentId, - fileId = fileId, - capabilities = capabilityDetector.detect( - dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot(), - ), - audioTrackIndex = decision.session.audioTrackIndex, - subtitleTrackIndex = before.selectedSubtitleIndex, + fileId = actualFileId, + capabilities = decision.capabilities, + audioTrackIndex = returnedAudioIndex, + subtitleTrackIndex = returnedSubtitleIndex ?: -1, + qualityPreference = currentMobileQualityPreference(), startPosition = sourcePosition, + clientPlaybackContext = decision.clientPlaybackContext, ), - session = decision.session, - renewMissingSessionWithLegacyStart = false, + session = decision.session.copy(subtitleUrls = authoritativeSubtitles), + isCurrent = { isCurrentServerSeek(request, recoveryGeneration) }, ) + // Deliberately no stop on refusal, unlike the replan paths. A seek + // re-anchor is validated to reuse the SAME session id — the manager + // rejects any response that changes it — so there is no disposable + // candidate here. The id names the session still playing, and the + // ordinary reason for refusal is that a newer seek was queued, which + // needs that very session as its base. + if (!adopted) return + // Same rule as the other two adoption paths: the exit token names what + // the lifecycle owns, from the moment it owns it. Supersession or + // cancellation before the UI publication below would otherwise leave + // exit naming the predecessor and the replacement running. + retainedOwnedSessionId = decision.session.sessionId if (!isCurrentServerSeek(request, recoveryGeneration)) return currentCoroutineContext().ensureActive() val mountGeneration = expectNextMediaMount() @@ -2464,7 +2790,8 @@ class PlayerViewModel( current.copy( error = null, isBuffering = false, - sessionId = decision.session.sessionId, + sessionId = decision.session.sessionId + .also { retainedOwnedSessionId = it }, playMethod = decision.session.playMethod, playbackPlan = decision.session.playbackPlan, delivery = decision.plan.delivery, @@ -2475,8 +2802,21 @@ class PlayerViewModel( mediaMountGeneration = mountGeneration, position = sourcePosition, bufferedPosition = sourcePosition, + subtitleTracks = authoritativeSubtitles, + selectedSubtitleIndex = returnedSubtitleOrdinal, + committedSubtitleIdentity = returnedSubtitleIdentity, + selectedAudioIndex = selectedAudioTrackOrdinal( + returnedAudioIndex, + current.audioTracks, + ), ) } + val recoveredState = _uiState.value + mobileSubtitleTransactions.resetContent( + context = mobileSubtitleContext(recoveredState), + committedIdentity = returnedSubtitleIdentity, + ) + mobileSubtitleTransactions.restoreCommittedLocalMount() } // ---- Remote-control adapters (PlaybackRealtimeController calls these) ---- @@ -2555,6 +2895,7 @@ class PlayerViewModel( title = state.title, posterUrl = state.artworkUrl, appVersion = BuildConfig.VERSION_NAME, + buildIdentity = capabilityDetector.buildIdentity, ), ) } @@ -2572,13 +2913,13 @@ class PlayerViewModel( } /** - * Adopt server-recomputed intro/credits ranges (a `markers_updated` event). + * Adopt server-recomputed marker ranges (a `markers_updated` event). * The intro auto-skip observer and the credits-based F2 trigger read these * from UiState, so updating them takes effect immediately. Passing `null` * clears a marker the server says no longer applies. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?) { - _uiState.update { it.copy(intro = intro, credits = credits) } + fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } private fun mobileSubtitleContext(state: PlayerUiState): MobileSubtitlePlaybackContext = @@ -2595,12 +2936,15 @@ class PlayerViewModel( ?.audioTracks .orEmpty(), ), - qualityPreference = null, + qualityPreference = currentMobileQualityPreference(), subtitleTracks = state.subtitleTracks, audioTracks = state.audioTracks, writeScope = finalPositionScope, ) + private fun currentMobileQualityPreference(): String? = + routeIntentState.current?.quality ?: lastLoadArgs?.preferredQuality + private fun applyMobileSubtitleSnapshot(snapshot: MobileSubtitleTransactionSnapshot) { _uiState.update { state -> state.copy( @@ -2617,6 +2961,14 @@ class PlayerViewModel( subtitleApplying = snapshot.subtitleApplying, ) } + val state = _uiState.value + routeIntentState.applyCommittedTracks( + contentId = state.contentId, + committedAudioServerIndex = snapshot.transition.committed.audioTrackIndex, + committedSubtitleIdentity = snapshot.committedIdentity, + transactionFailed = snapshot.failureMessage != null, + transactionActive = mobileSubtitleTransactions.hasActiveTransaction, + ) snapshot.failureMessage?.let { showVersionSwitchMessage("Couldn't apply subtitles — playback continues unchanged.") } @@ -2632,33 +2984,67 @@ class PlayerViewModel( val committed = adoption.committed val ready = playback.ready ?: return MobileSubtitleAdoptionResult.Adopted val before = _uiState.value - val fileId = before.mediaFileId ?: return MobileSubtitleAdoptionResult.Superseded - val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() - val capabilities = capabilityDetector.detect(dolbyVision = dolbyVision) - val playbackContext = capabilityDetector.detectPlaybackContext( - formFactor = "mobile", - appVersion = BuildConfig.VERSION_NAME, - dolbyVision = dolbyVision, + val predecessorFileId = before.mediaFileId + ?: return MobileSubtitleAdoptionResult.Superseded + val effectiveFileId = ready.session.mediaFileId.takeIf { it > 0 } + ?: ready.plan.effectiveMediaFileId + ?: predecessorFileId + val catalogVersionIndex = before.versions.indexOfFirst { it.fileId == effectiveFileId } + val effectiveVersions = if (catalogVersionIndex >= 0) { + before.versions + } else { + before.versions + FileVersion(fileId = effectiveFileId) + } + val effectiveVersionIndex = catalogVersionIndex.takeIf { it >= 0 } + ?: effectiveVersions.lastIndex + val effectiveVersion = effectiveVersions[effectiveVersionIndex] + val authoritativeSubtitles = enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks = effectiveVersion.subtitleTracks.orEmpty(), + plannedTracks = playback.subtitleTracks.filterNot( + PlayerSubtitleInfo::isLocalDownloadedSubtitle, + ), ) - val sourcePosition = ready.plan.timeline.sourceStartSeconds - .takeIf { it.isFinite() && it >= 0.0 } - ?: before.position + val downloaded = if (effectiveFileId == predecessorFileId) { + playback.subtitleTracks + .filter(PlayerSubtitleInfo::isLocalDownloadedSubtitle) + .filterNot { local -> + authoritativeSubtitles.any { it.index == local.index } + } + } else { + emptyList() + } + val effectiveSubtitles = authoritativeSubtitles + downloaded + val returnedAudioIndex = ready.plan.selectedTracks.audio?.index + ?: ready.session.audioTrackIndex + val returnedSubtitleIndex = ready.plan.resolvedSelectedSubtitleIndex() + val remountPosition = ready.plan.timeline.replanMountPositionForSource( + adoption.requestedSourcePositionSeconds, + ) + val sourcePosition = remountPosition.sourcePositionSeconds if (!adoption.isCurrent()) return MobileSubtitleAdoptionResult.Superseded val lifecycleAdopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = before.contentId, - fileId = fileId, - capabilities = capabilities, - audioTrackIndex = committed.audioTrackIndex, - subtitleTrackIndex = committed.identity.serverTrackIndexForMobile(), + fileId = effectiveFileId, + capabilities = ready.capabilities, + audioTrackIndex = returnedAudioIndex, + subtitleTrackIndex = returnedSubtitleIndex ?: -1, qualityPreference = committed.qualityPreference, startPosition = sourcePosition, - clientPlaybackContext = playbackContext, + clientPlaybackContext = ready.clientPlaybackContext, ), - session = ready.session.copy(subtitleUrls = playback.subtitleTracks), - renewMissingSessionWithLegacyStart = false, + session = ready.session.copy(subtitleUrls = effectiveSubtitles), isCurrent = adoption::isCurrent, ) + // The lifecycle owns this session from here, so the exit token has to + // name it from here — not from the UI publication below. Supersession + // between the two abandons the manager's session without rolling the + // lifecycle back, and exit would otherwise name the predecessor, be + // rightly refused by the ownership guard, and leave the replacement + // running with the teardown gate stopping onCleared from retrying. + if (lifecycleAdopted) { + playback.sessionId?.let { retainedOwnedSessionId = it } + } if (!lifecycleAdopted || !adoption.isCurrent()) { return MobileSubtitleAdoptionResult.Superseded } @@ -2668,31 +3054,46 @@ class PlayerViewModel( _uiState.update { current -> current.copy( error = null, - sessionId = playback.sessionId, + sessionId = playback.sessionId + ?.also { retainedOwnedSessionId = it }, playMethod = ready.session.playMethod, playbackPlan = ready.session.playbackPlan, delivery = ready.plan.delivery, streamUrl = ready.plan.stream.url, requestHeaders = ready.plan.stream.headers, - container = ready.plan.stream.container ?: current.container, - startPosition = ready.plan.timeline.playerStartSeconds, + container = ready.plan.stream.container + ?: effectiveVersion.container + ?: current.container.takeIf { effectiveFileId == predecessorFileId }, + startPosition = remountPosition.playerPositionSeconds, mediaMountGeneration = mountGeneration, + versions = effectiveVersions, + selectedVersionIndex = effectiveVersionIndex, position = sourcePosition, - subtitleTracks = playback.subtitleTracks, - selectedAudioIndex = committed.audioTrackIndex - ?.let { selectedAudioTrackOrdinal(it, current.audioTracks) } - ?: current.selectedAudioIndex, + duration = ready.session.durationSeconds ?: 0.0, + serverDuration = ready.session.durationSeconds ?: 0.0, + subtitleTracks = effectiveSubtitles, + audioTracks = effectiveVersion.audioTracks.orEmpty(), + selectedAudioIndex = selectedAudioTrackOrdinal( + returnedAudioIndex, + effectiveVersion.audioTracks.orEmpty(), + ), selectedSubtitleIndex = resolveMobileSubtitleOrdinal( committed.identity, - playback.subtitleTracks, + effectiveSubtitles, ) ?: current.selectedSubtitleIndex, committedSubtitleIdentity = committed.identity, + chapters = effectiveVersion.chapters.orEmpty(), pendingSubtitleIdentity = pendingIdentity, localSubtitleMountIdentity = null, subtitleApplying = pendingIdentity != null, subtitleRefreshNonce = 0, ) } + Log.i( + TAG, + "subtitle_replan_mount restored_source_seconds=$sourcePosition " + + "player_seconds=${remountPosition.playerPositionSeconds}", + ) return MobileSubtitleAdoptionResult.Adopted } @@ -2707,6 +3108,7 @@ class PlayerViewModel( initialSubtitleTrackIndex = state.selectedSubtitleIndex, resumePositionOverride = state.position, suppressResumeRewind = true, + preserveRouteIntent = true, ) Log.w(TAG, "Subtitle committed-playback adoption failed: $detail") } @@ -2719,6 +3121,11 @@ class PlayerViewModel( .getOrNull(index) ?.let(::mobileSubtitleIdentity) ?: SubtitleIdentity.Off + routeIntentState.beginSubtitleSelection( + contentId = state.contentId, + routeOrdinal = catalogSubtitleRouteOrdinal(state, identity), + identity = identity, + ) mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) mobileSubtitleTransactions.select(identity) } @@ -2738,13 +3145,185 @@ class PlayerViewModel( } /** Select an audio track (may require server-side switch). */ - fun onSelectAudio(index: Int) { + fun onSelectAudio(index: Int) = selectAudio(index, userInitiated = true) + + private fun selectAudio(index: Int, userInitiated: Boolean) { val state = _uiState.value val serverIndex = selectedServerAudioTrackIndex(index, state.audioTracks) ?: return + if (userInitiated) { + routeIntentState.beginAudioSelection( + contentId = state.contentId, + routeOrdinal = index, + serverIndex = serverIndex, + ) + } + setDesiredAudio(serverIndex, explicit = userInitiated) + // Already in the mounted stream: switch it on the player instead of + // rebuilding the session to deliver audio already being received. A + // replan is only needed when the track is genuinely absent. + if (matchMountedAudioTrack( + state.audioTracks[serverIndex], + mountedAudio, + ) != null + ) { + return + } mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) mobileSubtitleTransactions.selectAudio(serverIndex) } + // ---- Desired audio ------------------------------------------------------ + // + // Mirrors TV: one generation-owned intent that every entry point writes, + // reconciled against each track snapshot by the shared decision. + + private var desiredAudioGeneration = 0L + private var desiredAudio: DesiredAudio? = null + private var localAudioAttempt = 0L + private var mountedAudio: List = emptyList() + + private val _pendingLocalAudioSelection = MutableStateFlow(null) + + /** A mounted track PlayerScreen should select directly on the player. */ + val pendingLocalAudioSelection: StateFlow = + _pendingLocalAudioSelection.asStateFlow() + + private fun setDesiredAudio(catalogOrdinal: Int, explicit: Boolean) { + desiredAudioGeneration += 1 + localAudioAttemptCount = 0 + val state = _uiState.value + desiredAudio = DesiredAudio( + generation = desiredAudioGeneration, + catalogOrdinal = catalogOrdinal, + explicit = explicit, + fileId = state.mediaFileId, + ) + _pendingLocalAudioSelection.value = null + reconcileDesiredAudio(mountedAudio, selectedMountedAudioOrdinal) + } + + private var selectedMountedAudioOrdinal: Int? = null + + /** Called by PlayerScreen on every Media3 track snapshot. */ + fun onMountedAudioChanged(mounted: List, selectedOrdinal: Int?) { + mountedAudio = mounted + selectedMountedAudioOrdinal = selectedOrdinal + reconcileDesiredAudio(mounted, selectedOrdinal) + } + + private fun reconcileDesiredAudio(mounted: List, selectedOrdinal: Int?) { + val desired = desiredAudio ?: return + val state = _uiState.value + when ( + val action = reconcileDesiredAudioAction( + desired = desired, + activeFileId = state.mediaFileId, + catalog = state.audioTracks, + mounted = mounted, + selectedOrdinal = selectedOrdinal, + planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, + ) + ) { + AudioReconcileAction.None -> Unit + + AudioReconcileAction.DropForeignFile -> { + desiredAudio = null + _pendingLocalAudioSelection.value = null + } + + AudioReconcileAction.Confirm -> { + _pendingLocalAudioSelection.value = null + if (!desired.confirmed) { + desiredAudio = desired.copy(confirmed = true) + commitLocalAudio(desired) + } + } + + is AudioReconcileAction.Apply -> { + // AudioTrackManager returns Unit and does nothing silently when + // the group has gone, and a no-op produces no callback -- so an + // unbounded local path can dead-end with the audio never + // applied. After a few snapshots that still have not taken, hand + // it to the server instead of retrying forever. + if (localAudioAttemptsFor(desired.generation) >= MAX_LOCAL_AUDIO_ATTEMPTS) { + _pendingLocalAudioSelection.value = null + replanForDesiredAudio(desired) + return + } + localAudioAttempt += 1 + localAudioAttemptGeneration = desired.generation + localAudioAttemptCount += 1 + if (desired.confirmed) desiredAudio = desired.copy(confirmed = false) + _pendingLocalAudioSelection.value = LocalAudioSelection( + generation = desired.generation, + catalogOrdinal = desired.catalogOrdinal, + targetOrdinal = action.targetOrdinal, + attempt = localAudioAttempt, + ) + } + } + } + + /** + * Publishes a locally-applied switch as committed state. + * + * A replan commit reaches all of this through + * [applyMobileSubtitleSnapshot]; the local path bypasses the transaction + * entirely, so without this the picker keeps the old checkmark, route + * redelivery reports the old ordinal, and a later recovery, Cast handoff or + * subtitle transaction starts from stale audio. + */ + private fun commitLocalAudio(desired: DesiredAudio) { + _uiState.update { it.copy(selectedAudioIndex = desired.catalogOrdinal) } + val state = _uiState.value + routeIntentState.applyCommittedTracks( + contentId = state.contentId, + committedAudioServerIndex = desired.catalogOrdinal, + committedSubtitleIdentity = state.committedSubtitleIdentity, + transactionFailed = false, + transactionActive = mobileSubtitleTransactions.hasActiveTransaction, + ) + // The reducer's committed audio is what the next subtitle transaction + // stages and what teardown persists, so it has to move too -- updating + // the context alone left it stale and the choice got undone. + mobileSubtitleTransactions.commitLocallyAppliedAudio(desired.catalogOrdinal) + if (desired.explicit) persistDesiredAudio(desired.catalogOrdinal) + } + + private var localAudioAttemptGeneration = 0L + private var localAudioAttemptCount = 0 + + private fun localAudioAttemptsFor(generation: Long): Int = + if (localAudioAttemptGeneration == generation) localAudioAttemptCount else 0 + + /** The local switch is not taking; let the server materialise the track. */ + private fun replanForDesiredAudio(desired: DesiredAudio) { + val state = _uiState.value + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) + mobileSubtitleTransactions.selectAudio(desired.catalogOrdinal) + } + + private fun persistDesiredAudio(catalogOrdinal: Int) { + val state = _uiState.value + val context = mobileSubtitleContext(state) + val scope = context.writeScope ?: return + viewModelScope.launch { + runCatching { + userItemStatePort.recordTrackSelection( + scope = scope, + contentId = context.contentId, + fileId = context.mediaFileId, + audioUpdate = mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = catalogOrdinal, + audioTracks = context.audioTracks, + ), + // Untouched: this path changed audio only. + subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, + ) + } + } + } + // ---- Subtitle suite: search / download / AI translate ----------------------- /** @@ -2835,12 +3414,67 @@ class PlayerViewModel( viewModelScope.launch { doRefreshSubtitles(autoSelectSubtitleId) } } + /** Applies the exact inventory row minted by the V3 server. */ + fun applySubtitleReady(update: PlaybackSubtitleReady) { + val state = _uiState.value + val sessionId = state.sessionId ?: return + if (update.sessionId != null && update.sessionId != sessionId) return + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return + val merged = applyAuthoritativeSubtitleReadyTrack(state.subtitleTracks, update) + if (merged == null) { + startProtocolV3Replan( + classification = "subtitle_inventory_changed", + notice = "Subtitle inventory changed. Refreshing playback metadata.", + state = state, + ) + return + } + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) + val owner = mobileSubtitleTransactions.beginRefresh() + if (!mobileSubtitleTransactions.ownsRefresh(owner)) return + _uiState.update { + it.copy( + subtitleTracks = merged, + subtitleRefreshNonce = it.subtitleRefreshNonce + 1, + ) + } + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(_uiState.value)) + val subtitleId = update.subtitleId + val added = update.track?.trackId?.let { trackId -> + merged.singleOrNull { it.serverTrackId == trackId } + } + if (subtitleId != null && added != null) { + authoritativeSubtitleReadyRows[sessionId to subtitleId] = added + } + if (subtitleId != null && pendingAuthoritativeSubtitleDownloadId == subtitleId) { + val selected = added + ?.let(::mobileSubtitleIdentity) + ?.let { mobileSubtitleTransactions.selectFromRefresh(owner, it) } + ?: false + if (selected) pendingAuthoritativeSubtitleDownloadId = null + } + } + private suspend fun doRefreshSubtitles(autoSelectSubtitleId: Int?) { val state = _uiState.value val mediaFileId = state.mediaFileId ?: return // Inert without a remote session (offline/local playback has no // session-scoped subtitle URLs to merge into). val sessionId = state.sessionId ?: return + if (state.playbackPlan != null) { + pendingAuthoritativeSubtitleDownloadId = autoSelectSubtitleId + val readyRow = autoSelectSubtitleId?.let { id -> + authoritativeSubtitleReadyRows[sessionId to id] + } + if (readyRow != null) { + mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) + val owner = mobileSubtitleTransactions.beginRefresh() + if (mobileSubtitleTransactions.selectFromRefresh(owner, mobileSubtitleIdentity(readyRow))) { + pendingAuthoritativeSubtitleDownloadId = null + } + } + return + } mobileSubtitleTransactions.updatePlaybackContext(mobileSubtitleContext(state)) val owner = mobileSubtitleTransactions.beginRefresh() val downloaded = when (val r = subtitlesRepository.list(mediaFileId)) { @@ -2963,22 +3597,22 @@ class PlayerViewModel( } } - /** Skip the intro (legacy alias used by PlayerOverlay). Same effect as [onSkipIntroNow]. */ - fun onSkipIntro() { - onSkipIntroNow() - } - - /** Skip the intro now: seek to the end of the intro range and clear any active countdown. */ - fun onSkipIntroNow() { - val intro = _uiState.value.intro ?: return - onSeek(intro.end) - introAutoSkipController.cancelCountdown() + /** + * The intro pill's primary action — a tap on it. The controller decides + * where it goes: the intro's end for the `ask` offer, its start for + * `always`'s undo. A no-op when no pill is showing. + */ + fun onSelectIntroPrompt() { + val target = introAutoSkipController.select() ?: return + onSeek(target) } - /** Cancel an in-flight auto-skip countdown — banner falls back to the manual Skip button. */ - fun onCancelIntroAutoSkip() { - introAutoSkipController.cancelCountdown() - } + /** + * System back while the intro pill is showing: take it down and resolve the + * intro without moving playback. True when a pill was actually dismissed, + * so the caller consumes the press only then. + */ + fun onDismissIntroPrompt(): Boolean = introAutoSkipController.dismiss() // ---- F2 next-episode auto-advance + pass-out protection ---- @@ -3171,7 +3805,7 @@ class PlayerViewModel( private fun startUpNextCountdown() { upNextCountdownJob?.cancel() upNextCountdownJob = viewModelScope.launch { - // Two anchors (iOS parity — prairie-apple's PlayerViewModel derives the + // Two anchors (iOS parity — silo-apple's PlayerViewModel derives the // countdown from movieTime and only auto-plays once playback truly // ends): // - Card committed BEFORE the end (credits / prompt crossing): the @@ -3279,6 +3913,10 @@ class PlayerViewModel( viewModelScope.launch { playerSettingsStore.setVideoGravity(value) } } + fun onSetLetterboxExpansion(value: String) { + viewModelScope.launch { playerSettingsStore.setLetterboxExpansion(value) } + } + /** HUD lock toggle — persisted like iOS's `setPlayerOrientationMode`. */ fun onSetOrientationLocked(locked: Boolean) { viewModelScope.launch { @@ -3288,8 +3926,8 @@ class PlayerViewModel( } } - fun onSetAutoSkipIntro(value: Boolean) { - viewModelScope.launch { playerSettingsStore.setAutoSkipIntro(value) } + fun onSetIntroSkipMode(value: IntroSkipMode) { + viewModelScope.launch { playerSettingsStore.setIntroSkipMode(value) } } fun onSetAutoPlayNext(value: Boolean) { @@ -3327,14 +3965,7 @@ class PlayerViewModel( * the new offset at every cue parse. */ fun onSetSubtitleDelay(value: Int) { - val contentId = _uiState.value.contentId.takeIf(String::isNotBlank) - viewModelScope.launch { - if (contentId == null) { - playerSettingsStore.setSubtitleSyncMs(value) - } else { - playerSettingsStore.setSubtitleSyncMsFor(contentId, value) - } - } + viewModelScope.launch { playerSettingsStore.setSubtitleSyncMs(value) } } // ---- Sleep timer setters --------------------------------------------------- @@ -3373,6 +4004,11 @@ class PlayerViewModel( val state = _uiState.value val version = state.versions.getOrNull(index) ?: return if (!isRecovery && index == state.selectedVersionIndex) return + if (isRecovery) { + routeIntentState.recoverVersionSelection(state.contentId) + } else { + routeIntentState.beginVersionSelection(state.contentId, version.fileId) + } viewModelScope.launch { sessionLifecycle.stop() loadContent( @@ -3382,6 +4018,7 @@ class PlayerViewModel( initialSubtitleTrackIndex = state.selectedSubtitleIndex, resumePositionOverride = state.position, suppressResumeRewind = true, + preserveRouteIntent = true, ) } } @@ -3466,15 +4103,35 @@ class PlayerViewModel( /** Called when the user exits the player. */ fun onExit() { if (!exitPrepared.compareAndSet(false, true)) return + routeIntentState.clear() resetPlaybackRecoveryState() loadOwners.invalidate() loadJob?.cancel() loadJob = null mobileSubtitleTransactions.invalidate() mobileSubtitleTransactions.requestDurableFinalPersistence() + // Qualified by the session this view model actually owns. The lifecycle + // is process-scoped, and phone navigation REPLACES the player back-stack + // entry — so a new view model can adopt its session before the outgoing + // one finishes tearing down, and an unqualified stop then kills the + // playback the viewer is currently watching. TV already qualifies both + // of its exits; phone did not. + // Retained first, UI second. Every path that publishes a session id into + // UI state writes this token no later, and the three adoption paths + // (protocol-V3 replan, seek recovery, subtitle replan) write it earlier — + // at the moment the lifecycle takes ownership. That gap is the whole + // point: reading UI first inside it names the predecessor, the lifecycle + // rightly refuses to stop a session it no longer owns, and the one-shot + // gate stops onCleared from trying again. The replacement runs on. + val ownedSessionId = retainedOwnedSessionId ?: _uiState.value.sessionId + retainedOwnedSessionId = ownedSessionId viewModelScope.launch { mobileSubtitleTransactions.persistCommittedSelectionAndFlush() - sessionLifecycle.stop() + // Never unqualified. A null expectedSessionId disables the ownership + // guard entirely, which is the opposite of what a missing token + // should mean — if we cannot say which session was ours, we have no + // business stopping anyone's. + ownedSessionId?.let { lifecycleTeardown.stopOrdered(expectedSessionId = it) } } val state = _uiState.value val cid = state.contentId.takeIf { it.isNotBlank() } @@ -3611,8 +4268,8 @@ class PlayerViewModel( sessionPosition = 0.0, detailPosition = detailPos, ) - val artworkUrl = watchDetail?.posterUrl?.takeIf { url -> url.isNotBlank() } - ?: watchDetail?.backdropUrl?.takeIf { url -> url.isNotBlank() } + val artworkUrl = watchDetail?.backdropUrl?.takeIf { url -> url.isNotBlank() } + ?: watchDetail?.posterUrl?.takeIf { url -> url.isNotBlank() } ?: sidecar.posterUrl?.takeIf { url -> url.isNotBlank() } val published = loadOwners.runIfOwned(loadOwner) { @@ -3656,6 +4313,8 @@ class PlayerViewModel( selectedSubtitleIndex = -1, intro = watchDetail?.intro, credits = watchDetail?.credits, + recap = watchDetail?.recap, + preview = watchDetail?.preview, chapters = versions[selectedIndex].chapters.orEmpty().ifEmpty { sidecar.chapters.orEmpty() }, seriesId = watchDetail?.seriesId, preferredAudioLanguage = null, @@ -3668,10 +4327,24 @@ class PlayerViewModel( "tryLocalPlayback: serving ${media.displayName} (${media.sizeBytes}B) for content=$contentId (sidecar id=${sidecar.record.id})", ) } + + // Downloaded playback publishes the catalog and hardcodes ordinal 0, but + // Media3 still picks its own default from the file's tracks -- so the + // intent has to exist here too or a multi-audio download cannot be + // corrected. + if (_uiState.value.audioTracks.isNotEmpty()) setDesiredAudio(0, explicit = false) return published } override fun onCleared() { + // The RETAINED token first, for the reason onExit gives. An explicit + // back/remote exit + // calls onExit() before navigation, which clears sessionId — so by the + // time onCleared runs, a "snapshot" of UI state is already null, and a + // null token disables the ownership guard and stops whatever session is + // current. That is precisely the session a replacement screen may have + // just adopted. + val clearedSessionId = retainedOwnedSessionId ?: _uiState.value.sessionId org.prairieserver.prairie.common.player.debug.PlaybackDebugState.screenError = null org.prairieserver.prairie.common.player.ActivePlaybackFile.clear(_uiState.value.mediaFileId) loadOwners.invalidate() @@ -3681,8 +4354,11 @@ class PlayerViewModel( mobileSubtitleTransactions.invalidate() onExit() // viewModelScope is cancelling here, so onExit's ordered stop may not run. - // stopAsync() is app-scoped and de-duplicates against an in-flight stop. - sessionLifecycle.stopAsync() + // The gate is what decides: if that stop already claimed teardown this + // is a no-op, and otherwise the app-scoped async stop takes ownership. + // Qualified for the same reason as the ordered stop above: by the time + // onCleared runs, a replacement screen may already own playback. + clearedSessionId?.let { lifecycleTeardown.stopDetached(expectedSessionId = it) } controlsHideJob?.cancel() introObserverJob?.cancel() lifecycleObserverJob?.cancel() @@ -3699,3 +4375,23 @@ class PlayerViewModel( return serverId to profileId } } + +/** Snapshots to let a local audio switch take before asking the server. */ +/** + * How long `isPlaying == false` must hold before it counts as a pause rather + * than a rebuffer, for the intro prompt's timer. The spec's + * PLAYBACK_PAUSE_GRACE_MS; TV carries the same constant. + */ +private const val PLAYBACK_PAUSE_GRACE_MS = 1_500L + +private const val MAX_LOCAL_AUDIO_ATTEMPTS = 3 + +internal fun authoritativePlaybackSubtitleOrdinal( + serverIndex: Int?, + playbackTracks: List, +): Int? = when (serverIndex) { + null -> -1 + -1 -> -1 + else -> playbackTracks.indexOfFirst { it.index == serverIndex } + .takeIf { it >= 0 } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt index 7d24e219b..22912b6ab 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/QualitySelector.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -17,13 +16,14 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.prairieserver.prairie.model.catalog.FileVersion @@ -38,28 +38,28 @@ fun QualitySelector( selectedIndex: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit, + tabletopPaneHeight: Dp? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( + PlayerModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + tabletopPaneHeight = tabletopPaneHeight, ) { Column( modifier = Modifier .fillMaxWidth() // Cap below the top edge + keep content flings from // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) + .playerSheetContent(tabletopPaneHeight) .nestedScroll(PlayerSheetFlingGuard) .padding(bottom = 32.dp), ) { - Text( - text = "Quality", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + PlayerSheetHeader( + title = "Quality", + subtitle = "Choose a source version", + onDismiss = onDismiss, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt index 820f68e21..3d9b198f1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt @@ -148,8 +148,16 @@ class RoomSyncController( while (isActive) { val state = viewModel.uiState.value val sessionId = state.sessionId + val deliveryKey = deliveryLatch.keyOrNull( + repository.connectionState.value, + sessionId, + ) val now = monotonicMs() - if (sessionId != null && + if (deliveryKey != null && + deliveryLatch.isServerAttached( + deliveryKey, + repository.roomDeliveryEcho.value, + ) && shouldEmitStateReport( now, lastReportMs, @@ -160,7 +168,7 @@ class RoomSyncController( ) { lastReportMs = now repository.stateReport( - sessionId = sessionId, + sessionId = deliveryKey.playbackSessionId, positionSeconds = state.position, isPaused = state.isPaused, ) @@ -186,7 +194,14 @@ class RoomSyncController( if (playbackState != RoomPlaybackState.Waiting || key == null) { return@collectLatest } - while (!deliveryLatch.isAttached(key)) delay(10) + while ( + !deliveryLatch.isServerAttached( + key, + repository.roomDeliveryEcho.value, + ) + ) { + delay(10) + } while (isActive && deliveryLatch.needsReadiness(key, buffering)) { val currentState = viewModel.uiState.value val delivered = if (buffering) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt index 40189f0e7..f7d126131 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SleepTimerSheet.kt @@ -9,12 +9,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -24,11 +24,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.common.player.SleepTimerState -import kotlinx.coroutines.launch /** * Glass-style bottom sheet for arming the sleep timer. Mirrors iOS @@ -52,31 +53,30 @@ fun SleepTimerSheet( // Gear-submenu back affordance: dismisses this sheet and reopens the // parent settings sheet (wired in PlayerOverlay). onBack: (() -> Unit)? = null, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Box( modifier = Modifier .fillMaxWidth() // Keep the sheet handle below the top screen edge — see // PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard) .background( brush = Brush.verticalGradient( colors = listOf( @@ -86,15 +86,17 @@ fun SleepTimerSheet( ), ), ) { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { PlayerSheetHeader( title = "Sleep Timer", onBack = onBack?.let { back -> - { - scope.launch { sheetState.hide() } - back() - } + { scope.dismissPlayerSheet(sheetState, back) } }, + onDismiss = dismissSheet, ) if (activeState is SleepTimerState.Active) { @@ -114,8 +116,7 @@ fun SleepTimerSheet( isSelected = preset.minutes == defaultMinutes, onClick = { onStart(preset.minutes) - scope.launch { sheetState.hide() } - onDismiss() + dismissSheet() }, ) } @@ -128,8 +129,7 @@ fun SleepTimerSheet( isDestructive = true, onClick = { onCancel() - scope.launch { sheetState.hide() } - onDismiss() + dismissSheet() }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt index bed8f5a05..5ad8ca586 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleSearchSheet.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -39,6 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.util.LanguageNames @@ -83,6 +83,7 @@ fun SubtitleSearchSheet( // Tracks-submenu back affordance: closes this sheet and reopens the parent // TracksSheet (wired in PlayerOverlay). Null falls back to a plain dismiss. onBack: (() -> Unit)? = null, + tabletopPaneHeight: Dp? = null, ) { var selectedLanguage by remember { mutableStateOf(defaultLanguage) } var languageMenuExpanded by remember { mutableStateOf(false) } @@ -94,18 +95,17 @@ fun SubtitleSearchSheet( if (tools.downloadCompleted) (onBack ?: onDismiss)() } - ModalBottomSheet( + PlayerModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Column( modifier = Modifier .fillMaxWidth() // Cap below the top edge + keep content flings from // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) + .playerSheetContent(tabletopPaneHeight) .nestedScroll(PlayerSheetFlingGuard) .background( brush = Brush.verticalGradient( @@ -119,6 +119,7 @@ fun SubtitleSearchSheet( PlayerSheetHeader( title = "Search Subtitles", onBack = onBack, + onDismiss = onDismiss, ) Row( @@ -216,7 +217,13 @@ fun SubtitleSearchSheet( LazyColumn( modifier = Modifier .fillMaxWidth() - .heightIn(max = 420.dp), + .then( + if (tabletopPaneHeight == null) { + Modifier.heightIn(max = 420.dp) + } else { + Modifier.weight(1f) + }, + ), ) { items(tools.searchResults, key = { "${it.provider}:${it.id}" }) { result -> val key = "${result.provider}:${result.id}" diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt index 3ae55f61e..b03671335 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleStyleSheet.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -22,7 +21,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch @@ -38,13 +36,13 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset -import kotlinx.coroutines.launch /** * Glass-style bottom sheet for editing the user's [SubtitleAppearance]: @@ -65,32 +63,30 @@ fun SubtitleStyleSheet( // Gear-submenu back affordance: dismisses this sheet and reopens the // parent settings sheet (wired in PlayerOverlay). onBack: (() -> Unit)? = null, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val scrollState = rememberScrollState() + val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Box( modifier = Modifier .fillMaxWidth() // Cap below the top edge + keep content flings from // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) + .playerSheetContent(tabletopPaneHeight) .nestedScroll(PlayerSheetFlingGuard) .background( brush = Brush.verticalGradient( @@ -109,11 +105,9 @@ fun SubtitleStyleSheet( PlayerSheetHeader( title = "Subtitle Style", onBack = onBack?.let { back -> - { - scope.launch { sheetState.hide() } - back() - } + { scope.dismissPlayerSheet(sheetState, back) } }, + onDismiss = dismissSheet, ) // ---- Text section ------------------------------------------------ diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt index 1d4020da5..45bca2dde 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/TracksSheet.kt @@ -6,22 +6,26 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material.icons.filled.Translate import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -29,29 +33,20 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.player.formatSubtitleTrackDisplayLabel -import kotlinx.coroutines.launch -/** - * Combined audio + subtitle picker bottom sheet. Mirrors iOS phone's - * `TrackSelectionSheet` phoneList variant - * (`iosApp/Screens/Player/Sheets/TrackSelectionSheet.swift:145-164`): a single - * sheet with an "Audio" section and a "Subtitles" section, each rendering - * compact rows with a trailing checkmark on the active selection. - * - * Subtitles always include an "Off" entry (`-1`) per tvOS / iOS contract. - * Audio section is hidden entirely when there are no audio tracks (the - * server already filters down to playable ones). - */ +/** Combined audio and subtitle picker with adaptive phone and foldable layouts. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TracksSheet( @@ -67,147 +62,274 @@ fun TracksSheet( showTranslateAction: Boolean = false, onSearchSubtitles: () -> Unit = {}, onTranslateWithAi: () -> Unit = {}, + tabletopPaneHeight: Dp? = null, ) { if (!isVisible) return val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() + val dismissSheet = { scope.dismissPlayerSheet(sheetState, onDismiss) } + val selectAudioAndDismiss: (Int) -> Unit = { index -> + onSelectAudio(index) + dismissSheet() + } + val selectSubtitleAndDismiss: (Int) -> Unit = { index -> + onSelectSubtitle(index) + dismissSheet() + } + val openSubtitleSearch = { + scope.dismissPlayerSheet(sheetState, onDismiss, onSearchSubtitles) + } + val openAiTranslate = { + scope.dismissPlayerSheet(sheetState, onDismiss, onTranslateWithAi) + } LaunchedEffect(isVisible) { if (isVisible) sheetState.show() } - ModalBottomSheet( - onDismissRequest = { - scope.launch { sheetState.hide() } - onDismiss() - }, + PlayerModalBottomSheet( + onDismissRequest = dismissSheet, sheetState = sheetState, - containerColor = Color.Transparent, - contentColor = Color.White, + tabletopPaneHeight = tabletopPaneHeight, ) { Column( modifier = Modifier .fillMaxWidth() - // Cap below the top edge + keep content flings from - // dismissing the sheet — see PlayerSheetSupport. - .heightIn(max = playerSheetMaxHeight()) - .nestedScroll(PlayerSheetFlingGuard) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1F2937).copy(alpha = 0.95f), - Color.Black.copy(alpha = 0.92f), - ), - ), - ) - .verticalScroll(rememberScrollState()), + .playerSheetContent(tabletopPaneHeight) + .nestedScroll(PlayerSheetFlingGuard), ) { - Text( - text = "Audio and Subtitles", - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 8.dp), + PlayerSheetHeader( + title = "Audio & Subtitles", + subtitle = "Choose language and accessibility tracks", + onDismiss = dismissSheet, ) - if (audioTracks.isNotEmpty()) { - SectionHeader("Audio") - audioTracks.forEachIndexed { index, track -> - TrackRow( - label = audioTrackName(track, index), - attributes = audioTrackAttributes(track), - isSelected = index == selectedAudioIndex, - onClick = { - onSelectAudio(index) - scope.launch { sheetState.hide() } - onDismiss() - }, + if (tabletopPaneHeight != null && audioTracks.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + AudioTrackCard( + audioTracks = audioTracks, + selectedAudioIndex = selectedAudioIndex, + onSelect = selectAudioAndDismiss, + scrollContent = true, + modifier = Modifier + .weight(0.44f) + .fillMaxHeight(), + ) + SubtitleTrackCard( + subtitles = subtitles, + selectedSubtitleIndex = selectedSubtitleIndex, + onSelect = selectSubtitleAndDismiss, + showSearchAction = showSearchAction, + showTranslateAction = showTranslateAction, + onSearchSubtitles = openSubtitleSearch, + onTranslateWithAi = openAiTranslate, + scrollContent = true, + modifier = Modifier + .weight(0.56f) + .fillMaxHeight(), + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding( + start = playerSheetHorizontalPadding(tabletopPaneHeight), + end = playerSheetHorizontalPadding(tabletopPaneHeight), + top = 8.dp, + bottom = 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (audioTracks.isNotEmpty()) { + AudioTrackCard( + audioTracks = audioTracks, + selectedAudioIndex = selectedAudioIndex, + onSelect = selectAudioAndDismiss, + scrollContent = false, + modifier = Modifier.fillMaxWidth(), + ) + } + SubtitleTrackCard( + subtitles = subtitles, + selectedSubtitleIndex = selectedSubtitleIndex, + onSelect = selectSubtitleAndDismiss, + showSearchAction = showSearchAction, + showTranslateAction = showTranslateAction, + onSearchSubtitles = openSubtitleSearch, + onTranslateWithAi = openAiTranslate, + scrollContent = false, + modifier = Modifier.fillMaxWidth(), ) } } + } + } +} - SectionHeader("Subtitles") - // "Off" is the canonical first entry — iOS / tvOS pattern. +@Composable +private fun AudioTrackCard( + audioTracks: List, + selectedAudioIndex: Int, + onSelect: (Int) -> Unit, + scrollContent: Boolean, + modifier: Modifier = Modifier, +) { + TrackSectionCard( + title = "Audio", + detail = trackCountLabel(audioTracks.size), + icon = Icons.Filled.GraphicEq, + scrollContent = scrollContent, + modifier = modifier, + ) { + audioTracks.forEachIndexed { index, track -> TrackRow( - label = "Off", - isSelected = selectedSubtitleIndex == -1, - onClick = { - onSelectSubtitle(-1) - scope.launch { sheetState.hide() } - onDismiss() - }, + label = audioTrackName(track, index), + attributes = audioTrackAttributes(track), + isSelected = index == selectedAudioIndex, + onClick = { onSelect(index) }, ) - subtitles.forEachIndexed { index, sub -> - TrackRow( - label = subtitleTrackLabel(sub, index), - isSelected = index == selectedSubtitleIndex, - onClick = { - onSelectSubtitle(index) - scope.launch { sheetState.hide() } - onDismiss() - }, - ) - } + } + } +} +@Composable +private fun SubtitleTrackCard( + subtitles: List, + selectedSubtitleIndex: Int, + onSelect: (Int) -> Unit, + showSearchAction: Boolean, + showTranslateAction: Boolean, + onSearchSubtitles: () -> Unit, + onTranslateWithAi: () -> Unit, + scrollContent: Boolean, + modifier: Modifier = Modifier, +) { + TrackSectionCard( + title = "Subtitles", + detail = trackCountLabel(subtitles.size + 1), + icon = Icons.Filled.Subtitles, + scrollContent = scrollContent, + modifier = modifier, + ) { + TrackRow( + label = "Off", + attributes = "No subtitles", + isSelected = selectedSubtitleIndex == -1, + onClick = { onSelect(-1) }, + ) + subtitles.forEachIndexed { index, subtitle -> + TrackRow( + label = subtitleTrackLabel(subtitle, index), + isSelected = index == selectedSubtitleIndex, + onClick = { onSelect(index) }, + ) + } + if (showSearchAction || showTranslateAction) { + PlayerSheetDivider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) + } + if (showSearchAction) { + ActionRow( + icon = Icons.Filled.Search, + label = "Find subtitles", + onClick = onSearchSubtitles, + ) + } + if (showTranslateAction) { + ActionRow( + icon = Icons.Filled.Translate, + label = "Translate with AI", + onClick = onTranslateWithAi, + ) + } + } +} - // Non-selecting action rows (web SubtitleMenu parity). Each - // dismisses this sheet first — Material 3 sheets can't nest — - // then PlayerOverlay opens the target sheet. - if (showSearchAction) { - ActionRow( - icon = Icons.Filled.Search, - label = "Search subtitles…", - onClick = { - scope.launch { sheetState.hide() } - onDismiss() - onSearchSubtitles() - }, - ) +@Composable +private fun TrackSectionCard( + title: String, + detail: String, + icon: ImageVector, + scrollContent: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + PlayerSheetCard(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + modifier = Modifier.size(36.dp), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } } - if (showTranslateAction) { - ActionRow( - icon = Icons.Filled.Translate, - label = "Translate with AI…", - onClick = { - scope.launch { sheetState.hide() } - onDismiss() - onTranslateWithAi() - }, + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = detail, + color = Color.White.copy(alpha = 0.48f), + fontSize = 12.sp, ) } - - Spacer(modifier = Modifier.height(16.dp)) } + PlayerSheetDivider() + Column( + modifier = if (scrollContent) { + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(vertical = 6.dp) + } else { + Modifier.padding(vertical = 6.dp) + }, + content = { content() }, + ) } } -@Composable -private fun SectionHeader(text: String) { - Text( - text = text.uppercase(), - color = Color.White.copy(alpha = 0.6f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 12.dp, bottom = 4.dp), - ) -} - @Composable private fun TrackRow( label: String, isSelected: Boolean, onClick: () -> Unit, attributes: String? = null, - enabled: Boolean = true, ) { - // iOS phone TrackRow: a Button with VStack(name, optional attributes - // caption) and a trailing tint checkmark when selected. Row( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (isSelected) PlayerSheetSelectedColor else Color.Transparent) .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), + .heightIn(min = 52.dp) + .padding(horizontal = 12.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -215,51 +337,40 @@ private fun TrackRow( Text( text = label, color = Color.White, - style = MaterialTheme.typography.bodyLarge, + fontSize = 15.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - if (attributes != null && attributes.isNotBlank()) { + if (!attributes.isNullOrBlank()) { Text( text = attributes, - color = Color.White.copy(alpha = 0.6f), + color = Color.White.copy(alpha = 0.50f), fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } if (isSelected) { - // iOS uses `.tint` (accent) for the selection checkmark. - Icon( - imageVector = Icons.Filled.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 8.dp), - ) - } else { - Box(modifier = Modifier.padding(start = 8.dp)) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(26.dp), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = "Selected", + tint = Color.Black, + modifier = Modifier.size(17.dp), + ) + } + } } } } -private fun audioTrackName(track: AudioTrack, index: Int): String = - listOfNotNull( - track.title?.takeIf { it.isNotBlank() }, - track.language?.takeIf { it.isNotBlank() }?.uppercase(), - ).joinToString(" · ").ifBlank { "Audio ${index + 1}" } - -private fun audioTrackAttributes(track: AudioTrack): String = - listOfNotNull( - track.codec?.takeIf { it.isNotBlank() }?.uppercase(), - track.channels?.let { "${it}ch" }, - ).joinToString(" · ") - -internal fun subtitleTrackLabel(sub: PlayerSubtitleInfo, index: Int): String = - formatSubtitleTrackDisplayLabel( - rawLabel = sub.label, - language = sub.language, - codecOrMime = sub.codec, - isForced = sub.forced == true, - index = index, - ) - @Composable private fun ActionRow( icon: ImageVector, @@ -269,8 +380,11 @@ private fun ActionRow( Row( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), + .heightIn(min = 50.dp) + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -283,7 +397,31 @@ private fun ActionRow( Text( text = label, color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyLarge, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, ) } } + +private fun trackCountLabel(count: Int): String = if (count == 1) "1 option" else "$count options" + +private fun audioTrackName(track: AudioTrack, index: Int): String = + listOfNotNull( + track.title?.takeIf { it.isNotBlank() }, + track.language?.takeIf { it.isNotBlank() }?.uppercase(), + ).joinToString(" · ").ifBlank { "Audio ${index + 1}" } + +private fun audioTrackAttributes(track: AudioTrack): String = + listOfNotNull( + track.codec?.takeIf { it.isNotBlank() }?.uppercase(), + track.channels?.let { "${it}ch" }, + ).joinToString(" · ") + +internal fun subtitleTrackLabel(sub: PlayerSubtitleInfo, index: Int): String = + formatSubtitleTrackDisplayLabel( + rawLabel = sub.label, + language = sub.language, + codecOrMime = sub.codec, + isForced = sub.forced == true, + index = index, + ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt index 3f25968d3..85ea58fc2 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -48,8 +49,8 @@ import org.prairieserver.prairie.android.ui.screens.auth.AuthColors import org.prairieserver.prairie.android.ui.screens.auth.AuthErrorBanner import org.prairieserver.prairie.android.ui.screens.auth.PrairieButton import org.prairieserver.prairie.android.ui.screens.auth.PrairieTextField -import org.prairieserver.prairie.model.profile.displayProfileQualityPreference import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef /** * Form for creating a new profile. @@ -100,9 +101,11 @@ fun CreateProfileScreen( Column( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .imePadding() + .navigationBarsPadding() .padding(horizontal = 24.dp), ) { state.error?.let { error -> @@ -115,7 +118,7 @@ fun CreateProfileScreen( // Preview ProfileAvatar( - avatar = state.selectedAvatar, + avatar = ProfileAvatarRef(state.selectedAvatar), name = state.name.ifBlank { "?" }, size = 80.dp, modifier = Modifier.align(Alignment.CenterHorizontally), @@ -128,11 +131,11 @@ fun CreateProfileScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (emoji in AvatarOptions.emojis) { + for (avatarRef in AvatarOptions.presets) { AvatarPickerItem( - emoji = emoji, - isSelected = state.selectedAvatar == emoji, - onClick = { viewModel.onAvatarSelected(emoji) }, + avatarRef = avatarRef, + isSelected = state.selectedAvatar == avatarRef, + onClick = { viewModel.onAvatarSelected(avatarRef) }, ) } } @@ -188,16 +191,6 @@ fun CreateProfileScreen( Spacer(modifier = Modifier.height(24.dp)) - // -- Quality preference -- - DropdownField( - label = "Quality Preference", - selected = displayProfileQualityPreference(state.qualityPreference), - options = QUALITY_OPTIONS, - onSelected = viewModel::onQualitySelected, - ) - - Spacer(modifier = Modifier.height(16.dp)) - // -- Subtitle mode -- DropdownField( label = "Subtitles", @@ -225,7 +218,7 @@ fun CreateProfileScreen( @Composable internal fun SectionHeader(text: String) { - // iOS phone field labels use prairieCaption (12pt regular, secondary). + // iOS phone field labels use siloCaption (12pt regular, secondary). Text( text = text, fontSize = 12.sp, @@ -247,14 +240,14 @@ internal fun SwitchRow( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - // iOS phone Toggle title: prairieBody (14pt), onSurface. + // iOS phone Toggle title: siloBody (14pt), onSurface. Text( text = label, fontSize = 14.sp, color = AuthColors.OnBackground, ) if (subtitle != null) { - // iOS phone Toggle subtitle: prairieCaption (12pt), secondary. + // iOS phone Toggle subtitle: siloCaption (12pt), secondary. Text( text = subtitle, fontSize = 12.sp, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt index eefaf3e77..a0780dcde 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/CreateProfileViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.CreateProfileRequest import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.profile.canonicalProfileQualityPreference import org.prairieserver.prairie.model.profile.hasProfileNamed import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.ProfileRepository @@ -21,7 +20,6 @@ data class CreateProfileUiState( val maxContentRating: String? = null, val pinEnabled: Boolean = false, val pin: String = "", - val qualityPreference: String? = null, val language: String? = null, val subtitleLanguage: String? = null, val subtitleMode: String? = null, @@ -62,8 +60,8 @@ class CreateProfileViewModel( _uiState.update { it.copy(name = value, error = null) } } - fun onAvatarSelected(emoji: String) { - _uiState.update { it.copy(selectedAvatar = emoji) } + fun onAvatarSelected(avatarRef: String) { + _uiState.update { it.copy(selectedAvatar = avatarRef) } } fun onChildToggled(checked: Boolean) { @@ -90,12 +88,6 @@ class CreateProfileViewModel( _uiState.update { it.copy(pin = filtered, error = null) } } - fun onQualitySelected(quality: String) { - _uiState.update { - it.copy(qualityPreference = canonicalProfileQualityPreference(quality)) - } - } - fun onLanguageSelected(language: String?) { _uiState.update { it.copy(language = language) } } @@ -135,7 +127,6 @@ class CreateProfileViewModel( pin = if (current.pinEnabled) current.pin else null, isChild = if (current.isChild) true else null, maxContentRating = current.maxContentRating, - qualityPreference = current.qualityPreference, language = current.language, subtitleLanguage = current.subtitleLanguage, subtitleMode = current.subtitleMode, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt index d18c5634f..c28e17429 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileScreen.kt @@ -36,6 +36,7 @@ import org.prairieserver.prairie.android.ui.screens.auth.AuthColors import org.prairieserver.prairie.android.ui.screens.auth.AuthErrorBanner import org.prairieserver.prairie.android.ui.screens.auth.PrairieButton import org.prairieserver.prairie.android.ui.screens.auth.PrairieTextField +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef import org.prairieserver.prairie.model.profile.displayProfileQualityPreference import org.koin.compose.viewmodel.koinViewModel @@ -115,7 +116,7 @@ fun EditProfileScreen( SectionHeader("Avatar") ProfileAvatar( - avatar = state.selectedAvatar, + avatar = ProfileAvatarRef(state.selectedAvatar, state.selectedAvatarUrl), name = state.name.ifBlank { "?" }, size = 80.dp, modifier = Modifier.align(Alignment.CenterHorizontally), @@ -128,11 +129,11 @@ fun EditProfileScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - for (emoji in AvatarOptions.emojis) { + for (avatarRef in AvatarOptions.presets) { AvatarPickerItem( - emoji = emoji, - isSelected = state.selectedAvatar == emoji, - onClick = { viewModel.onAvatarSelected(emoji) }, + avatarRef = avatarRef, + isSelected = state.selectedAvatar == avatarRef, + onClick = { viewModel.onAvatarSelected(avatarRef) }, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt index d8323415b..d06de1a80 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/EditProfileViewModel.kt @@ -18,6 +18,13 @@ data class EditProfileUiState( val profileId: String = "", val name: String = "", val selectedAvatar: String? = null, + /** + * Server-supplied URL for the avatar this profile was LOADED with, so an + * uploaded avatar renders in the editor preview instead of falling back to + * initials. Cleared the moment the user picks a different avatar, because + * the URL only describes the stored one. + */ + val selectedAvatarUrl: String? = null, val isChild: Boolean = false, val maxContentRating: String? = null, val pinEnabled: Boolean = false, @@ -70,6 +77,7 @@ class EditProfileViewModel( isLoading = false, name = profile.name, selectedAvatar = profile.avatar, + selectedAvatarUrl = profile.avatarUrl, isChild = profile.isChild, maxContentRating = profile.maxContentRating, pinEnabled = profile.hasPin, @@ -105,8 +113,10 @@ class EditProfileViewModel( _uiState.update { it.copy(name = value, error = null) } } - fun onAvatarSelected(emoji: String) { - _uiState.update { it.copy(selectedAvatar = emoji) } + fun onAvatarSelected(avatarRef: String) { + // Drop the loaded avatar's URL: it points at the stored image, which is + // no longer what the preview should show. + _uiState.update { it.copy(selectedAvatar = avatarRef, selectedAvatarUrl = null) } } fun onChildToggled(checked: Boolean) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt index 6aaaa1305..a2c6e0d8a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/PINEntryDialog.kt @@ -23,7 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,8 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import org.prairieserver.prairie.android.ui.screens.auth.AuthColors -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Close +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef private const val PIN_LENGTH = 4 @@ -53,13 +52,17 @@ private const val PIN_LENGTH = 4 @Composable fun PINEntryDialog( profileName: String, - profileAvatar: String?, + profileAvatar: ProfileAvatarRef, isLoading: Boolean, error: String?, onPinComplete: (String) -> Unit, onDismiss: () -> Unit, ) { - var pin by rememberSaveable { mutableStateOf("") } + // Deliberately NOT rememberSaveable: saved-instance state is serialized by + // the OS across configuration change and process death, which would put the + // raw PIN in system-managed storage well beyond the request that needs it. + // Losing four digits on rotation is the correct trade. + var pin by remember { mutableStateOf("") } // Clear pin on new error so the user can re-enter. LaunchedEffect(error) { @@ -133,14 +136,11 @@ fun PINEntryDialog( Spacer(modifier = Modifier.height(32.dp)) + // Cancel stays live during verification (the user must be able + // to back out of a slow round trip), and the ViewModel's + // generation guard makes that abandon the in-flight answer + // rather than commit it late. TextButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = AuthColors.Primary, - ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = "Cancel", color = AuthColors.Primary, @@ -152,7 +152,7 @@ fun PINEntryDialog( } } -// iOS prairieSurfaceVariant (#0E0F12) — used for empty PIN dots and pad keys. +// iOS siloSurfaceVariant (#0E0F12) — used for empty PIN dots and pad keys. private val SurfaceVariant = Color(0xFF0E0F12) @Composable @@ -258,7 +258,7 @@ private fun NumberPadKey( ), contentAlignment = Alignment.Center, ) { - // iOS prairiePIN: 32 bold monospaced. + // iOS siloPIN: 32 bold monospaced. Text( text = digit, fontSize = 32.sp, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt index 4726aa4a7..d8b216293 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileAvatar.kt @@ -7,10 +7,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -21,41 +19,37 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.screens.auth.AuthColors +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.isEmojiAvatar import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage /** - * Pre-defined avatar options. Each entry is an emoji displayed inside a coloured circle. - * - * The server stores the avatar as a string. It may be an emoji, a DiceBear URL, or - * an uploaded image path. When no avatar is set, initials derived from the profile - * name are shown instead. + * Pre-defined avatar options using the server's supported preset vocabulary. */ object AvatarOptions { - val emojis = listOf( - "\uD83D\uDE00", // grinning face - "\uD83D\uDE0E", // smiling face with sunglasses - "\uD83E\uDD13", // nerd face - "\uD83E\uDD78", // disguised face - "\uD83D\uDC7E", // alien monster - "\uD83D\uDC31", // cat face - "\uD83D\uDC36", // dog face - "\uD83E\uDD8A", // fox - "\uD83E\uDD81", // lion - "\uD83D\uDC3B", // bear - "\uD83D\uDC27", // penguin - "\uD83E\uDD89", // owl - "\uD83C\uDF1F", // glowing star - "\uD83C\uDF08", // rainbow - "\uD83C\uDFA8", // artist palette - "\uD83C\uDFAC", // clapper board - "\uD83C\uDFB5", // musical note - "\uD83D\uDE80", // rocket - "\uD83C\uDF0D", // globe - "\uD83C\uDF53", // strawberry + val presets = listOf( + "preset:dicebear:fun-emoji:cosmic-otter", + "preset:dicebear:fun-emoji:comet-cat", + "preset:dicebear:fun-emoji:star-bear", + "preset:dicebear:fun-emoji:neon-pup", + "preset:dicebear:fun-emoji:orbit-bunny", + "preset:dicebear:fun-emoji:solar-owl", + "preset:dicebear:fun-emoji:nova-gecko", + "preset:dicebear:fun-emoji:pixel-penguin", + "preset:dicebear:fun-emoji:mango-fox", + "preset:dicebear:fun-emoji:bubble-lion", + "preset:dicebear:fun-emoji:marble-panda", + "preset:dicebear:fun-emoji:starlight-tiger", + "preset:dicebear:fun-emoji:candy-dragon", + "preset:dicebear:fun-emoji:ember-parrot", + "preset:dicebear:fun-emoji:mochi-robot", + "preset:dicebear:fun-emoji:twinkle-sprite", + "preset:dicebear:fun-emoji:mint-puffin", + "preset:dicebear:fun-emoji:velvet-panther", + "preset:dicebear:fun-emoji:sunbeam-falcon", + "preset:dicebear:fun-emoji:lunar-meteor", ) /** Deterministic colour for a given avatar string so the same profile always gets the same colour. */ @@ -78,7 +72,7 @@ object AvatarOptions { /** * Displays a profile avatar as an image, emoji, or initials inside a coloured circle. * - * @param avatar Avatar string stored on the profile (nullable). + * @param avatar Avatar ref + server-resolved URL for the profile. * @param name Profile name, used for initials fallback. * @param size Circle diameter. * @param selected Whether to show a highlight border. @@ -86,7 +80,7 @@ object AvatarOptions { */ @Composable fun ProfileAvatar( - avatar: String?, + avatar: ProfileAvatarRef, name: String, modifier: Modifier = Modifier, size: Dp = 72.dp, @@ -94,14 +88,9 @@ fun ProfileAvatar( onClick: (() -> Unit)? = null, ) { val displayText = profileAvatarDisplayText(avatar = avatar, name = name) - // iOS ProfileAvatarView uses a single flat prairieSurfaceVariant (#0E0F12). + // iOS ProfileAvatarView uses a single flat siloSurfaceVariant (#0E0F12). val bgColor = Color(0xFF0E0F12) - val serverUrl = rememberProfileServerUrl() - val resolvedAvatarUrl = remember(avatar, serverUrl) { - avatar - ?.takeIf(::isImageAvatar) - ?.let { resolveAvatarUrl(serverUrl, it) } - } + val avatarImage = rememberProfileAvatarImage(avatar) val borderModifier = if (selected) { Modifier.border(3.dp, AuthColors.Primary, CircleShape) @@ -118,9 +107,9 @@ fun ProfileAvatar( .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), contentAlignment = Alignment.Center, ) { - if (resolvedAvatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = resolvedAvatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = "$name avatar", modifier = Modifier @@ -128,11 +117,12 @@ fun ProfileAvatar( .clip(CircleShape), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) } else { // iOS: emoji at size*0.45; initials at size*0.34 semibold, onSurface. - val isEmoji = !avatar.isNullOrBlank() && !isImageAvatar(avatar) - if (isEmoji) { + if (isEmojiAvatar(avatar)) { Text( text = displayText, fontSize = (size.value * 0.45).sp, @@ -150,31 +140,23 @@ fun ProfileAvatar( } /** - * Smaller selectable emoji avatar used in the avatar picker grid on - * create/edit screens. Mirrors iOS phone `EditProfileView`'s emoji grid: - * 40dp rounded-rect cell (radius 8), emoji at 28pt, selection shown by a - * tinted background (prairiePrimary at 30%). + * Smaller selectable server-backed avatar used in the create/edit picker grid. */ @Composable fun AvatarPickerItem( - emoji: String, + avatarRef: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) AuthColors.Primary.copy(alpha = 0.3f) else Color.Transparent, - ) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Text( - text = emoji, - fontSize = 28.sp, - ) - } + ProfileAvatar( + // Picker entries are always preset refs, never uploads, so there is no + // server URL to carry alongside them. + avatar = ProfileAvatarRef(avatarRef), + name = "Profile avatar", + modifier = modifier, + size = 40.dp, + selected = isSelected, + onClick = onClick, + ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt index 995ad78a8..5f7cb9014 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionScreen.kt @@ -59,16 +59,13 @@ import org.prairieserver.prairie.android.ui.components.aurora.AuroraBackdrop import org.prairieserver.prairie.android.ui.components.aurora.AuroraScrim import org.prairieserver.prairie.android.ui.components.aurora.AuroraVariant import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.components.isEmojiAvatar import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.model.profile.Profile import androidx.compose.runtime.remember import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Delete -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Check /** * Grid of profile avatars shown after login. @@ -125,23 +122,11 @@ fun ProfileSelectionScreen( }, confirmButton = { TextButton(onClick = viewModel::confirmDeleteProfile) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) Text("Delete", color = MaterialTheme.colorScheme.error) } }, dismissButton = { - TextButton(onClick = viewModel::dismissDeleteDialog) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } + TextButton(onClick = viewModel::dismissDeleteDialog) { Text("Cancel") } }, ) } @@ -150,7 +135,7 @@ fun ProfileSelectionScreen( state.pinDialogProfile?.let { profile -> PINEntryDialog( profileName = profile.name, - profileAvatar = profile.avatar, + profileAvatar = profile.avatarRef(), isLoading = state.pinIsVerifying, error = state.pinError, onPinComplete = viewModel::onPinEntered, @@ -218,13 +203,6 @@ fun ProfileSelectionScreen( Spacer(modifier = Modifier.height(24.dp)) TextButton(onClick = viewModel::toggleManageMode) { - Icon( - imageVector = if (state.isManageMode) Icons.Default.Check else Icons.Default.Edit, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = AuthColors.OnBackground, - ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = if (state.isManageMode) "Done" else "Manage Profiles", fontSize = 15.sp, @@ -359,12 +337,8 @@ private fun ProfileCard( @Composable private fun ProfileTileBody(profile: Profile, tint: Color) { val shape = RoundedCornerShape(TileCornerRadius) - val avatar = profile.avatar?.trim().orEmpty() - val serverUrl = rememberProfileServerUrl() - val resolvedAvatarUrl = remember(avatar, serverUrl) { - avatar.takeIf { it.isNotEmpty() && isImageAvatar(it) } - ?.let { resolveAvatarUrl(serverUrl, it) } - } + val avatar = profile.avatarRef() + val avatarImage = rememberProfileAvatarImage(avatar) Box( modifier = Modifier @@ -384,9 +358,9 @@ private fun ProfileTileBody(profile: Profile, tint: Color) { ), contentAlignment = Alignment.Center, ) { - if (resolvedAvatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = resolvedAvatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = "${profile.name} avatar", modifier = Modifier @@ -394,15 +368,17 @@ private fun ProfileTileBody(profile: Profile, tint: Color) { .clip(shape), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) - } else if (avatar.isNotEmpty() && !isImageAvatar(avatar)) { + } else if (isEmojiAvatar(avatar)) { Text( - text = avatar, + text = avatar.avatar.orEmpty().trim(), fontSize = TileEmojiSize.sp, ) } else { Text( - text = profileAvatarDisplayText(avatar = profile.avatar, name = profile.name), + text = profileAvatarDisplayText(avatar = avatar, name = profile.name), fontSize = TileInitialSize.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.92f), diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt index 62a658a3f..4f6b34b63 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionViewModel.kt @@ -3,7 +3,10 @@ package org.prairieserver.prairie.android.ui.screens.profiles import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.model.profile.authorizedProfileToken import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.ProfileCommitResult import org.prairieserver.prairie.repository.ProfileRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,22 +39,63 @@ class ProfileSelectionViewModel( private val _uiState = MutableStateFlow(ProfileSelectionUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Monotonic generation for PIN verification; see [onPinEntered]. */ + private var pinAttempt: Int = 0 + + /** Monotonic generation for profile-list loads; see [loadProfiles]. */ + private var loadAttempt: Int = 0 + init { loadProfiles() } + /** + * The identity the displayed grid was fetched under. + * + * Every selection is qualified with it, protected or not. Passing null for + * unprotected picks disabled the guard for exactly the case with no PIN + * round trip to re-establish scope — so if another account became active + * between the grid being accepted and the commit entering the barrier, one + * account's profile id was written into the other's token slot. + */ + private var gridScope: AuthScopeSnapshot? = null + /** * @param clearError false keeps an existing error banner (e.g. a failed * delete's explanation) visible across the follow-up list refresh, which * would otherwise silently swallow it. */ fun loadProfiles(clearError: Boolean = true) { + val load = ++loadAttempt viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = if (clearError) null else it.error) } + val scope = profileRepository.captureIdentityScope() val activeId = profileRepository.getActiveProfileId() - when (val result = profileRepository.listProfiles()) { + val result = profileRepository.listProfiles() + // Two separate reasons to drop this response: a newer load + // superseded it, or the identity it was fetched under is gone. + // The second is the one that matters — a stale grid lets the user + // pick a profile from a session the app no longer holds. + if (load != loadAttempt) return@launch + if (!profileRepository.identityScopeUnchanged(scope)) { + // The displayed grid is gone, so its scope must go with it. + // Leaving a scope behind for an empty grid is stale metadata + // that a later selection could be qualified against. + gridScope = null + _uiState.update { it.copy(isLoading = false, profiles = emptyList()) } + return@launch + } + + when (result) { is ApiResult.Success -> { + // The scope moves with the grid, and ONLY with it. Assigning + // it before this point meant a reload that failed under a + // NEW identity left the OLD grid on screen qualified by the + // NEW scope — so picking a profile from the old session was + // accepted as belonging to the new one. That is worse than + // the unguarded commit this was meant to fix. + gridScope = scope _uiState.update { it.copy(isLoading = false, profiles = result.data, activeProfileId = activeId) } @@ -89,6 +133,17 @@ class ProfileSelectionViewModel( // In manage mode, tapping opens edit -- handled by the screen composable. return } + // A click can already be queued when a scope mismatch clears the grid. + // gridScope is null then, and passing it through would disable the + // repository guard. Accept only profiles in the grid that is still + // displayed; use the id because refreshed model instances need not be + // referentially identical to the card's captured value. + if (_uiState.value.profiles.none { it.id == profile.id }) return + + // Bump before branching: ANY accepted selection supersedes a + // verification still in flight, including picking an unprotected + // profile while a protected one is mid-verify. + pinAttempt++ if (profile.hasPin) { _uiState.update { @@ -99,7 +154,10 @@ class ProfileSelectionViewModel( ) } } else { - selectProfile(profile.id) + // Qualified by the grid's scope. An unprotected pick has no PIN + // round trip to re-establish identity, so without this it was the + // one path that committed unguarded. + selectProfile(profile.id, expectedScope = gridScope) } } @@ -108,15 +166,27 @@ class ProfileSelectionViewModel( */ fun onPinEntered(pin: String) { val profile = _uiState.value.pinDialogProfile ?: return + val attempt = ++pinAttempt viewModelScope.launch { _uiState.update { it.copy(pinIsVerifying = true, pinError = null) } - when (val result = profileRepository.verifyPin(profile.id, pin)) { + // Pin the answer to the identity that was asked, not just to the + // dialog target: the active scope can move underneath us. + val scope = profileRepository.captureIdentityScope() + val result = profileRepository.verifyPin(profile.id, pin) + // The user can cancel (or tap a different profile) while the round + // trip is in flight. Intent proven before a suspension point is not + // intent after it, so re-check ownership before acting: committing + // unconditionally meant Cancel still entered the profile. + if (attempt != pinAttempt) return@launch + + when (result) { is ApiResult.Success -> { - if (result.data.valid) { + val token = result.data.authorizedProfileToken() + if (token != null) { _uiState.update { it.copy(pinIsVerifying = false, pinDialogProfile = null) } - selectProfile(profile.id) + selectProfile(profile.id, token, scope) } else { _uiState.update { it.copy(pinIsVerifying = false, pinError = "Incorrect PIN") @@ -143,6 +213,9 @@ class ProfileSelectionViewModel( } fun dismissPinDialog() { + // Bump the generation so an in-flight verification for the dismissed + // profile can no longer commit. + pinAttempt++ _uiState.update { it.copy(pinDialogProfile = null, pinIsVerifying = false, pinError = null) } @@ -194,9 +267,32 @@ class ProfileSelectionViewModel( _uiState.update { it.copy(selectedProfileId = null) } } - private fun selectProfile(profileId: String) { + private fun selectProfile( + profileId: String, + profileToken: String? = null, + expectedScope: AuthScopeSnapshot? = null, + ) { viewModelScope.launch { - profileRepository.selectProfile(profileId) + val result = profileRepository.selectProfile(profileId, profileToken, expectedScope) + if (result == ProfileCommitResult.ScopeChanged) { + // Someone else owns the identity now. Drop everything bound to + // the identity we no longer have — a retained grid would let + // the user pick a profile belonging to the previous session, + // and that commit carries no scope to reject it. + _uiState.update { + it.copy( + profiles = emptyList(), + activeProfileId = null, + selectedProfileId = null, + pinDialogProfile = null, + pinIsVerifying = false, + pinError = null, + deleteDialogProfile = null, + ) + } + loadProfiles() + return@launch + } _uiState.update { it.copy(selectedProfileId = profileId) } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/recommendations/RecommendationsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/recommendations/RecommendationsScreen.kt index 3d9b93625..2e5955706 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/recommendations/RecommendationsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/recommendations/RecommendationsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -41,6 +42,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import org.prairieserver.prairie.android.ui.screens.personal.FavoritesGridContent import org.prairieserver.prairie.android.ui.screens.personal.WatchlistGridContent +import org.prairieserver.prairie.android.ui.screens.personal.PersonalListControlsRow +import org.prairieserver.prairie.android.ui.screens.personal.PersonalListSource +import org.prairieserver.prairie.android.ui.screens.personal.queryState +import org.prairieserver.prairie.android.ui.screens.personal.rememberPersonalListControls +import org.prairieserver.prairie.viewmodel.PersonalListUiState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -50,27 +56,44 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.screens.home.HomeSectionRow import org.prairieserver.prairie.viewmodel.RecommendationsViewModel +import org.prairieserver.prairie.android.ui.components.MediaRowsSkeleton import org.prairieserver.prairie.android.ui.navigation.LocalBottomChromeInset import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Refresh /** * Phone Recommendations ("For You") screen. * - * Mirrors iOS `RecommendationsView.swift` (phone) 1:1: a saved-shortcuts pill - * row (Watchlist / Favorites) above the recommendation section rows, the same - * SectionRow layout used by Home, iOS section spacing, and the iOS sparkles - * empty state. The screen title + actions header is supplied by the shared - * `MainAppTopBar` in `MainScreen` (matching iOS `TabTopBarActions`). + * Mirrors iOS `RecommendationsView.swift` (phone): a saved-shortcuts pill + * row (Watchlist / Favorites) above the feed, and the iOS sparkles empty + * state. The feed itself follows the Libraries "Recommended" shape — plain + * HomeSectionRow rows in server order, no hero carousel — so the browse + * surfaces read as one app. The screen title + actions header is supplied by the shared + * `MainAppTopBar` in `MainScreen` (matching iOS `TabTopBarActions`); the + * saved-list selection is hoisted there so the header title can name what is + * on screen (For You / Watchlist / Favorites). + * + * The pill row scrolls with the content rather than pinning, so nothing is + * clipped along a hard edge — rows slide under the header glass instead. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun RecommendationsScreen( onItemClick: (String) -> Unit, + savedListSelection: ForYouList?, + onSavedListSelectionChange: (ForYouList?) -> Unit, + /** + * What the screen is actually showing, for the header title. Differs from + * [savedListSelection] only in the empty-feed fallback, which shows the + * Watchlist without turning that into an explicit selection. + */ + onDisplayedListChange: (ForYouList?) -> Unit = {}, contentTopPadding: Dp = 0.dp, viewModel: RecommendationsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val inFallback = !state.isLoading && state.error == null && state.sections.isEmpty() + val displayedList = if (inFallback) savedListSelection ?: ForYouList.Watchlist else savedListSelection + LaunchedEffect(displayedList) { onDisplayedListChange(displayedList) } // Self-heal the "For You" fallback. The shared VM loads only in init{} and // survives tab switches (saveState/restoreState), so an empty server @@ -95,8 +118,22 @@ fun RecommendationsScreen( when { state.isLoading && state.sections.isEmpty() -> { - // iOS phone loading state is an empty (Color.clear) placeholder. - Box(modifier = Modifier.fillMaxSize().padding(top = contentTopPadding)) + // Skeleton in the shape of the feed (pill row + poster rows) so the + // tab is never a blank black page while recommendations load. + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = contentTopPadding + 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + SavedShortcutsRow( + onWatchlistClick = { onSavedListSelectionChange(ForYouList.Watchlist) }, + onFavoritesClick = { onSavedListSelectionChange(ForYouList.Favorites) }, + ) + } + MediaRowsSkeleton(rowCount = 3) + } } state.error != null && state.sections.isEmpty() -> { @@ -125,12 +162,6 @@ fun RecommendationsScreen( ) Spacer(modifier = Modifier.height(20.dp)) Button(onClick = { viewModel.loadRecommendations() }) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Retry") } } @@ -141,122 +172,158 @@ fun RecommendationsScreen( // (e.g. embeddings disabled), the shortcut row becomes an inline // selector — Watchlist by default — over the saved-list grid, // instead of navigating away or showing an empty promise. - var savedListSelection by rememberSaveable { mutableStateOf(SavedList.Watchlist) } - Column(modifier = Modifier.fillMaxSize()) { - Spacer(modifier = Modifier.height(contentTopPadding + 8.dp)) - SavedShortcutsRow( - onWatchlistClick = { savedListSelection = SavedList.Watchlist }, - onFavoritesClick = { savedListSelection = SavedList.Favorites }, - selection = savedListSelection, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "No recommendations yet — showing your saved titles.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - // Explicit retry so the fallback is recoverable in place — the - // embedded grids below carry their own pull-to-refresh, so we do - // NOT wrap them in another PullToRefreshBox (nesting misbehaves). - OutlinedButton( - onClick = { viewModel.refresh() }, - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), + val selection = savedListSelection ?: ForYouList.Watchlist + val header: @Composable () -> Unit = { + Column { + SavedShortcutsRow( + onWatchlistClick = { onSavedListSelectionChange(ForYouList.Watchlist) }, + onFavoritesClick = { onSavedListSelectionChange(ForYouList.Favorites) }, + selection = selection, ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Check again") - } - Spacer(modifier = Modifier.height(8.dp)) - when (savedListSelection) { - SavedList.Watchlist -> WatchlistGridContent( - onItemClick = onItemClick, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomChromeInset.current), - ) - SavedList.Favorites -> FavoritesGridContent( - onItemClick = onItemClick, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomChromeInset.current), + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No recommendations yet — showing your saved titles.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(modifier = Modifier.height(8.dp)) + // Explicit retry so the fallback is recoverable in place — the + // embedded grids carry their own pull-to-refresh, so we do + // NOT wrap them in another PullToRefreshBox (nesting misbehaves). + OutlinedButton(onClick = { viewModel.refresh() }) { + Text("Check again") + } } } + SavedListGrid( + list = selection, + onItemClick = onItemClick, + contentTopPadding = contentTopPadding, + header = header, + ) } else -> { // Watchlist / Favorites toggle IN PLACE over the recommendations feed // instead of navigating to a separate page (Jim 2026-07-09 — a // deliberate divergence from iOS, which navigates when recs exist). - // The pill row is pinned above the content so it is always reachable; - // null selection shows the recommendation sections, and re-tapping the - // active pill returns to them. - var savedListSelection by rememberSaveable { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { - Spacer(modifier = Modifier.height(contentTopPadding + 8.dp)) + // The pill row leads the content and scrolls with it; null selection + // shows the recommendation sections, and re-tapping the active pill + // returns to them. + val pills: @Composable () -> Unit = { SavedShortcutsRow( onWatchlistClick = { - savedListSelection = - if (savedListSelection == SavedList.Watchlist) null else SavedList.Watchlist + onSavedListSelectionChange( + if (savedListSelection == ForYouList.Watchlist) null else ForYouList.Watchlist, + ) }, onFavoritesClick = { - savedListSelection = - if (savedListSelection == SavedList.Favorites) null else SavedList.Favorites + onSavedListSelectionChange( + if (savedListSelection == ForYouList.Favorites) null else ForYouList.Favorites, + ) }, selection = savedListSelection, - modifier = Modifier.padding(horizontal = 16.dp), ) - Spacer(modifier = Modifier.height(8.dp)) - when (savedListSelection) { - SavedList.Watchlist -> WatchlistGridContent( - onItemClick = onItemClick, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomChromeInset.current), - ) - SavedList.Favorites -> FavoritesGridContent( - onItemClick = onItemClick, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomChromeInset.current), - ) - null -> PullToRefreshBox( - isRefreshing = state.isRefreshing, - onRefresh = { viewModel.refresh() }, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), + } + when (savedListSelection) { + null -> PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + // Content starts under the header glass and keeps room for + // the floating bottom nav while preserving iOS section + // rhythm inside the list. Top = header + the grid's own + // 16dp inset so the pills sit at the same y in both modes. + contentPadding = PaddingValues( + top = contentTopPadding + 16.dp, + bottom = 24.dp + LocalBottomChromeInset.current, + ), + // iOS sectionSpacing (phone) = largePadding (24). + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - // Keep room for the floating bottom nav while preserving - // iOS section rhythm inside the list. - contentPadding = PaddingValues(bottom = 24.dp + LocalBottomChromeInset.current), - // iOS sectionSpacing (phone) = largePadding (24). - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - items( - items = state.sections, - key = { it.id }, - ) { section -> - HomeSectionRow( - section = section, - onItemClick = onItemClick, - ) - } + item(key = "savedShortcuts") { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { pills() } + } + items( + items = state.sections, + key = { it.id }, + ) { section -> + // No "See All" — iOS has no such affordance, so the + // row omits it when onSeeAllClick is null. + HomeSectionRow( + section = section, + onItemClick = onItemClick, + ) } } } + else -> SavedListGrid( + list = savedListSelection, + onItemClick = onItemClick, + contentTopPadding = contentTopPadding, + header = pills, + ) } } } } -/** Which saved list the empty-state fallback is showing. */ -private enum class SavedList { Watchlist, Favorites } +/** Which saved list For You is showing; null is the recommendations feed. */ +enum class ForYouList { Watchlist, Favorites } + +/** Header title for the current For You content. */ +fun ForYouList?.headerTitle(): String = when (this) { + null -> "For You" + ForYouList.Watchlist -> "Watchlist" + ForYouList.Favorites -> "Favorites" +} + +@Composable +private fun SavedListGrid( + list: ForYouList, + onItemClick: (String) -> Unit, + contentTopPadding: Dp, + header: @Composable () -> Unit, +) { + val contentPadding = PaddingValues( + top = contentTopPadding, + bottom = 24.dp + LocalBottomChromeInset.current, + ) + // Sort/filter controls (TV parity), shared with the standalone + // Watchlist / Favorites screens through the activity-scoped holder. + val source = when (list) { + ForYouList.Watchlist -> PersonalListSource.Watchlist + ForYouList.Favorites -> PersonalListSource.Favorites + } + val controls = rememberPersonalListControls(source) + val query by controls.queryState() + val gridHeader: @Composable (PersonalListUiState) -> Unit = { state -> + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + header() + PersonalListControlsRow(controls = controls, total = state.total) + } + } + when (list) { + ForYouList.Watchlist -> WatchlistGridContent( + onItemClick = onItemClick, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + query = query, + header = gridHeader, + ) + ForYouList.Favorites -> FavoritesGridContent( + onItemClick = onItemClick, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + query = query, + header = gridHeader, + ) + } +} + /** * Watchlist / Favorites pill row. Mirrors iOS `SavedShortcutsRow` (phone): @@ -269,7 +336,7 @@ private fun SavedShortcutsRow( onFavoritesClick: () -> Unit, modifier: Modifier = Modifier, /** Non-null renders the pills as an inline selector (fallback mode). */ - selection: SavedList? = null, + selection: ForYouList? = null, ) { Row( modifier = modifier.fillMaxWidth(), @@ -280,13 +347,13 @@ private fun SavedShortcutsRow( title = "Watchlist", icon = Icons.Filled.Bookmark, onClick = onWatchlistClick, - selected = selection == SavedList.Watchlist, + selected = selection == ForYouList.Watchlist, ) SavedShortcutPill( title = "Favorites", icon = Icons.Filled.Favorite, onClick = onFavoritesClick, - selected = selection == SavedList.Favorites, + selected = selection == ForYouList.Favorites, ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchBar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchBar.kt index d23afac6d..720f7e5af 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchBar.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchBar.kt @@ -1,5 +1,13 @@ package org.prairieserver.prairie.android.ui.screens.search +import android.content.ActivityNotFoundException +import android.content.Intent +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -7,6 +15,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -16,20 +25,62 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp +/** Builds the free-form speech recogniser intent used by voice search. */ +private fun voiceSearchIntent(): Intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, "Search Prairie") + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + } + /** - * Search text field with an icon, placeholder, and clear button. + * Remembers whether this device can service a speech-recognition intent. + * + * Hoisted out of [SearchBar] because the empty state also words itself + * differently when there is no microphone affordance to point at. The state is + * mutable so a launch that still fails with [ActivityNotFoundException] can + * retire the affordance for the rest of the session. + */ +@Composable +fun rememberVoiceSearchAvailability(): MutableState { + val context = LocalContext.current + return remember(context) { + val available = SpeechRecognizer.isRecognitionAvailable(context) || + context.packageManager.resolveActivity(voiceSearchIntent(), 0) != null + mutableStateOf(available) + } +} + +/** + * Search text field with an icon, placeholder, voice input, and clear button. + * + * The trailing area is always `[mic][clear-if-non-empty]` so voice search stays + * one tap away regardless of what has been typed. * * @param query The current search query text. * @param onQueryChanged Callback as the user types. * @param onClear Callback when the clear button is tapped. + * @param onVoiceQuery Callback with a recognised spoken query, already trimmed + * and guaranteed non-blank. Implementations should search immediately. + * @param voiceAvailable Whether the microphone affordance should be shown. + * @param onVoiceUnavailable Called when launching the recogniser failed, so the + * caller can retire the affordance. * @param autoFocus Whether to auto-focus the text field on first composition. * @param modifier Compose modifier. */ @@ -38,9 +89,13 @@ fun SearchBar( query: String, onQueryChanged: (String) -> Unit, onClear: () -> Unit, + onVoiceQuery: (String) -> Unit, + voiceAvailable: Boolean, + onVoiceUnavailable: () -> Unit, autoFocus: Boolean = true, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -50,16 +105,32 @@ fun SearchBar( } } + val voiceLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + ?.trim() + .orEmpty() + if (spoken.isNotBlank()) { + // Deliberately no focus request: the results should be visible + // straight away rather than hidden behind the keyboard. + keyboardController?.hide() + onVoiceQuery(spoken) + } + } + OutlinedTextField( value = query, onValueChange = onQueryChanged, modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 4.dp) .focusRequester(focusRequester), placeholder = { Text( - text = "Search Prairie", + text = "Search movies, shows, and more", style = MaterialTheme.typography.bodyLarge, ) }, @@ -71,25 +142,53 @@ fun SearchBar( ) }, trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = onClear) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = "Clear search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (voiceAvailable) { + IconButton( + onClick = { + try { + voiceLauncher.launch(voiceSearchIntent()) + } catch (_: ActivityNotFoundException) { + onVoiceUnavailable() + Toast.makeText( + context, + "Voice search isn't available on this device", + Toast.LENGTH_SHORT, + ).show() + } + }, + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Search by voice", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } }, singleLine = true, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(28.dp), colors = OutlinedTextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrect = false, + imeAction = ImeAction.Search, + ), keyboardActions = KeyboardActions( onSearch = { keyboardController?.hide() }, ), diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt index 3ca96ae39..a87861348 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchResults.kt @@ -20,9 +20,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import org.prairieserver.prairie.android.ui.components.MediaCard import org.prairieserver.prairie.android.ui.components.MediaGridDefaults import org.prairieserver.prairie.android.ui.components.rememberBrowseItemCardActions @@ -53,6 +53,17 @@ fun SearchResults( footer: (@Composable () -> Unit)? = null, ) { val gridState = rememberLazyGridState() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + // Scrolling the results is a clear signal the user is done typing: get the + // keyboard out of the way so more of the grid is visible. + LaunchedEffect(gridState.isScrollInProgress) { + if (gridState.isScrollInProgress) { + keyboardController?.hide() + focusManager.clearFocus() + } + } // Trigger load more when scrolled near bottom val shouldLoadMore by remember { @@ -70,7 +81,7 @@ fun SearchResults( LazyVerticalGrid( columns = GridCells.Adaptive(MediaGridDefaults.PosterGridMinWidth), state = gridState, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), horizontalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridHorizontalSpacing), verticalArrangement = Arrangement.spacedBy(MediaGridDefaults.PosterGridVerticalSpacing), modifier = modifier, @@ -79,10 +90,11 @@ fun SearchResults( item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-result-count") { Text( text = "$total result${if (total == 1) "" else "s"}", - fontSize = 12.sp, - fontWeight = FontWeight.Normal, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp), + // The grid's own contentPadding supplies the 16.dp gutters, so + // the header only needs to clear the first row of cards. + modifier = Modifier.padding(horizontal = 2.dp, vertical = 4.dp), ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt index f6a73cbdb..d43ddf63f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchScreen.kt @@ -12,6 +12,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -60,6 +63,8 @@ fun SearchScreen( initialMediaType: MobileSearchMediaType? = null, ) { val state by viewModel.uiState.collectAsState() + val voiceAvailableState = rememberVoiceSearchAvailability() + val voiceAvailable = voiceAvailableState.value val personalDataRepository: PersonalDataRepository = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() @@ -107,8 +112,18 @@ fun SearchScreen( query = state.query, onQueryChanged = { viewModel.onQueryChanged(it) }, onClear = { viewModel.clearSearch() }, + onVoiceQuery = { viewModel.onVoiceQuery(it) }, + voiceAvailable = voiceAvailable, + onVoiceUnavailable = { voiceAvailableState.value = false }, ) + if (state.isSearching) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + ) + } + if (state.query.isNotBlank() && state.availableMediaTypes.size > 1) { SingleChoiceSegmentedButtonRow( modifier = Modifier @@ -127,7 +142,7 @@ fun SearchScreen( ) } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) } @Composable @@ -181,14 +196,34 @@ fun SearchScreen( when { state.isSearching && state.results.isEmpty() -> { - // iOS shows a blank surface (Color.clear) while the first - // page is in flight — no spinner. - Box(modifier = Modifier.fillMaxSize()) + // Sits in the top part of the content area, matching the + // empty state's offset, so it stays visible above the IME + // instead of being centred in the space the keyboard covers. + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(80.dp)) + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Searching…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } !state.hasSearched && state.query.isBlank() -> { SearchEmptyState( text = "Search Prairie", - subtitle = "Find movies, shows, books, audio, and people.", + subtitle = if (voiceAvailable) { + "Find movies, shows, books, audio, and people. " + + "Tap the mic to search by voice." + } else { + "Find movies, shows, books, audio, and people." + }, ) } state.error != null && state.results.isEmpty() -> { @@ -212,6 +247,10 @@ fun SearchScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) + Spacer(modifier = Modifier.height(12.dp)) + FilledTonalButton(onClick = { viewModel.retry() }) { + Text("Retry") + } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt index f65e9466a..132079f2e 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/search/SearchViewModel.kt @@ -6,6 +6,7 @@ import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.mobileMediaModeForLibraryType import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.CatalogRepository import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -111,6 +112,13 @@ class SearchViewModel( private val pageSize = 60 + /** + * A voice query that [onVoiceQuery] already searched for immediately. The + * debounced collector consumes and skips it once so the same query is not + * requested twice. + */ + private var pendingVoiceQuery: String? = null + init { // Debounce search queries viewModelScope.launch { @@ -118,6 +126,10 @@ class SearchViewModel( .debounce(300) .distinctUntilChanged() .collectLatest { query -> + if (query.isNotBlank() && query == pendingVoiceQuery) { + pendingVoiceQuery = null + return@collectLatest + } if (query.isBlank()) { _uiState.update { it.copy( @@ -142,14 +154,41 @@ class SearchViewModel( * Called as the user types in the search field. */ fun onQueryChanged(query: String) { + // Typing supersedes any voice query still waiting to be skipped by the + // debounce, so a later identical keystroke run is searched normally. + pendingVoiceQuery = null _uiState.update { it.copy(query = query) } _queryFlow.value = query } + /** + * Accepts a query dictated through speech recognition. Unlike typing, the + * user has already committed to the phrase, so this searches immediately + * instead of waiting out the debounce window. + */ + fun onVoiceQuery(query: String) { + val trimmed = query.trim() + if (trimmed.isBlank()) return + pendingVoiceQuery = trimmed + _uiState.update { it.copy(query = trimmed) } + _queryFlow.value = trimmed + viewModelScope.launch { performSearch(trimmed, reset = true) } + } + + /** + * Re-runs the current query after a failure. + */ + fun retry() { + val query = _uiState.value.query + if (query.isBlank()) return + viewModelScope.launch { performSearch(query, reset = true) } + } + /** * Clears the search query and results. */ fun clearSearch() { + pendingVoiceQuery = null _uiState.update { it.copy( query = "", @@ -283,7 +322,7 @@ class SearchViewModel( _uiState.update { it.copy( isSearching = false, - error = "Network error. Check your connection.", + error = result.errorMessage("Search failed"), hasSearched = true, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt index 389b98b5b..9e2a816f5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt @@ -8,81 +8,87 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Security -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.prairieserver.prairie.model.auth.AuthSession +import org.prairieserver.prairie.android.ui.components.SignOutConfirmDialog +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.Spacing import org.prairieserver.prairie.model.auth.User /** - * Settings section showing user account info, session management, and sign out. + * Settings section showing user account info, device pairing, and sign out. + * + * Session management and the admin surface are deliberately absent: both were + * removed from the Android clients outright — phone, TV, and the shared code + * that served them — not merely hidden behind a gate. */ @Composable fun AccountSection( user: User?, isLoadingUser: Boolean, - onManageSessions: () -> Unit, onPairDevice: () -> Unit, onSignOut: () -> Unit, modifier: Modifier = Modifier, - isAdminVisible: Boolean = false, - onAdmin: () -> Unit = {}, // iOS parity: the account header is a button that opens profile // selection ("Tap to switch profile") — the chevron was previously dead. onSwitchProfile: () -> Unit = {}, ) { + var confirmSignOut by rememberSaveable { mutableStateOf(false) } + SettingsSectionCard(modifier = modifier) { if (isLoadingUser) { Box( modifier = Modifier .fillMaxWidth() - .padding(24.dp), + .padding(Spacing.xxl), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + CircularProgressIndicator( + modifier = Modifier.size(Spacing.xxl), + strokeWidth = 2.dp, + ) } } else if (user != null) { + // Claims the card's first row slot, so the row below it still + // draws its hairline. + val headerDivider = settingsRowDividerVisible() Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = SettingsDimens.rowMinHeight) + .settingsRowDivider(headerDivider) .clickable(onClick = onSwitchProfile) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = SettingsDimens.rowHorizontalPadding, + vertical = SettingsDimens.rowVerticalPadding, + ), verticalAlignment = Alignment.CenterVertically, ) { // Avatar — iOS ProfileAvatarView size 56. Box( modifier = Modifier - .size(56.dp) + .size(SettingsDimens.avatarSize) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)), contentAlignment = Alignment.Center, @@ -91,192 +97,58 @@ fun AccountSection( imageVector = Icons.Default.Person, contentDescription = null, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(30.dp), + modifier = Modifier.size(SettingsDimens.avatarIconSize), ) } - // iOS HStack spacing 14. - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(SettingsDimens.avatarGap)) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(3.dp), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), ) { - // iOS .title3.weight(.semibold) ≈ 20pt semibold. Text( text = user.username, - fontSize = 20.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + style = SettingsTextStyles.accountName, + color = PrairieForeground, maxLines = 1, ) - // iOS .footnote subtitle line in secondary color. Text( text = user.email, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = SettingsTextStyles.rowDescription, + color = PrairieMutedText, maxLines = 1, ) } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(18.dp), - ) + SettingsRowChevron() } - SettingsRowLabel( - title = "Quick Connect", - icon = Icons.Default.Devices, - badgeColor = SettingsBadgeTeal, + SettingsNavigationRow( + label = "Pair device", + description = "Link a TV or another device to this account.", onClick = onPairDevice, - showChevron = true, ) - // Apple-parity admin surface: stats dashboard only, role-gated. - if (isAdminVisible) { - SettingsRowLabel( - title = "Admin", - icon = Icons.Default.Security, - badgeColor = SettingsBadgeGray, - onClick = onAdmin, - showChevron = true, - ) - } - SettingsRowLabel( - title = "Manage Sessions", - icon = Icons.Default.Security, - badgeColor = SettingsBadgeGray, - onClick = onManageSessions, - showChevron = true, + SettingsDestructiveRow( + label = "Sign out", + description = "Sign this device out of ${user.username}'s account.", + onClick = { confirmSignOut = true }, ) - // iOS "Sign Out": its own section, centered destructive text. - Box( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onSignOut) - .padding(horizontal = 16.dp, vertical = 11.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Sign Out", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - } - } - } -} - -/** - * Bottom sheet showing active login sessions with revoke capability. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SessionsSheet( - sheetState: SheetState, - sessions: List, - isLoading: Boolean, - onRevokeSession: (String) -> Unit, - onDismiss: () -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 32.dp), - ) { - Text( - text = "Active Sessions", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + // Same dialog the profile menu raises, so the answer to "are you + // sure?" does not depend on which of the two routes was taken. + SignOutConfirmDialog( + visible = confirmSignOut, + accountName = user.username, + onConfirm = { + confirmSignOut = false + onSignOut() + }, + onDismiss = { confirmSignOut = false }, ) - - Spacer(modifier = Modifier.height(16.dp)) - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - } - } else if (sessions.isEmpty()) { - Text( - text = "No active sessions", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 16.dp), - ) - } else { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(sessions, key = { it.id }) { session -> - SessionItem( - session = session, - onRevoke = { onRevokeSession(session.id) }, - ) - } - } - } - } - } -} - -@Composable -private fun SessionItem( - session: AuthSession, - onRevoke: () -> Unit, -) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - shape = MaterialTheme.shapes.small, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = session.deviceName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = session.ipAddress, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - IconButton(onClick = onRevoke) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Revoke session", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp), - ) - } } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/CardOverlaySettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/CardOverlaySettingsScreen.kt deleted file mode 100644 index 94bbddb1e..000000000 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/CardOverlaySettingsScreen.kt +++ /dev/null @@ -1,898 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.settings - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.Divider -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.android.ui.components.PrairieTopBar -import org.prairieserver.prairie.common.overlays.CardOverlayVariant -import org.prairieserver.prairie.common.overlays.CardOverlays -import org.prairieserver.prairie.common.settings.OverlayPrefsStore -import org.prairieserver.prairie.overlays.CardOverlayPrefs -import org.prairieserver.prairie.overlays.OverlayAccentPalette -import org.prairieserver.prairie.overlays.OverlayCategory -import org.prairieserver.prairie.overlays.OverlayData -import org.prairieserver.prairie.overlays.OverlayDef -import org.prairieserver.prairie.overlays.OverlayId -import org.prairieserver.prairie.overlays.OverlayItemConfig -import org.prairieserver.prairie.overlays.OverlayPosition -import org.prairieserver.prairie.overlays.OverlayRegistry -import org.prairieserver.prairie.overlays.OverlaySchema -import org.prairieserver.prairie.overlays.PresetId -import kotlinx.coroutines.launch -import androidx.compose.material.icons.filled.RestartAlt - -/** - * Phone Card Overlays settings. Android port of Apple's - * `CardOverlaySettingsView`. Layout principles, in order of importance: - * - * 1. Live preview, always visible: the poster card sits at the top so the - * user sees every change immediately. - * 2. Glanceable category navigation: a segmented picker filters the long - * overlay list to one category at a time. - * 3. Per-overlay badge preview: every row shows the actual rendered badge - * it would produce, with the user's current preset + accent applied. - * 4. Inline disclosure: tapping a row expands it to reveal the visual - * corner picker + accent swatches + icon toggle. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CardOverlaySettingsScreen( - store: OverlayPrefsStore, - onBackClick: () -> Unit, -) { - val enabled by store.enabled.collectAsState() - val prefs by store.prefs.collectAsState() - - var sampleVariant by remember { mutableStateOf(OverlaySampleVariant.Movie) } - var category by remember { mutableStateOf(OverlayCategory.Tech) } - var expandedOverlay by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { store.hydrateIfNeeded() } - - Scaffold( - topBar = { - PrairieTopBar(title = "Card Overlays", onBackClick = onBackClick) - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(bottom = 40.dp), - ) { - // --- Sticky-ish live preview pane --- - item { - PreviewPane( - enabled = enabled, - prefs = prefs, - sampleVariant = sampleVariant, - onSampleVariantChange = { sampleVariant = it }, - onPresetChange = { newPreset -> - store.setPrefs(prefs.copy(preset = newPreset)) - }, - ) - } - - if (!enabled) { - item { DisabledBanner() } - } - - // --- Category picker --- - item { - CategoryPicker( - selected = category, - onSelected = { category = it }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - - item { - Text( - text = category.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), - ) - } - - // --- Overlay rows --- - items(OverlayRegistry.defs(category)) { def -> - OverlayRow( - def = def, - prefs = prefs, - sampleData = sampleVariant.data, - enabledGlobally = enabled, - isExpanded = expandedOverlay == def.id, - onToggleExpand = { - expandedOverlay = if (expandedOverlay == def.id) null else def.id - }, - onUpdate = { next -> store.setPrefs(next) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - // --- Reset footer --- - item { - ResetFooter( - hasUserOverride = store.hasUserOverride, - store = store, - ) - } - } - } -} - -// extension to allow items(List) without explicit import collision -private inline fun androidx.compose.foundation.lazy.LazyListScope.items( - list: List, - crossinline itemContent: @Composable androidx.compose.foundation.lazy.LazyItemScope.(T) -> Unit, -) = items(count = list.size) { index -> itemContent(list[index]) } - -// MARK: - Preview pane - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PreviewPane( - enabled: Boolean, - prefs: CardOverlayPrefs, - sampleVariant: OverlaySampleVariant, - onSampleVariantChange: (OverlaySampleVariant) -> Unit, - onPresetChange: (PresetId) -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), - MaterialTheme.colorScheme.background, - ), - ), - ) - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - PreviewPoster(enabled = enabled, prefs = prefs, sampleData = sampleVariant.data) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - SegmentedRow( - options = OverlaySampleVariant.entries.toList(), - selected = sampleVariant, - label = { it.label }, - onSelected = onSampleVariantChange, - ) - - PresetMenu(selected = prefs.preset, onSelected = onPresetChange) - - Text( - text = prefs.preset.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) -} - -@Composable -private fun PreviewPoster( - enabled: Boolean, - prefs: CardOverlayPrefs, - sampleData: OverlayData, -) { - Box( - modifier = Modifier - .width(120.dp) - .aspectRatio(2f / 3f) - .clip(RoundedCornerShape(12.dp)) - .background( - Brush.linearGradient( - colors = listOf( - Color(0xFF525252), - Color(0xFF2E2E2E), - Color(0xFF141414), - ), - ), - ), - ) { - if (enabled) { - CardOverlays( - data = sampleData, - prefs = prefs, - variant = CardOverlayVariant.Poster, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun PresetMenu( - selected: PresetId, - onSelected: (PresetId) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box { - Row( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .border( - BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)), - RoundedCornerShape(8.dp), - ) - .clickable { expanded = true } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = selected.label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Icon( - imageVector = Icons.Filled.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - PresetId.entries.forEach { preset -> - DropdownMenuItem( - text = { Text(preset.label) }, - onClick = { - onSelected(preset) - expanded = false - }, - ) - } - } - } -} - -// MARK: - Category picker (segmented) - -@Composable -private fun CategoryPicker( - selected: OverlayCategory, - onSelected: (OverlayCategory) -> Unit, - modifier: Modifier = Modifier, -) { - SegmentedRow( - options = OverlayCategory.entries.toList(), - selected = selected, - label = { shortLabel(it) }, - onSelected = onSelected, - modifier = modifier, - ) -} - -private fun shortLabel(category: OverlayCategory): String = - when (category) { - OverlayCategory.Tech -> "Tech" - OverlayCategory.Ratings -> "Ratings" - OverlayCategory.Metadata -> "Info" - OverlayCategory.Ribbons -> "Ribbons" - } - -/** A lightweight segmented control matching the iOS `.pickerStyle(.segmented)`. */ -@Composable -private fun SegmentedRow( - options: List, - selected: T, - label: (T) -> String, - onSelected: (T) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(2.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - options.forEach { option -> - val isSelected = option == selected - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(6.dp)) - .background( - if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) - } else { - Color.Transparent - }, - ) - .clickable { onSelected(option) } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = label(option), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } -} - -// MARK: - Disabled banner - -@Composable -private fun DisabledBanner() { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Filled.Lock, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Text( - text = "Overlays disabled by your server administrator.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -// MARK: - Overlay row - -@Composable -private fun OverlayRow( - def: OverlayDef, - prefs: CardOverlayPrefs, - sampleData: OverlayData, - enabledGlobally: Boolean, - isExpanded: Boolean, - onToggleExpand: () -> Unit, - onUpdate: (CardOverlayPrefs) -> Unit, - modifier: Modifier = Modifier, -) { - val config = prefs.items[def.id] ?: OverlayItemConfig( - enabled = def.defaultEnabled, - position = def.defaultPosition, - accentColor = null, - showIcon = null, - ) - - fun write(mutate: (OverlayItemConfig) -> OverlayItemConfig) { - val items = prefs.items.toMutableMap() - items[def.id] = mutate(config) - onUpdate(prefs.copy(items = items)) - } - - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background( - MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = if (isExpanded) 0.55f else 0.35f, - ), - ) - .border( - BorderStroke(1.dp, Color.White.copy(alpha = 0.06f)), - RoundedCornerShape(14.dp), - ) - .animateContentSize() - .padding(14.dp), - ) { - // Header row: toggle + label/desc + badge preview + chevron - Row(verticalAlignment = Alignment.CenterVertically) { - Switch( - checked = config.enabled, - onCheckedChange = { newValue -> write { it.copy(enabled = newValue) } }, - enabled = enabledGlobally, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - ), - ) - Spacer(Modifier.width(12.dp)) - Row( - modifier = Modifier - .weight(1f) - .clickable(enabled = enabledGlobally, onClick = onToggleExpand), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = def.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = def.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - } - Spacer(Modifier.width(8.dp)) - BadgePreview(def = def, prefs = prefs, config = config, sampleData = sampleData) - Spacer(Modifier.width(8.dp)) - Icon( - imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } - - AnimatedVisibility(visible = isExpanded) { - Column( - modifier = Modifier - .fillMaxWidth() - .alpha(if (config.enabled) 1f else 0.5f), - ) { - Divider( - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - modifier = Modifier.padding(vertical = 12.dp), - ) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column(modifier = Modifier.width(120.dp)) { - Text( - text = "Position", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(6.dp)) - OverlayPositionGrid( - selection = config.position, - onSelect = { pos -> write { it.copy(position = pos) } }, - accent = config.accentColor?.let { hexToColor(it) } - ?: def.defaultAccent?.let { hexToColor(it) } - ?: Color.White, - width = 72.dp, - enabled = config.enabled, - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - if (def.iconCapable) { - IconToggle( - preset = prefs.preset, - showIcon = config.showIcon, - enabled = config.enabled, - onChange = { resolved, preferIcon -> - write { - it.copy(showIcon = if (resolved == preferIcon) null else resolved) - } - }, - ) - } - AccentPicker( - selectedHex = config.accentColor, - enabled = config.enabled, - onSelect = { hex -> write { it.copy(accentColor = hex) } }, - onClear = { write { it.copy(accentColor = null) } }, - ) - } - } - def.availabilityNote?.let { note -> - Spacer(Modifier.height(10.dp)) - Text( - text = note, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -/** - * Renders the actual badge this overlay would produce. Builds a standalone - * single-item prefs document (defaults + this overlay enabled, with its - * accent/icon override) so the chip reflects THIS overlay only, mirroring - * Apple's `previewPrefs`. - */ -@Composable -private fun BadgePreview( - def: OverlayDef, - prefs: CardOverlayPrefs, - config: OverlayItemConfig, - sampleData: OverlayData, -) { - // Only render if the overlay would resolve a value for the sample data. - if (def.getValue(sampleData).isNullOrBlank() && def.getIcon?.invoke(sampleData) == null) { - return - } - val previewItem = config.copy(enabled = true, position = OverlayPosition.TopLeft) - val previewPrefs = OverlaySchema.buildDefaults().copy( - preset = prefs.preset, - items = OverlayRegistry.defs(def.category) - .associate { d -> - d.id to OverlayItemConfig( - enabled = d.id == def.id, - position = OverlayPosition.TopLeft, - accentColor = if (d.id == def.id) config.accentColor else null, - showIcon = if (d.id == def.id) config.showIcon else null, - ) - } - .toMutableMap() - .apply { put(def.id, previewItem) }, - ) - Box( - modifier = Modifier - .height(36.dp) - .width(72.dp), - contentAlignment = Alignment.Center, - ) { - CardOverlays( - data = sampleData, - prefs = previewPrefs, - variant = CardOverlayVariant.Poster, - ) - } -} - -@Composable -private fun IconToggle( - preset: PresetId, - showIcon: Boolean?, - enabled: Boolean, - onChange: (resolved: Boolean, preferIcon: Boolean) -> Unit, -) { - val preferIcon = presetPrefersIcon(preset) - val resolved = showIcon ?: preferIcon - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Show icon", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Switch( - checked = resolved, - onCheckedChange = { newValue -> onChange(newValue, preferIcon) }, - enabled = enabled, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - ), - ) - } -} - -@Composable -private fun AccentPicker( - selectedHex: String?, - enabled: Boolean, - onSelect: (String) -> Unit, - onClear: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Accent", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - if (selectedHex != null) { - TextButton(onClick = onClear, enabled = enabled) { - Text("Default", style = MaterialTheme.typography.labelMedium) - } - } - } - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - OverlayAccentPalette.entries.forEach { entry -> - val isSelected = selectedHex?.lowercase() == entry.hex.lowercase() - val scale by animateFloatAsState(if (isSelected) 1.05f else 1f, label = "swatch") - Box( - modifier = Modifier - .scale(scale) - .size(28.dp) - .clip(CircleShape) - .background(hexToColor(entry.hex)) - .border( - BorderStroke( - width = if (isSelected) 3.dp else 1.dp, - color = if (isSelected) Color.White else Color.White.copy(alpha = 0.2f), - ), - CircleShape, - ) - .clickable(enabled = enabled) { onSelect(entry.hex) }, - ) - } - } - } -} - -// MARK: - Reset footer - -@Composable -private fun ResetFooter( - hasUserOverride: Boolean, - store: OverlayPrefsStore, -) { - val scope = androidx.compose.runtime.rememberCoroutineScope() - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - TextButton( - onClick = { scope.launch { store.resetToDefaults() } }, - enabled = hasUserOverride, - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Reset to Defaults", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.error, - ) - } - Text( - text = if (hasUserOverride) { - "Clears your overrides and falls back to the server's baseline." - } else { - "Using the server's baseline overlays." - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -// MARK: - Position grid (Compose port of OverlayPositionGrid.swift) - -@Composable -private fun OverlayPositionGrid( - selection: OverlayPosition, - onSelect: (OverlayPosition) -> Unit, - accent: Color, - width: Dp, - enabled: Boolean, -) { - val height = width * 1.5f - Box( - modifier = Modifier - .width(width) - .height(height) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .border( - BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)), - RoundedCornerShape(12.dp), - ), - ) { - OverlayPosition.entries.forEach { position -> - CornerDot( - position = position, - selected = selection == position, - accent = accent, - enabled = enabled, - onClick = { onSelect(position) }, - modifier = Modifier.align(alignmentFor(position)), - ) - } - } -} - -@Composable -private fun CornerDot( - position: OverlayPosition, - selected: Boolean, - accent: Color, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier.padding(8.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(18.dp) - .clip(CircleShape) - .background(if (selected) accent else Color.White.copy(alpha = 0.18f)) - .border( - BorderStroke( - width = if (selected) 2.dp else 1.dp, - color = if (selected) Color.White else Color.White.copy(alpha = 0.35f), - ), - CircleShape, - ) - .clickable(enabled = enabled, onClick = onClick), - ) - } -} - -private fun alignmentFor(position: OverlayPosition): Alignment = - when (position) { - OverlayPosition.TopLeft -> Alignment.TopStart - OverlayPosition.TopRight -> Alignment.TopEnd - OverlayPosition.BottomLeft -> Alignment.BottomStart - OverlayPosition.BottomRight -> Alignment.BottomEnd - } - -// MARK: - Helpers - -/** - * Mirrors the per-preset icon preference from the android-shared renderer - * (`OverlayPresetStyles`), which is internal to that module. Keep in sync. - */ -private fun presetPrefersIcon(preset: PresetId): Boolean = - when (preset) { - PresetId.Vibrant, PresetId.Pill -> true - PresetId.Minimal, PresetId.Classic, PresetId.Square -> false - } - -/** Parses `#rgb`, `#rrggbb`, or `#aarrggbb`; falls back to white on error. */ -private fun hexToColor(hex: String?): Color { - if (hex.isNullOrBlank()) return Color.White - val cleaned = (if (hex.startsWith("#")) hex.substring(1) else hex).trim() - val expanded = if (cleaned.length == 3) { - buildString { cleaned.forEach { append(it); append(it) } } - } else { - cleaned - } - return when (expanded.length) { - 6 -> { - val rgb = expanded.toLongOrNull(16) ?: return Color.White - Color(0xFF000000.toInt() or (rgb.toInt() and 0x00FFFFFF)) - } - 8 -> { - val argb = expanded.toLongOrNull(16) ?: return Color.White - Color(argb.toInt()) - } - else -> Color.White - } -} - -// MARK: - Sample data - -/** A representative sample bag so the preview shows real badges. */ -enum class OverlaySampleVariant(val label: String) { - Movie("Movie"), - Show("Show"), - ; - - val data: OverlayData - get() = when (this) { - Movie -> OverlayData( - resolution = "4K", - hdr = "DV", - audio = "Atmos", - audioChannels = "7.1", - videoCodec = "HEVC", - container = "MKV", - aspectRatio = "2.39:1", - releaseType = "BluRay", - edition = "Director's Cut", - multiAudio = true, - multiSub = true, - ratingImdb = 8.7, - ratingTmdb = 8.4, - ratingRtCritic = 94, - ratingRtAudience = 91, - contentRating = "PG-13", - year = 2024, - runtime = 142, - originalLanguage = "EN", - studio = "Warner Bros.", - imdbTop250 = 42, - rtCertifiedFresh = true, - ) - Show -> OverlayData( - resolution = "1080p", - hdr = "HDR10", - audio = "DD+", - audioChannels = "5.1", - videoCodec = "H.264", - container = "MKV", - aspectRatio = "16:9", - releaseType = "WEB-DL", - multiAudio = true, - multiSub = true, - ratingImdb = 9.1, - ratingTmdb = 8.8, - ratingRtCritic = 97, - ratingRtAudience = 89, - contentRating = "TV-MA", - year = 2023, - runtime = 52, - originalLanguage = "EN", - network = "HBO", - showStatus = "Returning", - ) - } -} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt index d0395e2c0..365a668fd 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt @@ -1,23 +1,20 @@ package org.prairieserver.prairie.android.ui.screens.settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.annotation.StringRes import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -private val qualityOptions = listOf("Auto", "Original", "4K", "1080p", "720p", "480p") -private val languageOptions = listOf("Default", "English", "Spanish", "French", "German", "Japanese", "Korean", "Chinese", "Portuguese", "Italian", "Russian") +import androidx.compose.ui.res.stringResource +import org.prairieserver.prairie.android.R +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys + +// Quality is two settings behind one picker: playback.preferred_quality (a +// resolution cap) and playback.max_bitrate_kbps (a bandwidth cap, null = +// uncapped). The preset table is shared with the TV app and mirrors the web +// client's, so the same choice reads back with the same label everywhere. // Discrete choices for the two behavior settings (0 = off). Dropdown idiom // matches the rest of this section; the label↔value maps below convert. @@ -40,9 +37,11 @@ private fun nextUpPromptLabel(seconds: Int): String = when { */ @Composable fun PlaybackSettings( - defaultQuality: String, + qualityResolution: String, + maxBitrateKbps: Int?, audioLanguage: String, - autoSkipIntro: Boolean, + audioLanguageSuggestions: List = emptyList(), + introSkipMode: IntroSkipMode, autoSkipCredits: Boolean, pictureInPictureEnabled: Boolean, dolbyVisionEnabled: Boolean, @@ -51,9 +50,10 @@ fun PlaybackSettings( nextUpPromptSeconds: Int, resumeRewindSeconds: Int, passOutThreshold: Int, - onQualityChanged: (String) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, - onAutoSkipIntroChanged: (Boolean) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, onPictureInPictureEnabledChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, @@ -65,31 +65,55 @@ fun PlaybackSettings( onResetPlaybackOverrides: () -> Unit, modifier: Modifier = Modifier, ) { - SettingsSectionCard(modifier = modifier) { - SettingsSectionHeader("Playback") - + val audioLanguageOptions = remember(audioLanguage, audioLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = audioLanguage, + runtimeValues = audioLanguageSuggestions, + ) + } + val introSkipOptions = IntroSkipMode.entries.map { it to stringResource(introSkipModeLabel(it)) } + SettingsSection(title = "Playback", modifier = modifier) { + // A pair no preset covers (set through the API, or left by a legacy + // compound value) still gets a truthful label rather than a picker + // silently showing the wrong entry. SettingsDropdownRow( - label = "Default Quality", - value = defaultQuality, - options = qualityOptions, - onOptionSelected = onQualityChanged, + label = "Preferred quality", + description = "The quality Silo requests when playback starts.", + value = QualityPresets.describe(qualityResolution, maxBitrateKbps), + options = QualityPresets.ALL.map { it.label }, + onOptionSelected = { label -> + QualityPresets.ALL.firstOrNull { it.label == label } + ?.let { onQualityPresetSelected(it.id) } + }, ) SettingsDropdownRow( - label = "Audio Language", - value = audioLanguage, - options = languageOptions, - onOptionSelected = onAudioLanguageChanged, + label = "Audio language", + description = "Choose which spoken language Silo should prefer first.", + value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), + options = audioLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onAudioLanguageChanged(LanguageOptions.wireValue(label, audioLanguageOptions)) + }, ) - SettingsSwitchRow( - label = "Auto-Skip Intros", - checked = autoSkipIntro, - onCheckedChange = onAutoSkipIntroChanged, + // Three-way, not a switch: the boolean this replaced could not say + // "never". Labels and semantics are fixed by the contract. + SettingsDropdownRow( + label = stringResource(R.string.settings_intro_skip_title), + description = "What happens when a detected intro starts: leave it alone, " + + "offer a Skip Intro button, or skip it and offer an undo.", + value = stringResource(introSkipModeLabel(introSkipMode)), + options = introSkipOptions.map { it.second }, + onOptionSelected = { label -> + introSkipOptions.firstOrNull { it.second == label }?.let { onIntroSkipModeChanged(it.first) } + }, ) SettingsSwitchRow( - label = "Auto-Skip Credits", + label = "Auto-skip credits", + description = "Move through end credits automatically when a skip is available.", checked = autoSkipCredits, onCheckedChange = onAutoSkipCreditsChanged, ) @@ -99,31 +123,36 @@ fun PlaybackSettings( // only shows while Dolby Vision is on. SettingsSwitchRow( label = "Dolby Vision", + description = "Allow Dolby Vision output on this device.", checked = dolbyVisionEnabled, onCheckedChange = onDolbyVisionEnabledChanged, ) if (dolbyVisionEnabled) { SettingsSwitchRow( - label = "Profile 7 HDR10 Fallback", + label = "Profile 7 HDR10 fallback", + description = "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", checked = dvProfile7HDR10Fallback, onCheckedChange = onDvProfile7HDR10FallbackChanged, ) } SettingsSwitchRow( - label = "Picture-in-Picture", + label = "Picture-in-picture", + description = "Keep playing in a floating window when you leave the player.", checked = pictureInPictureEnabled, onCheckedChange = onPictureInPictureEnabledChanged, ) SettingsSwitchRow( - label = "Auto-Play Next Episode", + label = "Auto-play next episode", + description = "Continue to the next episode automatically.", checked = autoPlayNext, onCheckedChange = onAutoPlayNextChanged, ) SettingsDropdownRow( - label = "Show Next Up", + label = "Next up prompt", + description = "How long before the end of an episode the next-up prompt appears.", value = nextUpPromptLabel(nextUpPromptSeconds), options = nextUpPromptOptions.map(::nextUpPromptLabel), onOptionSelected = { label -> @@ -132,7 +161,8 @@ fun PlaybackSettings( ) SettingsDropdownRow( - label = "Resume Skip-Back", + label = "Rewind on resume", + description = "Skip back this far when resuming a partly watched item.", value = resumeRewindLabel(resumeRewindSeconds), options = resumeRewindOptions.map(::resumeRewindLabel), onOptionSelected = { label -> @@ -141,7 +171,8 @@ fun PlaybackSettings( ) SettingsDropdownRow( - label = "Still-Watching Prompt After", + label = "Still watching prompt", + description = "How many episodes auto-play before Silo asks whether you are still watching.", value = passOutThresholdLabel(passOutThreshold), options = passOutThresholdOptions.map(::passOutThresholdLabel), onOptionSelected = { label -> @@ -149,73 +180,18 @@ fun PlaybackSettings( }, ) - SettingsActionRow( - label = "Reset Playback Overrides", + SettingsDestructiveRow( + label = "Reset playback settings", + description = "Return this device's playback settings to their defaults.", onClick = onResetPlaybackOverrides, ) } } -@Composable -private fun SettingsActionRow( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - // iOS renders this as a destructive (red) button row. - androidx.compose.foundation.layout.Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 11.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - ) - } -} - -/** - * A settings row with a dropdown menu for selecting from a list of options. - */ -@Composable -fun SettingsDropdownRow( - label: String, - value: String, - options: List, - onOptionSelected: (String) -> Unit, - modifier: Modifier = Modifier, -) { - var expanded by remember { mutableStateOf(false) } - - Box(modifier = modifier) { - SettingsRow( - label = label, - modifier = Modifier.clickable { expanded = true }, - ) { - Text( - text = value, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - onOptionSelected(option) - expanded = false - }, - ) - } - } - } +/** The label each intro-skip mode is offered under; the copy is contract-fixed. */ +@StringRes +private fun introSkipModeLabel(mode: IntroSkipMode): Int = when (mode) { + IntroSkipMode.NEVER -> R.string.settings_intro_skip_never + IntroSkipMode.ASK -> R.string.settings_intro_skip_ask + IntroSkipMode.ALWAYS -> R.string.settings_intro_skip_always } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt index 54798a9fe..c3baa88c5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt @@ -1,83 +1,38 @@ package org.prairieserver.prairie.android.ui.screens.settings -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.filled.Dns -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import org.prairieserver.prairie.update.AppUpdateStatus -import org.prairieserver.prairie.update.changelogUrlOrNull -import org.prairieserver.prairie.update.latestVersionLabel -import org.prairieserver.prairie.update.releaseUrlOrNull -import org.prairieserver.prairie.update.statusLabel +import org.prairieserver.prairie.android.BuildConfig +import org.prairieserver.prairie.common.network.clientVersionLabel /** - * Connection + About section. Mirrors the iOS phone Settings `Server` row and - * About version block: server management plus current version / update status. + * Connection section. Mirrors the iOS phone Settings `Server` row: a + * single teal-badged `server.rack` row whose trailing value is the + * active server label, with a disclosure chevron that opens the server + * list. */ @Composable fun ServerInfoSection( serverUrl: String, - appVersionName: String, - appUpdateStatus: AppUpdateStatus, onManageServersClick: () -> Unit = {}, - onOpenUrl: ((String) -> Unit)? = null, modifier: Modifier = Modifier, ) { - val latest = appUpdateStatus.latestVersionLabel() - val releaseUrl = appUpdateStatus.releaseUrlOrNull() - val changelogUrl = appUpdateStatus.changelogUrlOrNull() SettingsSectionCard(modifier = modifier) { - SettingsRowLabel( - title = "Server", - icon = Icons.Default.Dns, - badgeColor = SettingsBadgeTeal, + SettingsNavigationRow( + label = "Server", + description = "The Prairie server this device is signed in to.", value = serverUrl.ifBlank { "Not connected" }, onClick = onManageServersClick, - showChevron = true, ) - SettingsRowLabel( - title = "Version", - icon = Icons.Default.Info, - badgeColor = SettingsBadgeGray, - value = appVersionName, + SettingsNavigationRow( + label = "Version", + description = "The app build running on this device.", + // Includes the build number so a support report and the server's + // admin Activity page name the exact same build, in the "1.0.0 (5)" + // form Play, TestFlight and the server's own diagnostics page all + // use. Unstamped local builds show the bare version rather than a + // meaningless "(0)", matching what those builds report. + value = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER), ) - SettingsRowLabel( - title = "Update status", - icon = Icons.Default.SystemUpdate, - badgeColor = if (appUpdateStatus is AppUpdateStatus.UpdateAvailable) { - SettingsBadgeTeal - } else { - SettingsBadgeGray - }, - value = appUpdateStatus.statusLabel(), - onClick = releaseUrl?.let { url -> - onOpenUrl?.let { handler -> { handler(url) } } - }, - showChevron = releaseUrl != null && onOpenUrl != null, - ) - if (latest != null && - appUpdateStatus !is AppUpdateStatus.Checking && - appUpdateStatus !is AppUpdateStatus.Unavailable - ) { - SettingsRowLabel( - title = "Latest version", - icon = Icons.Default.Info, - badgeColor = SettingsBadgeGray, - value = latest, - ) - } - if (changelogUrl != null && onOpenUrl != null) { - SettingsRowLabel( - title = "Changelog", - icon = Icons.AutoMirrored.Filled.MenuBook, - badgeColor = SettingsBadgeGray, - value = "View", - onClick = { onOpenUrl(changelogUrl) }, - showChevron = true, - ) - } } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt index 91dea9b36..0e5a6030a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues @@ -12,57 +13,66 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Layers -import androidx.compose.material.icons.outlined.BookmarkBorder -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.icons.outlined.GridView -import androidx.compose.material.icons.outlined.History -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import org.prairieserver.prairie.android.ui.components.PrairieConfirmDialog import org.prairieserver.prairie.android.ui.components.PrairieTopBar import org.prairieserver.prairie.android.ui.screens.downloads.DownloadsViewModel import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.DiagnosticsViewModel import org.prairieserver.prairie.android.ui.screens.settings.diagnostics.shouldShowDiagnosticsEntry +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieBorder +import org.prairieserver.prairie.android.ui.theme.PrairieDestructive +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainer +import org.prairieserver.prairie.android.ui.theme.PrairieSurfaceContainerHigh +import org.prairieserver.prairie.android.ui.theme.siloRowTopDivider import org.prairieserver.prairie.android.ui.util.formatBytes import org.prairieserver.prairie.model.download.DownloadQuality import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore import org.prairieserver.prairie.model.metadata.MetadataAiOnView -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete /** * Main settings screen organized in grouped sections. @@ -74,19 +84,16 @@ import androidx.compose.material.icons.filled.Delete * @param showTopBar Whether to show the top bar (false when inside MainScreen tab). * @param onBackClick Back navigation handler for standalone mode. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( onLoggedOut: () -> Unit, onNavigateToServers: () -> Unit = {}, onPairDevice: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, - onNavigateToAdmin: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, onNavigateToFavorites: () -> Unit = {}, onNavigateToHistory: () -> Unit = {}, onNavigateToCollections: () -> Unit = {}, - onNavigateToCardOverlays: () -> Unit = {}, onNavigateToDiagnostics: () -> Unit = {}, showTopBar: Boolean = false, onBackClick: (() -> Unit)? = null, @@ -95,7 +102,6 @@ fun SettingsScreen( diagnosticsViewModel: DiagnosticsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() - val uriHandler = LocalUriHandler.current var subtitleStyleVisible by remember { mutableStateOf(false) } org.prairieserver.prairie.android.ui.screens.player.SubtitleStyleSheet( isVisible = subtitleStyleVisible, @@ -105,7 +111,6 @@ fun SettingsScreen( ) val downloadsState by downloadsViewModel.uiState.collectAsState() val diagnosticsState by diagnosticsViewModel.state.collectAsState() - val sessionsSheetState = rememberModalBottomSheetState() var showRemoveAllDownloadsConfirm by remember { mutableStateOf(false) } LaunchedEffect(state.loggedOut) { @@ -121,26 +126,30 @@ fun SettingsScreen( PrairieTopBar( title = "Settings", onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, ) } }, - containerColor = MaterialTheme.colorScheme.background, + containerColor = PrairieSettingsBackground, ) { padding -> LazyColumn( modifier = Modifier .fillMaxSize() .padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(SettingsDimens.pageGutter), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), ) { item { if (!showTopBar) { Text( text = "Settings", style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.onBackground, + color = PrairieForeground, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + modifier = Modifier.padding( + top = SettingsDimens.pageTopPadding, + bottom = SettingsDimens.headerStartInset, + ), ) } } @@ -150,33 +159,17 @@ fun SettingsScreen( onSwitchProfile = onSwitchProfile, user = state.user, isLoadingUser = state.isLoadingUser, - isAdminVisible = state.isAdminVisible, - onManageSessions = viewModel::loadSessions, onPairDevice = onPairDevice, - onAdmin = onNavigateToAdmin, onSignOut = viewModel::logout, ) } - item { - SettingsSectionCard { - SettingsRowLabel( - title = "Card Overlays", - icon = Icons.Filled.Layers, - badgeColor = SettingsBadgeIndigo, - onClick = onNavigateToCardOverlays, - showChevron = true, - ) - } - } - if (shouldShowDiagnosticsEntry(diagnosticsState)) { item { SettingsSectionCard { - SettingsRowLabel( - title = "Diagnostics", - icon = Icons.Outlined.Info, - badgeColor = SettingsBadgeOrange, + SettingsNavigationRow( + label = "Diagnostics", + description = "Capture and review a report when something goes wrong.", value = when (diagnosticsState.availability) { org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.AVAILABLE -> "Available" org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.DISABLED -> "Disabled" @@ -185,17 +178,24 @@ fun SettingsScreen( org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi.INELIGIBLE -> null }, onClick = onNavigateToDiagnostics, - showChevron = true, ) } } } + if (state.settingsAvailability == + org.prairieserver.prairie.domain.settings.ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED + ) { + item { SettingsUpgradeRequiredNotice() } + } + item { PlaybackSettings( - defaultQuality = state.defaultQuality, + qualityResolution = state.qualityResolution, + maxBitrateKbps = state.maxBitrateKbps, audioLanguage = state.audioLanguage, - autoSkipIntro = state.autoSkipIntro, + audioLanguageSuggestions = state.audioLanguageSuggestions, + introSkipMode = state.introSkipMode, autoSkipCredits = state.autoSkipCredits, pictureInPictureEnabled = state.pictureInPictureEnabled, dolbyVisionEnabled = state.dolbyVisionEnabled, @@ -204,9 +204,9 @@ fun SettingsScreen( nextUpPromptSeconds = state.nextUpPromptSeconds, resumeRewindSeconds = state.resumeRewindSeconds, passOutThreshold = state.passOutThreshold, - onQualityChanged = viewModel::setDefaultQuality, + onQualityPresetSelected = viewModel::setQualityPreset, onAudioLanguageChanged = viewModel::setAudioLanguage, - onAutoSkipIntroChanged = viewModel::setAutoSkipIntro, + onIntroSkipModeChanged = viewModel::setIntroSkipMode, onAutoSkipCreditsChanged = viewModel::setAutoSkipCredits, onPictureInPictureEnabledChanged = viewModel::setPictureInPictureEnabled, onDolbyVisionEnabledChanged = viewModel::setDolbyVisionEnabled, @@ -224,6 +224,7 @@ fun SettingsScreen( val metadataAiStatus by metadataAiStore.status.collectAsState() SubtitleSettings( subtitleLanguage = state.subtitleLanguage, + subtitleLanguageSuggestions = state.subtitleLanguageSuggestions, subtitleMode = state.subtitleMode, showForcedSubtitles = state.showForcedSubtitles, onLanguageChanged = viewModel::setSubtitleLanguage, @@ -235,35 +236,36 @@ fun SettingsScreen( metadataLanguageEnabled = metadataAiStatus.enabled && metadataAiStatus.onView != MetadataAiOnView.Off, metadataLanguage = state.metadataLanguage, + metadataLanguageSuggestions = state.metadataLanguageSuggestions, onMetadataLanguageChanged = viewModel::setMetadataLanguage, ) } item { - SettingsSectionCard { - SettingsSectionHeader(title = "Library") - SettingsClickableRow( - icon = Icons.Outlined.BookmarkBorder, + SettingsSection(title = "Library") { + SettingsNavigationRow( label = "Watchlist", + description = "Titles you saved to watch later.", onClick = onNavigateToWatchlist, ) - SettingsClickableRow( - icon = Icons.Outlined.FavoriteBorder, + SettingsNavigationRow( label = "Favorites", + description = "Titles you marked as favorites.", onClick = onNavigateToFavorites, ) - SettingsClickableRow( - icon = Icons.Outlined.History, - label = "Watch History", + SettingsNavigationRow( + label = "Watch history", + description = "Everything you have played, most recent first.", onClick = onNavigateToHistory, ) - SettingsClickableRow( - icon = Icons.Outlined.GridView, + SettingsNavigationRow( label = "Collections", + description = "Curated groups of titles from your libraries.", onClick = onNavigateToCollections, ) SettingsSwitchRow( - label = "Show Audiobooks", + label = "Show audiobooks", + description = "Show the Audiobooks section in navigation.", checked = state.showAudiobooks, onCheckedChange = viewModel::setShowAudiobooks, ) @@ -272,31 +274,35 @@ fun SettingsScreen( if (state.notificationsAvailable) { item { - SettingsSectionCard { - SettingsSectionHeader(title = "Notifications") + SettingsSection(title = "Notifications") { SettingsSwitchRow( label = "In-app notifications", + description = "Show alerts inside Silo as new releases arrive.", checked = state.notificationsEnabled, onCheckedChange = viewModel::setNotificationsEnabled, ) if (state.notificationsEnabled) { SettingsSwitchRow( label = "Favorites", + description = "Notify when something you favorited has a new episode.", checked = state.notifyFavorites, onCheckedChange = viewModel::setNotifyFavorites, ) SettingsSwitchRow( label = "Watchlist", + description = "Notify when something on your watchlist becomes available.", checked = state.notifyWatchlist, onCheckedChange = viewModel::setNotifyWatchlist, ) SettingsSwitchRow( label = "Continue watching", + description = "Notify about titles you started but have not finished.", checked = state.notifyContinueWatching, onCheckedChange = viewModel::setNotifyContinueWatching, ) SettingsSwitchRow( label = "Next up", + description = "Notify when the next episode of a series you watch arrives.", checked = state.notifyNextUp, onCheckedChange = viewModel::setNotifyNextUp, ) @@ -306,37 +312,37 @@ fun SettingsScreen( } item { - SettingsSectionCard { - SettingsSectionHeader(title = "Downloads") + SettingsSection(title = "Downloads") { SettingsDropdownRow( - label = "Default Quality", + label = "Download quality", + description = "Quality preset used for new downloads.", value = state.defaultDownloadQuality, options = DownloadQuality.entries.map { it.label }, onOptionSelected = viewModel::setDefaultDownloadQuality, ) SettingsSwitchRow( - label = "Wi-Fi only", + label = "Download over Wi-Fi only", + description = "Only download while connected to Wi-Fi.", checked = state.downloadsWifiOnly, onCheckedChange = viewModel::setDownloadsWifiOnly, ) SettingsSwitchRow( label = "Keep watched downloads", + description = "Do not suggest reclaiming space from downloads you have finished.", checked = state.keepWatchedDownloads, onCheckedChange = viewModel::setKeepWatchedDownloads, ) if (!downloadsState.isEmpty || downloadsState.totalBytesUsed > 0L) { - SettingsClickableRow( - icon = Icons.Outlined.Delete, + SettingsDestructiveRow( label = if (downloadsState.isRemovingAllDownloads) { - "Removing Downloads..." + "Removing downloads…" } else { - "Remove All Downloads" + "Remove all downloads" }, - onClick = { showRemoveAllDownloadsConfirm = true }, - labelColor = SettingsBadgeRed, - iconTint = SettingsBadgeRed, + description = "Delete every downloaded file from this device.", + value = formatBytes(downloadsState.totalBytesUsed), enabled = !downloadsState.isRemovingAllDownloads, - trailingText = formatBytes(downloadsState.totalBytesUsed), + onClick = { showRemoveAllDownloadsConfirm = true }, ) } } @@ -345,289 +351,520 @@ fun SettingsScreen( item { ServerInfoSection( serverUrl = state.serverUrl, - appVersionName = state.appVersionName, - appUpdateStatus = state.appUpdateStatus, onManageServersClick = onNavigateToServers, - onOpenUrl = { url -> - runCatching { uriHandler.openUri(url) } - }, ) } // Bottom spacing - item { Spacer(modifier = Modifier.height(32.dp)) } + item { Spacer(modifier = Modifier.height(SettingsDimens.pageBottomSpacer)) } } } - // Sessions bottom sheet - if (state.showSessions) { - SessionsSheet( - sheetState = sessionsSheetState, - sessions = state.sessions, - isLoading = state.isLoadingSessions, - onRevokeSession = viewModel::revokeSession, - onDismiss = viewModel::hideSessions, + if (showRemoveAllDownloadsConfirm) { + PrairieConfirmDialog( + title = "Remove all downloads?", + body = "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files " + + "from this device. Your library and server media stay intact.", + confirmLabel = if (downloadsState.isRemovingAllDownloads) "Removing…" else "Remove all", + confirmEnabled = !downloadsState.isRemovingAllDownloads, + onConfirm = { + showRemoveAllDownloadsConfirm = false + downloadsViewModel.removeAllDownloads() + }, + onDismiss = { showRemoveAllDownloadsConfirm = false }, ) } +} - if (showRemoveAllDownloadsConfirm) { - AlertDialog( - onDismissRequest = { showRemoveAllDownloadsConfirm = false }, - title = { Text("Remove all downloads?") }, - text = { - Text( - "This removes ${formatBytes(downloadsState.totalBytesUsed)} of downloaded files from this device. " + - "Your library and server media stay intact.", - ) - }, - confirmButton = { - TextButton( - enabled = !downloadsState.isRemovingAllDownloads, - onClick = { - showRemoveAllDownloadsConfirm = false - downloadsViewModel.removeAllDownloads() - }, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (downloadsState.isRemovingAllDownloads) "Removing..." else "Remove All") - } - }, - dismissButton = { - TextButton(onClick = { showRemoveAllDownloadsConfirm = false }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") - } - }, +/** + * Shown when the connected server predates the canonical settings API. + * + * The failure mode this replaces was an empty (or silently non-saving) + * settings screen: the profile preferences resolve to nothing, so the rows + * render defaults and an edit goes nowhere with no explanation. Saying so is + * the whole point — playback keeps working from the device's local defaults, + * only the profile-wide preferences are unavailable. + */ +@Composable +fun SettingsUpgradeRequiredNotice(modifier: Modifier = Modifier) { + SettingsSection(title = "Server update needed", modifier = modifier) { + SettingsProse( + title = "This server is too old for profile settings", + body = "Subtitle and metadata preferences are stored by the server, and this one " + + "does not support them yet. Playback still works using this device's settings. " + + "Ask whoever runs the server to update it.", ) } } -// --- iOS system-color badge palette (maps SwiftUI .blue/.pink/etc.) --- +// --- Shared Settings UI Components --- +// +// The grouped surface these build is the Silo web client's, adapted to Android +// row mechanics: an opaque card on a lifted page ground, a lettered heading +// above rather than inside it, a label over a muted description, and the +// control kept trailing rather than stacked underneath the way the web layout +// stacks it. Metrics live in `ui.theme.SettingsDimens` / `SettingsTextStyles`. +// +// There is deliberately no leading icon on any row. The web client puts icons +// only in its settings *sidebar*, never on a row; Android has no sidebar, so +// its destination rows sit inline among the value rows and an icon on half of +// them is exactly the "iOS Settings at the ends, unstyled form in the middle" +// split this pass removed. The description line is the scanning aid now, and +// the trailing affordance (chevron / value / switch) is what separates a +// destination from a setting. -val SettingsBadgeBlue = Color(0xFF0A84FF) -val SettingsBadgePink = Color(0xFFFF375F) -val SettingsBadgeIndigo = Color(0xFF5E5CE6) -val SettingsBadgeTeal = Color(0xFF64D2FF) -val SettingsBadgeOrange = Color(0xFFFF9F0A) -val SettingsBadgeRed = Color(0xFFFF453A) -val SettingsBadgeGray = Color(0xFF8E8E93) -val SettingsBadgePurple = Color(0xFFBF5AF2) +/** + * Per-card row counter backing the "no divider above the first row" rule. + * + * Rows claim a slot on first composition and remember it, so the index is + * stable across recomposition and follows source order within the card. A card + * whose *first* row is conditional would need [SettingsRow]'s `showDivider` + * override — no section does that today, since every card's opening row is + * unconditional. + */ +@Stable +internal class SettingsSectionSlots { + private var next = 0 -// --- Shared Settings UI Components --- + fun claim(): Int = next++ +} + +internal val LocalSettingsSectionSlots = staticCompositionLocalOf { null } + +/** True for the first row composed into the enclosing [SettingsSectionCard]. */ +@Composable +private fun isFirstSettingsRow(): Boolean { + val slots = LocalSettingsSectionSlots.current ?: return true + return remember(slots) { slots.claim() } == 0 +} /** - * Card container for a settings section. Mirrors the iOS inset-grouped - * `Section` whose rows sit on `prairieSurfaceElevated`. iOS uses a - * ~10pt corner radius for grouped sections. + * Claims a row slot for a card child that is not a [SettingsRow] — the account + * header, a prose pane — and reports whether it should draw a hairline above + * itself. A custom child that skips this is invisible to the divider rule, and + * the row after it would wrongly believe it is the card's first. + * + * Pair with [settingsRowDivider]. + */ +@Composable +fun settingsRowDividerVisible(): Boolean = !isFirstSettingsRow() + +/** Draws the standard inter-row hairline along this element's top edge. */ +fun Modifier.settingsRowDivider(show: Boolean): Modifier = settingsRowTopDivider(show) + +// The hairline itself lives in `ui.theme` beside the tokens it draws with: +// the popup menus rule their rows the same way, and one line rendered by two +// implementations is how two lines end up different. +private fun Modifier.settingsRowTopDivider(show: Boolean): Modifier = siloRowTopDivider(show) + +/** + * A settings group: a lettered heading sitting above its card. + * + * The heading used to render *inside* the card as its first child, which is + * what made every section start with a stray caps line on the same surface as + * the rows. Its own KDoc always described the intended placement. + */ +@Composable +fun SettingsSection( + title: String?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier = modifier.fillMaxWidth()) { + if (!title.isNullOrBlank()) { + SettingsSectionHeader(title) + } + SettingsSectionCard(content = content) + } +} + +/** + * Card container for a settings section, and the owner of the row dividers. */ @Composable fun SettingsSectionCard( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - // iOS rows sit on `prairieSurfaceElevated`, which the Android - // theme exposes as `primaryContainer` (0xFF15171C). - .background(MaterialTheme.colorScheme.primaryContainer), - content = content, - ) + val slots = remember { SettingsSectionSlots() } + CompositionLocalProvider(LocalSettingsSectionSlots provides slots) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SettingsDimens.cardRadius)) + .background(PrairieSurfaceContainer), + content = content, + ) + } } /** - * Section header text. iOS grouped-list section headers are uppercased - * footnote text in the secondary color, sitting above the card with a - * small inset. + * Section heading — uppercased, letter-spaced, muted, sitting above the card + * with a small inset. */ @Composable fun SettingsSectionHeader(title: String) { Text( text = title.uppercase(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 6.dp), + style = SettingsTextStyles.sectionHeader, + color = PrairieMutedText, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + bottom = SettingsDimens.headerBottomGap, + ), + ) +} + +/** + * Disclosure chevron. + */ +@Composable +fun SettingsRowChevron(enabled: Boolean = true) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = PrairieMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), + modifier = Modifier.size(SettingsDimens.chevronSize), ) } /** - * iOS Settings-app style row: a colored rounded-square icon badge - * (cornerRadius 7, 29x29), the row title, and an optional trailing - * value in secondary color. Mirrors `SettingsRowLabel`. + * The one settings row. + * + * Every other row type in this package is this one with a different trailing + * slot: label over an optional description, control trailing, a 60dp floor so + * a described row and a bare row still read as the same list, and a hairline + * above every row but the card's first. + * + * A trailing [value] shares the *label's* line rather than sitting beside the + * whole text block. That is the structural half of a real defect: with the + * value beside the block, a long one ("30 seconds before end") squeezed the + * description into a narrow column whose last line then ended a few dp from + * the value, and the two read as touching. On the label's line the value can + * never abut the description — the description runs the full width beneath it + * — and the wider column costs a line rather than adding one, so the page gets + * shorter, not taller. Only [trailing] controls (chevron, switch, radio) sit + * beside the block now, and an icon at [SettingsDimens.rowTrailingGap] does + * not read as a collision the way text does. + * + * @param showDivider Overrides the automatic first-row rule. Only needed in a + * card whose opening row is conditional. */ @Composable -fun SettingsRowLabel( - title: String, - icon: ImageVector, - badgeColor: Color, +fun SettingsRow( + label: String, modifier: Modifier = Modifier, + description: String? = null, value: String? = null, + labelColor: Color = PrairieForeground, + enabled: Boolean = true, onClick: (() -> Unit)? = null, - showChevron: Boolean = false, + showDivider: Boolean? = null, + trailing: @Composable RowScope.() -> Unit = {}, ) { + val contentAlpha = if (enabled) 1f else SettingsDimens.disabledAlpha + val divider = showDivider ?: !isFirstSettingsRow() Row( modifier = modifier .fillMaxWidth() - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 16.dp, vertical = 11.dp), + .heightIn(min = SettingsDimens.rowMinHeight) + .settingsRowTopDivider(divider) + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, onClick = onClick) + } else { + Modifier + }, + ) + .padding( + horizontal = SettingsDimens.rowHorizontalPadding, + vertical = SettingsDimens.rowVerticalPadding, + ), verticalAlignment = Alignment.CenterVertically, ) { - androidx.compose.foundation.layout.Box( - modifier = Modifier - .size(29.dp) - .clip(RoundedCornerShape(7.dp)) - .background(badgeColor), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(17.dp), - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + Column( modifier = Modifier.weight(1f), - ) - - if (value != null) { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = value, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = SettingsTextStyles.rowLabel, + color = labelColor.copy(alpha = contentAlpha), + // Fills the line so the value stays trailing-aligned, and + // yields — by wrapping — when a capped value needs room. + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(modifier = Modifier.width(SettingsDimens.rowLabelValueGap)) + SettingsRowValue(value = value, enabled = enabled) + } + } + if (!description.isNullOrBlank()) { + Text( + text = description, + style = SettingsTextStyles.rowDescription, + color = PrairieMutedText.copy(alpha = contentAlpha), + ) + } } + trailing() + } +} +/** + * A row that navigates somewhere, optionally showing the current value. + * + * Replaces the old `SettingsRowLabel` (iOS coloured badge) and + * `SettingsClickableRow` (bare 20dp icon), which differed only in their + * leading treatment. + */ +@Composable +fun SettingsNavigationRow( + label: String, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + onClick: (() -> Unit)? = null, + showChevron: Boolean = onClick != null, + enabled: Boolean = true, + labelColor: Color = PrairieForeground, +) { + SettingsRow( + label = label, + modifier = modifier, + description = description, + value = value, + labelColor = labelColor, + enabled = enabled, + onClick = onClick, + ) { if (showChevron) { - Spacer(modifier = Modifier.width(8.dp)) - SettingsRowChevron() + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + SettingsRowChevron(enabled = enabled) } } } /** - * Disclosure chevron matching the iOS `SettingsRowChevron`. + * Destructive row. One tint, [PrairieDestructive], for every destructive action + * on this surface — Sign out, Reset playback settings, Remove all downloads — + * which previously used three different layouts and two different reds. */ @Composable -fun SettingsRowChevron() { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(18.dp), +fun SettingsDestructiveRow( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + value: String? = null, + enabled: Boolean = true, +) { + SettingsNavigationRow( + label = label, + modifier = modifier, + description = description, + value = value, + onClick = onClick, + showChevron = false, + enabled = enabled, + labelColor = PrairieDestructive, ) } /** - * Generic settings row with a label and a trailing content slot. + * Trailing value text — smaller and muted, so a picker's current choice does + * not read as a second label. + * + * Unweighted, so [SettingsRow]'s label line measures it first: it gets the + * width it asks for up to [SettingsDimens.rowValueMaxWidth], and the label + * takes what is left. */ @Composable -fun SettingsRow( +private fun SettingsRowValue(value: String, enabled: Boolean) { + Text( + text = value, + style = SettingsTextStyles.rowValue, + color = PrairieMutedText.copy(alpha = if (enabled) 1f else SettingsDimens.disabledAlpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.widthIn(max = SettingsDimens.rowValueMaxWidth), + ) +} + +/** + * Settings row with a switch toggle. The whole row toggles, not just the + * thumb, and [enabled] now exists — the diagnostics screen used to hand-roll + * its own copy of this row purely to get a disabled switch. + */ +@Composable +fun SettingsSwitchRow( label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, - trailing: @Composable RowScope.() -> Unit = {}, + description: String? = null, + enabled: Boolean = true, ) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, + SettingsRow( + label = label, + description = description, + enabled = enabled, + modifier = modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ), ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + Switch( + checked = checked, + // The row owns the gesture; the switch is the indicator. + onCheckedChange = null, + enabled = enabled, + colors = settingsSwitchColors(), ) - trailing() } } +@Composable +private fun settingsSwitchColors() = SwitchDefaults.colors( + checkedThumbColor = PrairieSurfaceContainer, + checkedTrackColor = PrairieForeground, + checkedBorderColor = Color.Transparent, + uncheckedThumbColor = PrairieMutedText, + uncheckedTrackColor = PrairieSurfaceContainerHigh, + uncheckedBorderColor = PrairieBorder, + disabledCheckedThumbColor = PrairieSurfaceContainer, + disabledCheckedTrackColor = PrairieForeground.copy(alpha = SettingsDimens.disabledAlpha), + disabledCheckedBorderColor = Color.Transparent, + disabledUncheckedThumbColor = PrairieMutedText.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedTrackColor = PrairieSurfaceContainerHigh.copy(alpha = SettingsDimens.disabledAlpha), + disabledUncheckedBorderColor = PrairieBorder.copy(alpha = SettingsDimens.disabledAlpha), +) + /** - * Settings row with a switch toggle. + * Settings row for one option in a mutually exclusive set. Like the switch + * row, the whole row is the target. */ @Composable -fun SettingsSwitchRow( +fun SettingsChoiceRow( label: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, + selected: Boolean, + onSelect: () -> Unit, modifier: Modifier = Modifier, + description: String? = null, + enabled: Boolean = true, ) { - SettingsRow(label = label, modifier = modifier) { - Switch( - checked = checked, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant, + SettingsRow( + label = label, + description = description, + enabled = enabled, + modifier = modifier.selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onSelect, + ), + ) { + Spacer(modifier = Modifier.width(SettingsDimens.rowTrailingGap)) + RadioButton( + selected = selected, + // The row owns the gesture; the button is the indicator. + onClick = null, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = PrairieForeground, + unselectedColor = PrairieMutedText, + disabledSelectedColor = PrairieForeground.copy(alpha = SettingsDimens.disabledAlpha), + disabledUnselectedColor = PrairieMutedText.copy(alpha = SettingsDimens.disabledAlpha), ), ) } } /** - * Clickable row with an icon and label, used for action items like "Sign Out". + * A settings row that opens a menu of options. + * + * Trailing value plus a chevron, so a picker reads as something you can open + * rather than as a read-only fact. */ @Composable -fun SettingsClickableRow( - icon: ImageVector, +fun SettingsDropdownRow( label: String, - onClick: () -> Unit, + value: String, + options: List, + onOptionSelected: (String) -> Unit, modifier: Modifier = Modifier, - labelColor: Color = MaterialTheme.colorScheme.onSurface, - iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + description: String? = null, enabled: Boolean = true, - trailingText: String? = null, ) { - Row( + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + SettingsNavigationRow( + label = label, + description = description, + value = value, + enabled = enabled, + onClick = { expanded = true }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + onOptionSelected(option) + expanded = false + }, + ) + } + } + } +} + +/** + * A prose block inside a card, for the notices that are explanation rather + * than setting. Carries the same divider rule as a row. + */ +@Composable +fun SettingsProse( + body: String, + modifier: Modifier = Modifier, + title: String? = null, +) { + val divider = !isFirstSettingsRow() + Column( modifier = modifier .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, + .settingsRowTopDivider(divider) + .padding( + horizontal = SettingsDimens.proseHorizontalPadding, + vertical = SettingsDimens.proseVerticalPadding, + ), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.rowLabelGap), ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint.copy(alpha = if (enabled) 1f else 0.5f), - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - color = labelColor.copy(alpha = if (enabled) 1f else 0.5f), - modifier = Modifier.weight(1f), - ) - if (trailingText != null) { - Spacer(modifier = Modifier.width(8.dp)) + if (title != null) { Text( - text = trailingText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + text = title, + style = SettingsTextStyles.rowLabel, + color = PrairieForeground, ) } + Text( + text = body, + style = SettingsTextStyles.rowDescription, + color = PrairieMutedText, + ) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt index 8f62bd98f..f82020dfb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsViewModel.kt @@ -5,19 +5,15 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore -import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface -import org.prairieserver.prairie.model.auth.AuthSession +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.auth.User -import org.prairieserver.prairie.model.auth.isActingAdmin import org.prairieserver.prairie.model.download.DownloadQuality import org.prairieserver.prairie.model.notifications.NotificationPreferencesUpdate -import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.NotificationsRepository -import org.prairieserver.prairie.repository.ProfileRepository -import org.prairieserver.prairie.update.AppUpdateChecker -import org.prairieserver.prairie.update.AppUpdateStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -28,12 +24,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** - * Subtitle display mode. + * Subtitle display mode. [wire] is the `playback.subtitle_mode` enum member + * the settings contract declares — the labels are display only. */ -enum class SubtitleMode(val label: String) { - OFF("Off"), - AUTO("Auto"), - ALWAYS("Always"), +enum class SubtitleMode(val label: String, val wire: String) { + OFF("Off", "off"), + AUTO("Auto", "auto"), + ALWAYS("Always", "always"); + + companion object { + fun fromWire(value: String?): SubtitleMode = + entries.firstOrNull { it.wire == value?.lowercase() } ?: AUTO + } } data class SettingsUiState( @@ -41,17 +43,27 @@ data class SettingsUiState( val user: User? = null, val serverUrl: String = "", val isLoadingUser: Boolean = false, - val sessions: List = emptyList(), - val isLoadingSessions: Boolean = false, - val showSessions: Boolean = false, val loggedOut: Boolean = false, - // Client admin is hidden for now even when the server would accept acting-admin. - val isAdminVisible: Boolean = false, + + // Whether this server serves the canonical settings API. When it reports + // SERVER_UPGRADE_REQUIRED the screen explains that instead of rendering + // rows whose edits would silently go nowhere; playback keeps working from + // the local defaults either way. + val settingsAvailability: ProfileSettingsController.Availability = + ProfileSettingsController.Availability.UNKNOWN, // Playback - val defaultQuality: String = "Auto", - val audioLanguage: String = "Default", - val autoSkipIntro: Boolean = false, + // The quality picker composes playback.preferred_quality (a resolution + // cap) and playback.max_bitrate_kbps (a bandwidth cap; null = uncapped) + // into one list. The compound legacy spellings are dead and never written. + val qualityResolution: String = QualityPresets.RESOLUTION_AUTO, + val maxBitrateKbps: Int? = null, + /** True when policy capped the resolution below the profile's choice. */ + val qualityConstrained: Boolean = false, + // BCP 47 tag, "" = no preference. The picker converts to and from labels. + val audioLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), + val introSkipMode: IntroSkipMode = IntroSkipMode.Default, val autoSkipCredits: Boolean = false, val pictureInPictureEnabled: Boolean = true, val dolbyVisionEnabled: Boolean = true, @@ -75,10 +87,13 @@ data class SettingsUiState( val defaultDownloadQuality: String = DownloadQuality.Original.label, // Subtitles - val subtitleLanguage: String = "Off", + // BCP 47 tag, "" = off. The picker converts to and from labels. + val subtitleLanguage: String = "", + val subtitleLanguageSuggestions: List = emptyList(), // Metadata AI: preferred description/metadata language. // ISO 639-1 code; "" = inherit library metadata language. val metadataLanguage: String = "", + val metadataLanguageSuggestions: List = emptyList(), val subtitleMode: SubtitleMode = SubtitleMode.AUTO, val showForcedSubtitles: Boolean = true, @@ -90,26 +105,18 @@ data class SettingsUiState( val notifyWatchlist: Boolean = true, val notifyContinueWatching: Boolean = true, val notifyNextUp: Boolean = true, - - // App version / update status (GitHub Releases for this client family). - val appVersionName: String = "", - val appUpdateStatus: AppUpdateStatus = AppUpdateStatus.Checking, ) class SettingsViewModel( private val authRepository: AuthRepository, private val playerSettingsStore: PlayerSettingsStore, - private val profileRepository: ProfileRepository, private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, private val overlayPrefsStore: OverlayPrefsStore, private val notificationsRepository: NotificationsRepository, - private val appUpdateChecker: AppUpdateChecker, - private val appVersionName: String, + private val profileSettings: ProfileSettingsController, ) : ViewModel() { - private val _uiState = MutableStateFlow( - SettingsUiState(appVersionName = appVersionName), - ) + private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { @@ -117,20 +124,6 @@ class SettingsViewModel( observePlayerSettings() observePlaybackBehaviorSettings() observeNotifications() - checkForAppUpdate() - } - - private fun checkForAppUpdate() { - viewModelScope.launch { - _uiState.update { - it.copy( - appVersionName = appVersionName, - appUpdateStatus = AppUpdateStatus.Checking, - ) - } - val status = appUpdateChecker.check(appVersionName) - _uiState.update { it.copy(appUpdateStatus = status) } - } } private fun loadUserInfo() { @@ -150,50 +143,66 @@ class SettingsViewModel( playerSettingsStore.refreshFromServer() - when (val profileResult = profileRepository.getActiveProfileResult()) { - is ApiResult.Success -> { - val profile = profileResult.data - _uiState.update { - it.copy( - subtitleLanguage = profile.subtitleLanguage?.ifBlank { "Off" } ?: "Off", - metadataLanguage = profile.preferredMetadataLanguage.orEmpty(), - subtitleMode = subtitleModeFromServer(profile.subtitleMode), - showForcedSubtitles = profile.showForcedSubtitles ?: true, - isAdminVisible = shouldShowClientAdminSurface(isActingAdmin(it.user, profile)), - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - // Active profile unresolved — fall back to the user role - // only (a null profile does not block an admin per the gate). - _uiState.update { - it.copy(isAdminVisible = shouldShowClientAdminSurface(isActingAdmin(it.user, null))) - } - } + // The active profile is no longer resolved here. It existed only to + // decide the admin gate, which this screen no longer has; the + // profile-scoped *preferences* are resolved canonically below. + loadProfileSettings() + } + } + + /** + * Resolves the profile-scoped preferences through the canonical settings + * API, and records whether this server speaks it at all. + * + * On [Availability.SERVER_UPGRADE_REQUIRED] the values are left as they + * are and the screen explains the situation — rendering the rows anyway + * would offer edits that go nowhere. Playback is unaffected: it runs from + * the device-scoped store, which has its own defaults. + */ + fun loadProfileSettings() { + viewModelScope.launch { + val result = profileSettings.load() + _uiState.update { state -> + val snapshot = result.snapshot ?: return@update state.copy( + settingsAvailability = result.availability, + ) + state.copy( + settingsAvailability = result.availability, + subtitleLanguage = snapshot.subtitleLanguage, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + showForcedSubtitles = snapshot.showForcedSubtitles, + metadataLanguage = snapshot.metadataLanguage, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } } } private data class PlayerSettingsSnapshot( val quality: String, + val maxBitrateKbps: Int?, val audioLanguage: String, - val autoSkipIntro: Boolean, + val introSkipMode: IntroSkipMode, val autoSkipCredits: Boolean, ) private fun observePlayerSettings() { combine( playerSettingsStore.preferredQualityFlow, + playerSettingsStore.maxBitrateKbpsFlow, playerSettingsStore.audioLanguageFlow, - playerSettingsStore.autoSkipIntroFlow, + playerSettingsStore.introSkipModeFlow, playerSettingsStore.autoSkipCreditsFlow, ::PlayerSettingsSnapshot, ).onEach { snap -> _uiState.update { it.copy( - defaultQuality = qualityLabel(snap.quality), - audioLanguage = audioLanguageLabel(snap.audioLanguage), - autoSkipIntro = snap.autoSkipIntro, + qualityResolution = snap.quality, + maxBitrateKbps = snap.maxBitrateKbps, + audioLanguage = snap.audioLanguage, + introSkipMode = snap.introSkipMode, autoSkipCredits = snap.autoSkipCredits, ) } @@ -339,39 +348,6 @@ class SettingsViewModel( viewModelScope.launch { notificationsRepository.updatePreferences(update) } } - fun loadSessions() { - viewModelScope.launch { - _uiState.update { it.copy(isLoadingSessions = true, showSessions = true) } - when (val result = authRepository.getSessions()) { - is ApiResult.Success -> { - _uiState.update { - it.copy(sessions = result.data, isLoadingSessions = false) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { it.copy(isLoadingSessions = false) } - } - } - } - } - - fun hideSessions() { - _uiState.update { it.copy(showSessions = false) } - } - - fun revokeSession(id: String) { - viewModelScope.launch { - when (authRepository.deleteSession(id)) { - is ApiResult.Success -> { - _uiState.update { state -> - state.copy(sessions = state.sessions.filter { it.id != id }) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> Unit - } - } - } - fun logout() { viewModelScope.launch { // Push any in-flight settings before tearing down the session. @@ -391,20 +367,26 @@ class SettingsViewModel( // -- Playback -- - fun setDefaultQuality(quality: String) { + /** + * Applies one quality preset — the two axes it decomposes into. The + * compound legacy spellings ("1080p-high") are never written. + */ + fun setQualityPreset(presetId: String) { + val preset = QualityPresets.byId(presetId) ?: return viewModelScope.launch { - playerSettingsStore.setPreferredQuality(qualityWireValue(quality)) + playerSettingsStore.setQuality(preset.resolution, preset.bitrateKbps) } } + /** [language] is a BCP 47 tag, or "" for no preference. */ fun setAudioLanguage(language: String) { viewModelScope.launch { - playerSettingsStore.setAudioLanguage(audioLanguageWireValue(language)) + playerSettingsStore.setAudioLanguage(language) } } - fun setAutoSkipIntro(enabled: Boolean) { - viewModelScope.launch { playerSettingsStore.setAutoSkipIntro(enabled) } + fun setIntroSkipMode(mode: IntroSkipMode) { + viewModelScope.launch { playerSettingsStore.setIntroSkipMode(mode) } } fun setAutoSkipCredits(enabled: Boolean) { @@ -432,7 +414,13 @@ class SettingsViewModel( } fun setSubtitleAppearance(value: org.prairieserver.prairie.model.settings.SubtitleAppearance) { - viewModelScope.launch { playerSettingsStore.setSubtitleAppearance(value) } + viewModelScope.launch { + playerSettingsStore.setSubtitleAppearance(value) + // The granular subtitle.* fields are client-local — the contract + // carries appearance as one object — so a per-field edit only + // reaches the server once projected into the composite. + playerSettingsStore.flushProjectedSubtitleAppearance() + } } fun resetPlaybackOverrides() { @@ -446,84 +434,120 @@ class SettingsViewModel( // -- Subtitles -- + // These four are profile-scoped canonical settings. They used to ride + // named columns on PUT /profiles/{id}; each now writes exactly the one key + // it changes at scope=profile, so a failed write cannot also revert the + // other three (which sending the whole triple every time did). + // + // Each applies optimistically and rolls back only if the state still shows + // the value it wrote — a newer edit landing during the request wins. + fun setMetadataLanguage(code: String) { + val previous = _uiState.value.metadataLanguage _uiState.update { it.copy(metadataLanguage = code) } viewModelScope.launch { - profileRepository.updateActiveProfile( - UpdateProfileRequest( - preferredMetadataLanguage = code.ifBlank { null }, - ) - ) + val result = profileSettings.setMetadataLanguage(code) + if (!result.succeeded) { + _uiState.update { + if (it.metadataLanguage == code) it.copy(metadataLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = code) { it.metadataLanguage } + } } } + /** [language] is a BCP 47 tag, or "" for off. */ fun setSubtitleLanguage(language: String) { + val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = language) } - persistProfileSubtitleSettings() + viewModelScope.launch { + val result = profileSettings.setSubtitleLanguage(language) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleLanguage == language) it.copy(subtitleLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = language) { it.subtitleLanguage } + } + } } fun setSubtitleMode(mode: SubtitleMode) { + val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = mode) } - persistProfileSubtitleSettings() + viewModelScope.launch { + val result = profileSettings.setSubtitleMode(mode.wire) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleMode == mode) it.copy(subtitleMode = previous) else it + } + } else { + applyResolved(result.snapshot, edited = mode.wire) { it.subtitleMode } + } + } } fun setShowForcedSubtitles(enabled: Boolean) { + val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } - persistProfileSubtitleSettings() - } - - private fun persistProfileSubtitleSettings() { - val state = _uiState.value viewModelScope.launch { - profileRepository.updateActiveProfile( - UpdateProfileRequest( - subtitleLanguage = state.subtitleLanguage.takeUnless { it == "Off" }, - subtitleMode = state.subtitleMode.toServerValue(), - showForcedSubtitles = state.showForcedSubtitles, - ) - ) + val result = profileSettings.setShowForcedSubtitles(enabled) + if (!result.succeeded) { + _uiState.update { + if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it + } + } else { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + } } } - private fun qualityLabel(value: String): String = - when (value.lowercase()) { - "auto" -> "Auto" - "original" -> "Original" - "2160p", "4k" -> "4K" - else -> value.uppercase() + /** + * Replaces the optimistic values with what the server actually resolves. + * + * A successful PUT stores the authored value; it does not make it + * effective. Policy can narrow it, and a device-scoped row for the same key + * outranks the profile row these setters write — so the screen would + * otherwise show a preference playback is not using. Skipped when a newer + * edit for the *same* field landed while the round trip was in flight + * ([edited] no longer matches [fieldOf]), which the optimistic rollback + * above guards the same way. + */ + private fun applyResolved( + snapshot: ProfileSettingsController.Snapshot?, + edited: String, + fieldOf: (ProfileSettingsController.Snapshot) -> String, + ) { + if (snapshot == null) return + if (fieldOf(snapshot) == edited) { + _uiState.update { + it.copy( + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } + return } - - private fun qualityWireValue(value: String): String = - when (value) { - "Auto" -> "auto" - "Original" -> "original" - "4K" -> "2160p" - else -> value.lowercase() + _uiState.update { state -> + state.copy( + subtitleLanguage = snapshot.subtitleLanguage, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + showForcedSubtitles = snapshot.showForcedSubtitles, + metadataLanguage = snapshot.metadataLanguage, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } + } private fun downloadQualityLabel(value: String): String = DownloadQuality.fromWire(value).label private fun downloadQualityWireValue(value: String): String = DownloadQuality.entries.firstOrNull { it.label == value }?.wire ?: DownloadQuality.Original.wire - - private fun audioLanguageLabel(value: String): String = - value.ifBlank { "Default" } - - private fun audioLanguageWireValue(value: String): String = - value.takeUnless { it == "Default" }.orEmpty() - - private fun subtitleModeFromServer(value: String?): SubtitleMode = - when (value?.lowercase()) { - "off" -> SubtitleMode.OFF - "always" -> SubtitleMode.ALWAYS - else -> SubtitleMode.AUTO - } - - private fun SubtitleMode.toServerValue(): String = - when (this) { - SubtitleMode.OFF -> "off" - SubtitleMode.AUTO -> "auto" - SubtitleMode.ALWAYS -> "always" - } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt index 89741ea50..b29fa9feb 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt @@ -1,27 +1,10 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.compose.runtime.Composable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier - -private val subtitleLanguageOptions = listOf("Off", "English", "Spanish", "French", "German", "Japanese", "Korean", "Chinese", "Portuguese", "Italian", "Russian") - -// Metadata language stores ISO 639-1 codes (server contract; "" = inherit) — -// display labels, persist codes. Kept in lockstep with the TV list. -private val metadataLanguageOptions = listOf( - "" to "Off", - "en" to "English", - "es" to "Spanish", - "fr" to "French", - "de" to "German", - "ja" to "Japanese", - "ko" to "Korean", - "zh" to "Chinese", - "pt" to "Portuguese", - "it" to "Italian", - "ru" to "Russian", -) +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.model.settings.SettingKeys /** * Subtitle settings section with language, display mode, and forced subtitles toggle. @@ -29,6 +12,7 @@ private val metadataLanguageOptions = listOf( @Composable fun SubtitleSettings( subtitleLanguage: String, + subtitleLanguageSuggestions: List = emptyList(), subtitleMode: SubtitleMode, showForcedSubtitles: Boolean, onLanguageChanged: (String) -> Unit, @@ -40,21 +24,38 @@ fun SubtitleSettings( modifier: Modifier = Modifier, // Metadata AI description translation (server-gated; row hidden when off). metadataLanguageEnabled: Boolean = false, - metadataLanguage: String = "Off", + metadataLanguage: String = "", + metadataLanguageSuggestions: List = emptyList(), onMetadataLanguageChanged: (String) -> Unit = {}, ) { - SettingsSectionCard(modifier = modifier) { - SettingsSectionHeader("Subtitles") - + val subtitleLanguageOptions = remember(subtitleLanguage, subtitleLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = subtitleLanguage, + runtimeValues = subtitleLanguageSuggestions, + ) + } + val metadataLanguageOptions = remember(metadataLanguage, metadataLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = metadataLanguage, + runtimeValues = metadataLanguageSuggestions, + ) + } + SettingsSection(title = "Subtitles", modifier = modifier) { SettingsDropdownRow( - label = "Subtitle Language", - value = subtitleLanguage, - options = subtitleLanguageOptions, - onOptionSelected = onLanguageChanged, + label = "Subtitle language", + description = "Choose which subtitle language Silo should prefer first.", + value = LanguageOptions.label(subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + options = subtitleLanguageOptions.map { it.second }, + onOptionSelected = { label -> + onLanguageChanged(LanguageOptions.wireValue(label, subtitleLanguageOptions)) + }, ) SettingsDropdownRow( - label = "Subtitle Mode", + label = "Subtitle behavior", + description = "When Silo should turn subtitles on.", value = subtitleMode.label, options = SubtitleMode.entries.map { it.label }, onOptionSelected = { label -> @@ -63,7 +64,8 @@ fun SubtitleSettings( ) SettingsSwitchRow( - label = "Show Forced Subtitles", + label = "Show forced subtitles", + description = "Show subtitles for foreign-language dialogue even when subtitles are off.", checked = showForcedSubtitles, onCheckedChange = onForcedSubtitlesChanged, ) @@ -72,28 +74,27 @@ fun SubtitleSettings( // (appearance follows the OS captioning preferences) + the custom // appearance editor (the same sheet the player uses). SettingsSwitchRow( - label = "Match Device Settings", + label = "Match device caption settings", + description = "Use the operating system's caption style instead of Silo's.", checked = subtitleMatchesDevice, onCheckedChange = onSubtitleMatchesDeviceChanged, ) if (!subtitleMatchesDevice) { - SettingsClickableRow( - icon = Icons.Filled.ClosedCaption, - label = "Subtitle Appearance", + SettingsNavigationRow( + label = "Subtitle appearance", + description = "How subtitles are drawn during playback.", onClick = onOpenSubtitleAppearance, ) } if (metadataLanguageEnabled) { - val selectedLabel = metadataLanguageOptions.firstOrNull { it.first == metadataLanguage }?.second ?: "Off" SettingsDropdownRow( - label = "Metadata Language", - value = selectedLabel, + label = "Metadata language", + description = "Fallback language Silo prefers for titles, descriptions, and artwork.", + value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), options = metadataLanguageOptions.map { it.second }, onOptionSelected = { label -> - metadataLanguageOptions.firstOrNull { it.second == label }?.let { - onMetadataLanguageChanged(it.first) - } + onMetadataLanguageChanged(LanguageOptions.wireValue(label, metadataLanguageOptions)) }, ) } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt index 33851dff8..dcbc41127 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsPrompt.kt @@ -9,18 +9,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import org.prairieserver.prairie.common.diagnostics.DiagnosticsPrompt -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Block -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.DoneAll -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.Spacer -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp @Composable fun DiagnosticsPromptDialog( @@ -29,30 +17,19 @@ fun DiagnosticsPromptDialog( onSend: () -> Unit, onAlwaysSend: () -> Unit, onDontSend: () -> Unit, + allowAlwaysSend: Boolean = true, ) { var confirmAlways by remember { mutableStateOf(false) } - if (confirmAlways) { + if (confirmAlways && allowAlwaysSend) { AlertDialog( onDismissRequest = { confirmAlways = false }, title = { Text("Always send crash reports?") }, text = { Text("Future eligible reports may be uploaded automatically until you change this setting.") }, confirmButton = { - TextButton(onClick = onAlwaysSend) { Icon( - imageVector = Icons.Default.DoneAll, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Always send") } + TextButton(onClick = onAlwaysSend) { Text("Always send") } }, dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } + TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } }, ) return @@ -61,25 +38,31 @@ fun DiagnosticsPromptDialog( onDismissRequest = onDontSend, title = { Text("Prairie encountered a problem") }, text = { - Text( + val reportDescription = if (prompt.reportCount == 1) { - "A ${prompt.reportType.displayName().lowercase()} report is ready. Review it before deciding whether to send it." + "A ${prompt.reportType.displayName().lowercase()} report is ready. " + + "Review it before deciding whether to send it." + } else { + "${prompt.reportCount} diagnostics reports are ready. " + + "Review them before deciding whether to send them." + } + Text( + if (allowAlwaysSend) { + reportDescription } else { - "${prompt.reportCount} diagnostics reports are ready. Review them before deciding whether to send them." + "$reportDescription\n\nThe report includes the Prairie app version and build, Android version, " + + "device model, crash details, and diagnostic logs. Its pseudonymous credential is not " + + "linked to an account on your self-hosted server. Username, email, profile, server " + + "address, and playback session IDs are omitted. It never sends automatically and may " + + "be retained for up to 30 days." }, ) }, confirmButton = { - TextButton(onClick = onReview) { Icon( - imageVector = Icons.Default.Visibility, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Review") } + TextButton(onClick = onReview) { Text("Review") } }, dismissButton = { - ColumnButtons(onSend, { confirmAlways = true }, onDontSend) + ColumnButtons(onSend, if (allowAlwaysSend) ({ confirmAlways = true }) else null, onDontSend) }, ) } @@ -87,28 +70,12 @@ fun DiagnosticsPromptDialog( @Composable private fun ColumnButtons( onSend: () -> Unit, - onAlwaysSend: () -> Unit, + onAlwaysSend: (() -> Unit)?, onDontSend: () -> Unit, ) { - TextButton(onClick = onSend) { Icon( - imageVector = Icons.Default.Send, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Send") } - TextButton(onClick = onAlwaysSend) { Icon( - imageVector = Icons.Default.DoneAll, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Always send") } - TextButton(onClick = onDontSend) { Icon( - imageVector = Icons.Default.Block, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Don't send") } + TextButton(onClick = onSend) { Text("Send") } + onAlwaysSend?.let { action -> + TextButton(onClick = action) { Text("Always send") } + } + TextButton(onClick = onDontSend) { Text("Don't send") } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt index bc773367a..fa8c8251f 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt @@ -38,15 +38,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.android.ui.components.PrairieTopBar +import org.prairieserver.prairie.android.ui.screens.settings.SettingsSection import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionCard -import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionHeader +import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadDecision -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Send @Composable fun DiagnosticsReportScreen( @@ -59,10 +56,18 @@ fun DiagnosticsReportScreen( var confirmDelete by remember { mutableStateOf(false) } var uploading by remember { mutableStateOf(false) } var sentShortId by remember { mutableStateOf(null) } + var sentState by remember { mutableStateOf("processing") } var uploadNotice by remember { mutableStateOf(null) } + var uploadNoticeIsError by remember { mutableStateOf(true) } Scaffold( - topBar = { PrairieTopBar(title = "Report details", onBackClick = onBackClick) }, - containerColor = MaterialTheme.colorScheme.background, + topBar = { + PrairieTopBar( + title = "Report details", + onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, + ) + }, + containerColor = PrairieSettingsBackground, ) { padding -> val shortId = sentShortId when { @@ -71,6 +76,7 @@ fun DiagnosticsReportScreen( // reads as the report having vanished. shortId != null -> DiagnosticsSentConfirmation( shortId = shortId, + state = sentState, modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), onDone = onBackClick, ) @@ -81,7 +87,7 @@ fun DiagnosticsReportScreen( Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Spacer(Modifier.size(12.dp)) - Text("Sending report to your server…") + Text("Sending report…") } } else { Text("This report is no longer on this device.") @@ -99,8 +105,17 @@ fun DiagnosticsReportScreen( Text(report.capturedAt, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(12.dp)) DetailLine("Evidence", formatDiagnosticBytes(report.evidenceBytes)) - DetailLine("Destination", report.destinationServerInstanceId) - DetailLine("Captured profile", report.capturedProfileId ?: "Account scoped") + DetailLine( + "Destination", + if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "Prairie Diagnostics" + } else { + report.destinationServerInstanceId + }, + ) + if (report.destinationKind == DiagnosticsDestinationKind.SELF_HOSTED) { + DetailLine("Captured profile", report.capturedProfileId ?: "Account scoped") + } DetailLine("Expires", formatDiagnosticDate(report.expiresAtEpochMs)) DetailLine("Upload state", report.uploadStatus.name.lowercase().replace('_', ' ')) report.uploadErrorCode?.let { DetailLine("Last error", it) } @@ -108,8 +123,7 @@ fun DiagnosticsReportScreen( } } item { - SettingsSectionCard { - SettingsSectionHeader("Archive entries") + SettingsSection(title = "Archive entries") { report.archiveEntries.forEach { entry -> Text( entry, @@ -127,13 +141,20 @@ fun DiagnosticsReportScreen( CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) Spacer(Modifier.size(12.dp)) Text( - "Sending report to your server…", + "Sending report…", color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } uploadNotice?.let { notice -> - Text(notice, color = MaterialTheme.colorScheme.error) + Text( + notice, + color = if (uploadNoticeIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { Button( @@ -144,34 +165,28 @@ fun DiagnosticsReportScreen( viewModel.upload(report.id) { decision -> uploading = false when (decision) { - is DiagnosticsUploadDecision.Uploaded -> sentShortId = decision.shortId - else -> uploadNotice = uploadKeptMessage(decision) + is DiagnosticsUploadDecision.Uploaded -> { + sentShortId = decision.shortId + sentState = decision.state.wireValue + } + is DiagnosticsUploadDecision.HostedProcessing -> { + uploadNoticeIsError = false + uploadNotice = uploadKeptMessage(decision) + } + else -> { + uploadNoticeIsError = true + uploadNotice = uploadKeptMessage(decision) + } } } }, modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (uploading) "Sending…" else "Send") - } + ) { Text(if (uploading) "Sending…" else "Send") } OutlinedButton( enabled = !uploading, onClick = { confirmDelete = true }, modifier = Modifier.weight(1f), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Delete") - } + ) { Text("Delete") } } } } @@ -183,32 +198,23 @@ fun DiagnosticsReportScreen( AlertDialog( onDismissRequest = { confirmDelete = false }, title = { Text("Delete this report?") }, - text = { Text("The local evidence will be permanently removed from this device.") }, + text = { + Text( + if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "The local evidence will be removed from this device. If this report was already " + + "submitted, its copy in Silo Diagnostics will also be permanently deleted." + } else { + "The local evidence will be permanently removed from this device." + }, + ) + }, confirmButton = { TextButton(onClick = { confirmDelete = false viewModel.delete(report.id, onBackClick) - }) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Delete") - } - }, - dismissButton = { - TextButton(onClick = { confirmDelete = false }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") - } + }) { Text("Delete") } }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, ) } } @@ -216,6 +222,7 @@ fun DiagnosticsReportScreen( @Composable private fun DiagnosticsSentConfirmation( shortId: String, + state: String, modifier: Modifier, onDone: () -> Unit, ) { @@ -239,28 +246,24 @@ private fun DiagnosticsSentConfirmation( } } Spacer(Modifier.height(12.dp)) + DetailLine("Processing state", state.replace('_', ' ')) + Spacer(Modifier.height(12.dp)) Text( - "The report was removed from this device once your server received a copy. " + - "Share the reference ID with your server admin so they can find it.", + "The report was removed from this device after the destination accepted a copy. " + + "Share the reference ID with Silo Diagnostics or your server admin.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) } } - Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Done") - } + Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Done") } } } internal fun uploadKeptMessage(decision: DiagnosticsUploadDecision): String = when (decision) { is DiagnosticsUploadDecision.Uploaded -> "" // handled by the caller + is DiagnosticsUploadDecision.HostedProcessing -> + "Report ${decision.shortId} was accepted and is still processing. It will be checked again automatically." DiagnosticsUploadDecision.KeptRetryable -> "The upload didn't go through. The report stays on this device to try again later." DiagnosticsUploadDecision.KeptIdentityChanged -> diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt index bd929c568..9b7bf2c71 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt @@ -1,13 +1,11 @@ package org.prairieserver.prairie.android.ui.screens.settings.diagnostics -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -20,9 +18,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -31,28 +27,33 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import java.text.DateFormat import java.util.Date import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.android.ui.components.PrairieTopBar +import org.prairieserver.prairie.android.ui.screens.settings.SettingsChoiceRow +import org.prairieserver.prairie.android.ui.screens.settings.SettingsNavigationRow +import org.prairieserver.prairie.android.ui.screens.settings.SettingsProse import org.prairieserver.prairie.android.ui.screens.settings.SettingsRow +import org.prairieserver.prairie.android.ui.screens.settings.SettingsSection import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionCard -import org.prairieserver.prairie.android.ui.screens.settings.SettingsSectionHeader +import org.prairieserver.prairie.android.ui.screens.settings.SettingsSwitchRow +import org.prairieserver.prairie.android.ui.theme.SettingsDimens +import org.prairieserver.prairie.android.ui.theme.SettingsTextStyles +import org.prairieserver.prairie.android.ui.theme.PrairieForeground +import org.prairieserver.prairie.android.ui.theme.PrairieMutedText +import org.prairieserver.prairie.android.ui.theme.PrairieSettingsBackground +import org.prairieserver.prairie.android.ui.theme.Spacing import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState import org.prairieserver.prairie.common.diagnostics.TimedCaptureStatus -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.FiberManualRecord -import androidx.compose.material.icons.filled.Send -import androidx.compose.material.icons.filled.Stop @Composable fun DiagnosticsSettingsScreen( @@ -69,6 +70,7 @@ fun DiagnosticsSettingsScreen( state = state, onBackClick = onBackClick, onConsentChanged = viewModel::setConsent, + onDestinationChanged = viewModel::setDestination, onDebugLoggingChanged = viewModel::setDebugLogging, onSendNow = { viewModel.captureNow(onReportSelected) }, onStartCapture = viewModel::startTimedCapture, @@ -83,6 +85,7 @@ internal fun DiagnosticsSettingsContent( state: DiagnosticsUiState, onBackClick: () -> Unit, onConsentChanged: (DiagnosticsConsentMode) -> Unit, + onDestinationChanged: (DiagnosticsDestinationKind) -> Unit, onDebugLoggingChanged: (Boolean) -> Unit, onSendNow: () -> Unit, onStartCapture: () -> Unit, @@ -91,117 +94,138 @@ internal fun DiagnosticsSettingsContent( onReportSelected: (String) -> Unit, ) { var confirmAlways by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current val model = diagnosticsPhoneScreenModel(state) + val effectiveConsent = if ( + state.consent == DiagnosticsConsentMode.ALWAYS && !state.allowsAutomaticUpload + ) { + DiagnosticsConsentMode.ASK + } else { + state.consent + } Scaffold( - topBar = { PrairieTopBar(title = "Diagnostics", onBackClick = onBackClick) }, - containerColor = MaterialTheme.colorScheme.background, + topBar = { + PrairieTopBar( + title = "Diagnostics", + onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, + ) + }, + containerColor = PrairieSettingsBackground, ) { padding -> LazyColumn( modifier = Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(SettingsDimens.pageGutter), + verticalArrangement = Arrangement.spacedBy(SettingsDimens.sectionGap), ) { - item { DiagnosticsStatusCard(state.availability) } item { - SettingsSectionCard { - SettingsSectionHeader("Crash reports") - DiagnosticsConsentMode.entries.forEach { mode -> - val label = when (mode) { - DiagnosticsConsentMode.ASK -> "Ask before sending" - DiagnosticsConsentMode.ALWAYS -> "Always send" - DiagnosticsConsentMode.NEVER -> "Never send" + SettingsSection(title = "Send reports to") { + DiagnosticsDestinationKind.entries.forEach { destination -> + val label = when (destination) { + DiagnosticsDestinationKind.HOSTED -> "Prairie Diagnostics" + DiagnosticsDestinationKind.SELF_HOSTED -> "This Prairie server" } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { + SettingsChoiceRow( + label = label, + selected = state.destinationKind == destination, + onSelect = { onDestinationChanged(destination) }, + ) + } + SettingsProse( + body = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "Reports include the Silo app version and build, Android version, device model, " + + "crash details, and diagnostic logs you review. A pseudonymous installation " + + "credential is not linked to an account on your self-hosted server. Username, " + + "email, profile, server address, and playback session IDs are omitted. Reports " + + "are never sent automatically and may be retained for up to " + + "${state.retentionDays} days." + } else { + "Compatibility mode sends reports to the diagnostics endpoint on your active server." + }, + ) + TextButton( + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, + modifier = Modifier.padding( + start = SettingsDimens.rowHorizontalPadding - 12.dp, + bottom = SettingsDimens.rowVerticalPadding, + ), + ) { + Text("Privacy Policy") + } + } + } + item { DiagnosticsStatusCard(state) } + item { + SettingsSection(title = "Crash reports") { + DiagnosticsConsentMode.entries + .filter { it != DiagnosticsConsentMode.ALWAYS || state.allowsAutomaticUpload } + .forEach { mode -> + val label = when (mode) { + DiagnosticsConsentMode.ASK -> "Ask before sending" + DiagnosticsConsentMode.ALWAYS -> "Always send" + DiagnosticsConsentMode.NEVER -> "Never send" + } + SettingsChoiceRow( + label = label, + selected = effectiveConsent == mode, + onSelect = { if (consentActionModel(state.consent, mode).requiresConfirmation) { confirmAlways = true } else { onConsentChanged(mode) } - } - .padding(horizontal = 12.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = state.consent == mode, - onClick = null, + }, ) - Text(label, style = MaterialTheme.typography.bodyLarge) } - } - SettingsRow( + // The shared switch row now carries `enabled`, so this no + // longer needs its own hand-rolled copy of it. + SettingsSwitchRow( label = "Debug logging", - trailing = { - Switch( - checked = state.debugLogging, - enabled = state.consent != DiagnosticsConsentMode.NEVER, - onCheckedChange = onDebugLoggingChanged, - ) - }, + description = "Record extra detail so a report can explain what went wrong.", + checked = state.debugLogging, + enabled = state.consent != DiagnosticsConsentMode.NEVER, + onCheckedChange = onDebugLoggingChanged, ) } } item { - SettingsSectionCard { - SettingsSectionHeader("Capture") + SettingsSection(title = "Capture") { + val paneModifier = Modifier.padding( + horizontal = SettingsDimens.proseHorizontalPadding, + vertical = SettingsDimens.proseVerticalPadding, + ) if (state.timedCapture.status == TimedCaptureStatus.ACTIVE) { - Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Text("Diagnostic capture is running", fontWeight = FontWeight.SemiBold) + Column(paneModifier) { + Text( + "Diagnostic capture is running", + style = SettingsTextStyles.rowLabel, + color = PrairieForeground, + ) Text( "Reproduce the issue, then stop to review exactly what will be sent.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, + color = PrairieMutedText, + style = SettingsTextStyles.rowDescription, ) - Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button(onClick = onStopCapture) { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Stop & review") - } - OutlinedButton(onClick = onCancelCapture) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel") - } + Spacer(Modifier.height(Spacing.md)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Button(onClick = onStopCapture) { Text("Stop & review") } + OutlinedButton(onClick = onCancelCapture) { Text("Cancel") } } } } else { - Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Column(paneModifier) { Button(onClick = onSendNow, enabled = model.canCapture) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Send diagnostics now") } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(Spacing.sm)) OutlinedButton(onClick = onStartCapture, enabled = model.canCapture) { - Icon( - imageVector = Icons.Default.FiberManualRecord, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Start diagnostic capture") } Text( "A one-time report uses the recent in-memory log. Timed capture records more detail until you stop it.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 10.dp), + color = PrairieMutedText, + style = SettingsTextStyles.rowDescription, + modifier = Modifier.padding(top = Spacing.sm), ) } } @@ -209,22 +233,13 @@ internal fun DiagnosticsSettingsContent( } if (model.showPending) { item { - SettingsSectionCard { - SettingsSectionHeader("Pending reports") + SettingsSection(title = "Pending reports") { state.pending.forEach { report -> - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { onReportSelected(report.id) } - .padding(horizontal = 16.dp, vertical = 11.dp), - ) { - Text(report.type.displayName(), fontWeight = FontWeight.Medium) - Text( - "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } + SettingsNavigationRow( + label = report.type.displayName(), + description = "${report.capturedAt} · ${formatDiagnosticBytes(report.evidenceBytes)}", + onClick = { onReportSelected(report.id) }, + ) } } } @@ -233,45 +248,45 @@ internal fun DiagnosticsSettingsContent( item { val clipboard = LocalClipboardManager.current Column { - SettingsSectionCard { - SettingsSectionHeader("Recently sent") + SettingsSection(title = "Recently sent") { state.sentHistory.forEach { sent -> - SettingsRow(label = sent.shortId) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - formatDiagnosticDate(sent.sentAtEpochMs), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, + SettingsRow( + label = sent.shortId, + description = "${sent.state.replace('_', ' ')} · " + + formatDiagnosticDate(sent.sentAtEpochMs), + ) { + IconButton( + onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = "Copy reference ID", + tint = PrairieMutedText, + modifier = Modifier.size(18.dp), ) - IconButton( - onClick = { clipboard.setText(AnnotatedString(sent.shortId)) }, - ) { - Icon( - imageVector = Icons.Outlined.ContentCopy, - contentDescription = "Copy reference ID", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } } } } } Text( - "Sent reports are removed from this device once your server has a copy. " + + "Sent reports are removed from this device once the selected destination has a copy. " + "Use the reference ID when asking for help.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp), + color = PrairieMutedText, + style = SettingsTextStyles.rowDescription, + modifier = Modifier.padding( + start = SettingsDimens.headerStartInset, + end = SettingsDimens.headerStartInset, + top = Spacing.sm, + ), ) } } } - item { Spacer(Modifier.height(24.dp)) } + item { Spacer(Modifier.height(SettingsDimens.pageBottomSpacer)) } } } - if (confirmAlways) { + if (confirmAlways && state.allowsAutomaticUpload) { AlertDialog( onDismissRequest = { confirmAlways = false }, title = { Text("Always send crash reports?") }, @@ -285,44 +300,48 @@ internal fun DiagnosticsSettingsContent( }) { Text("Always send") } }, dismissButton = { - TextButton(onClick = { confirmAlways = false }) { Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier = Modifier.width(8.dp)) - Text("Cancel") } + TextButton(onClick = { confirmAlways = false }) { Text("Cancel") } }, ) } } +private const val PRIVACY_POLICY_URL = "https://prairieserver.org/privacy" + @Composable private fun DiagnosticsUnavailableScreen(onBackClick: () -> Unit) { Scaffold( - topBar = { PrairieTopBar(title = "Diagnostics", onBackClick = onBackClick) }, - containerColor = MaterialTheme.colorScheme.background, + topBar = { + PrairieTopBar( + title = "Diagnostics", + onBackClick = onBackClick, + containerColor = PrairieSettingsBackground, + ) + }, + containerColor = PrairieSettingsBackground, ) { padding -> - Column(Modifier.fillMaxSize().padding(padding).padding(24.dp)) { - Text("Diagnostics aren't available for this profile.", style = MaterialTheme.typography.titleMedium) + Column(Modifier.fillMaxSize().padding(padding).padding(Spacing.xxl)) { + Text( + "Diagnostics aren't available for this profile.", + style = MaterialTheme.typography.titleMedium, + color = PrairieForeground, + ) } } } @Composable -private fun DiagnosticsStatusCard(availability: DiagnosticsAvailabilityUi) { - val (title, detail) = when (availability) { - DiagnosticsAvailabilityUi.AVAILABLE -> "Available" to "Reports can be reviewed and sent to this server." - DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" to "Local reports remain available to inspect or delete." - DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Server storage unavailable" to "Local reports remain on this device." +private fun DiagnosticsStatusCard(state: DiagnosticsUiState) { + val destination = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) "Prairie Diagnostics" else "this server" + val (title, detail) = when (state.availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" to "Reports can be reviewed and sent to $destination." + DiagnosticsAvailabilityUi.DISABLED -> "Disabled" to "Local reports remain available to inspect or delete." + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" to "Local reports remain on this device." DiagnosticsAvailabilityUi.OFFLINE -> "Offline" to "Connect to refresh diagnostics availability." DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" to "Diagnostics are not available for this profile." } SettingsSectionCard { - Column(Modifier.padding(16.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text(detail, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) - } + SettingsProse(title = title, body = detail) } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt index 9ca74edeb..b857ec5d9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt @@ -30,16 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.watchtogether.canDismissRoomEntry import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.Icons -import androidx.compose.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.automirrored.filled.ArrowBack /** * Watch Together entry sheet, opened from the item-detail overflow. @@ -101,20 +93,10 @@ fun WatchTogetherEntrySheet( onClick = { viewModel.host(contentId, fileId) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(if (state.busy) "Creating…" else "Host a room") - } + ) { Text(if (state.busy) "Creating…" else "Host a room") } OutlinedButton( - onClick = { - viewModel.host(contentId, fileId, RoomSelectionMode.Vote) - }, + onClick = { viewModel.hostEmptyVoteRoom() }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), ) { Text("Host a vote room") } @@ -123,15 +105,7 @@ fun WatchTogetherEntrySheet( onClick = { viewModel.clearError(); showJoin = true }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.Default.Link, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Join by code") - } + ) { Text("Join by code") } } else { OutlinedTextField( value = code, @@ -146,31 +120,14 @@ fun WatchTogetherEntrySheet( enabled = !state.busy && code.length >= 4, modifier = Modifier.fillMaxWidth(), ) { - if (state.busy) { - CircularProgressIndicator(modifier = Modifier.height(18.dp)) - } else { - Icon( - imageVector = Icons.Default.Link, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Join") - } + if (state.busy) CircularProgressIndicator(modifier = Modifier.height(18.dp)) + else Text("Join") } OutlinedButton( onClick = { viewModel.clearError(); showJoin = false }, enabled = !state.busy, modifier = Modifier.fillMaxWidth(), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Back") - } + ) { Text("Back") } } } Spacer(Modifier.height(24.dp)) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt index b59565f84..06b9e5ea9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt @@ -5,16 +5,21 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.android.ui.navigation.Route import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest -import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -26,16 +31,13 @@ import kotlinx.coroutines.launch * Kept top-level + pure so it is unit-testable without Compose or the repo. */ fun watchTogetherDestination(room: RoomSnapshot): String = - if (!room.selectedContentId.isNullOrBlank() && - !(room.selfRole == MemberRole.Host && room.memberCount <= 1) - ) { - Route.Player( - contentId = room.selectedContentId!!, + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Player -> Route.Player( + contentId = requireNotNull(room.selectedContentId), fileId = room.selectedFileId, roomId = room.roomId, ).route - } else { - Route.WatchTogetherLobby(roomId = room.roomId).route + WatchTogetherEntryTarget.Lobby -> Route.WatchTogetherLobby(roomId = room.roomId).route } /** @@ -43,13 +45,13 @@ fun watchTogetherDestination(room: RoomSnapshot): String = * the room selection) or joins an existing room by invite code, then resolves a * navigation [UiState.destination] the sheet observes. * - * The repository stores the room JWT internally on create/join and reads the + * The gateway stores the room JWT internally on create/join and reads the * active roomId from its own snapshot, so [setSelection] takes only the request. * Create/join/selection all return a `{room, room_access_token}` [RoomResponse] * wrapper; the snapshot lives at `.data.room`. */ class WatchTogetherEntryViewModel( - private val repository: WatchTogetherRepository, + private val gateway: WatchTogetherEntryGateway, ) : ViewModel() { data class UiState( @@ -61,6 +63,13 @@ class WatchTogetherEntryViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) /** Host flow: create a room with this title pre-selected as the room selection. */ fun host( @@ -68,22 +77,22 @@ class WatchTogetherEntryViewModel( fileId: Int?, selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, ) { + if (selectionMode == RoomSelectionMode.Vote) { + hostEmptyVoteRoom() + return + } if (_uiState.value.busy) return _uiState.update { it.copy(busy = true, error = null) } viewModelScope.launch { when ( - val created = repository.createRoom( + val created = gateway.createRoom( CreateRoomRequest(selectionMode = selectionMode.wire), ) ) { is ApiResult.Success -> { - if (selectionMode == RoomSelectionMode.Vote) { - finish(created.data.room) - return@launch - } // Set this title as the room selection so everyone lands on it. when ( - val sel = repository.setSelection( + val sel = gateway.setSelection( SetSelectionRequest(contentId = contentId, fileId = fileId), ) ) { @@ -98,13 +107,41 @@ class WatchTogetherEntryViewModel( } } + /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ + fun hostEmptyVoteRoom() { + if (_uiState.value.busy) return + _uiState.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + /** Resume the valid room snapshot already held by the process session. */ + fun resumeCurrentRoom() { + if (_uiState.value.busy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } + /** Join flow: resolve an invite code; route to player if a selection exists, else lobby. */ fun joinByCode(code: String) { val trimmed = code.trim() if (_uiState.value.busy || trimmed.isBlank()) return _uiState.update { it.copy(busy = true, error = null) } viewModelScope.launch { - when (val joined = repository.joinRoom(JoinRoomRequest(code = trimmed))) { + when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { is ApiResult.Success -> finish(joined.data.room) is ApiResult.Error, is ApiResult.NetworkError -> fail(joined.errorMessage("Could not join — check the code")) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt index a1591e521..48cc79a48 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.android.ui.screens.watchtogether import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,7 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.HowToVote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button @@ -77,6 +78,14 @@ fun WatchTogetherLobbyScreen( if (closedReason != null) onBack() } + LaunchedEffect(viewModel) { + viewModel.errors.collect { message -> + if (message.isNotBlank()) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + } + // Role drives only the cosmetic header label; mutating controls gate on the // server's per-recipient management capability so a demoted/grace-period host // (selfRole still "host" but management revoked) doesn't see dead buttons. @@ -88,11 +97,22 @@ fun WatchTogetherLobbyScreen( TopAppBar( title = { Text("Watch Together") }, navigationIcon = { - IconButton(onClick = { viewModel.leave(); onBack() }) { - Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "Leave room") + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to browse", + ) } }, actions = { + TextButton( + onClick = { + viewModel.leave() + onBack() + }, + ) { + Text("Leave room") + } if (canManage) { TextButton(onClick = { viewModel.closeRoom() }) { Text("Close") } } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt index d8ff5ae21..0fe7ebeb1 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyViewModel.kt @@ -8,6 +8,8 @@ import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.RoomPhase import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.watchtogether.RoomSession import kotlinx.coroutines.flow.SharingStarted @@ -62,17 +64,36 @@ class WatchTogetherLobbyViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) val roomClosedReason: StateFlow = repository.roomClosedReason .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) + val errors = repository.errors + + fun vote(suggestionId: String) = + launchOperation("Could not vote") { repository.vote(suggestionId) } + + fun unvote(suggestionId: String) = + launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - fun vote(suggestionId: String) = viewModelScope.launch { repository.vote(suggestionId) } - fun unvote(suggestionId: String) = viewModelScope.launch { repository.unvote(suggestionId) } fun removeSuggestion(suggestionId: String) = - viewModelScope.launch { repository.deleteSuggestion(suggestionId) } + launchOperation("Could not remove suggestion") { + repository.deleteSuggestion(suggestionId) + } /** Host: promote a suggestion to the room selection (moves everyone to the player). */ fun promote(suggestionId: String) = - viewModelScope.launch { repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) } + launchOperation("Could not start suggestion") { + repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) + } - fun closeRoom() = viewModelScope.launch { repository.closeRoom() } + fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } + + private fun launchOperation( + fallback: String, + operation: suspend () -> ApiResult, + ) = viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) { + repository.reportDeliveryFailure(result.errorMessage(fallback)) + } + } /** Guest/host leave: tear down the WS + clear room state. */ fun leave() { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt new file mode 100644 index 000000000..d2e466b2b --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt @@ -0,0 +1,127 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchTogetherMenuEntrySheet( + onNavigate: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchTogetherEntryViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val currentRoom by viewModel.currentRoom.collectAsState() + var code by rememberSaveable { mutableStateOf("") } + var showJoin by rememberSaveable { mutableStateOf(false) } + val latestBusy by rememberUpdatedState(state.busy) + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + onDismiss() + onNavigate(destination) + } + + ModalBottomSheet( + onDismissRequest = { + if (canDismissRoomEntry(state.busy)) { + viewModel.clearError() + onDismiss() + } + }, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { target -> + target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) + }, + ), + ) { + Text( + text = "Watch Together", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + HorizontalDivider() + + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (!showJoin) { + if (currentRoom != null) { + Button( + onClick = { viewModel.resumeCurrentRoom() }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Resume current room") } + } + Button( + onClick = { viewModel.hostEmptyVoteRoom() }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.busy) "Creating…" else "Host a room") } + OutlinedButton( + onClick = { viewModel.clearError(); showJoin = true }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Join by code") } + } else { + OutlinedTextField( + value = code, + onValueChange = { + code = it.uppercase().filter(Char::isLetterOrDigit).take(8) + }, + label = { Text("Invite code") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.joinByCode(code) }, + enabled = !state.busy && code.length >= 4, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.busy) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text("Join") + } + OutlinedButton( + onClick = { viewModel.clearError(); showJoin = false }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Back") } + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + Spacer(Modifier.height(24.dp)) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt index 2389b9afe..4c2993c20 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt @@ -2,29 +2,70 @@ package org.prairieserver.prairie.android.ui.theme import androidx.compose.ui.graphics.Color -// Prairie Dusk palette — mirrors prairie-server Phase 3 / prairie-smarttv / iOS Theme/Colors.swift. -// Deep slate surfaces + amber wheat accent (#E0A84A). - -val PrairieBackground = Color(0xFF141820) -val PrairieSurface = Color(0xFF1C222C) -val PrairieSurfaceVariant = Color(0xFF0E1116) -val PrairieSurfaceElevated = Color(0xFF222B38) -/** Brand / Material primary — amber wheat */ -val PrairiePrimary = Color(0xFFE0A84A) -val PrairieOnSurface = Color(0xFFF2EEE6) -val PrairieSecondaryText = Color(0xFF9AA3B2) +// Plezy OLED Dark palette — mirrors iosApp/iosApp/Theme/Colors.swift exactly. +// Pure-black backgrounds, EDEDED primary text, white-at-opacity for everything else. + +val PrairieBackground = Color(0xFF000000) +val PrairieSurface = Color(0xFF0A0A0A) +val PrairieSurfaceVariant = Color(0xFF0E0F12) +val PrairieSurfaceElevated = Color(0xFF15171C) +val PrairiePrimary = Color(0xFFEDEDED) +val PrairieOnSurface = Color(0xFFEDEDED) +val PrairieSecondaryText = Color(0xFFEDEDED).copy(alpha = 0.60f) val PrairieDisabled = Color(0xFF4B5563) -val PrairieOutline = PrairieOnSurface.copy(alpha = 0.12f) -val PrairieDivider = PrairieOnSurface.copy(alpha = 0.12f) -val PrairieOverlay = PrairieBackground.copy(alpha = 0.72f) +val PrairieOutline = Color.White.copy(alpha = 0.12f) +val PrairieDivider = Color.White.copy(alpha = 0.12f) +val PrairieOverlay = Color.Black.copy(alpha = 0.60f) + +// --- Grouped-surface palette (Prairie web client parity) --- +// +// The OLED values above are the app's chrome: pure black grounds with +// white-at-opacity on top. That reads well over artwork and badly over a long +// form, where a card has to separate from its page without a border and a +// hairline has to be visible without glowing. These are the web client's +// settings values, and they fill M3's `surfaceContainer*` ladder — which +// `darkColorScheme` otherwise leaves on its purple-tinted baseline. + +/** Lifted page ground for form-shaped screens. Web `--background`. */ +val PrairieSettingsBackground = Color(0xFF141417) + +/** Grouped card surface. Web `--card`. */ +val PrairieSurfaceContainer = Color(0xFF1C1C20) + +val PrairieSurfaceContainerLowest = Color(0xFF060608) +val PrairieSurfaceContainerLow = Color(0xFF141417) +val PrairieSurfaceContainerHigh = Color(0xFF24242A) +val PrairieSurfaceContainerHighest = Color(0xFF2C2C33) +val PrairieSurfaceDim = Color(0xFF000000) +val PrairieSurfaceBright = Color(0xFF2C2C33) + +/** Hairline between rows and around inset controls. Web `--border`. */ +val PrairieBorder = Color(0xFF28282E) + +/** Secondary copy on a grouped surface. Web `--muted-foreground`. */ +val PrairieMutedText = Color(0xFF9696A0) + +/** Primary copy on a grouped surface. Web `--foreground`. */ +val PrairieForeground = Color(0xFFE8E8EC) + +/** + * The single destructive tint. + * + * Settings previously carried two: `colorScheme.error` (0xFFB00020, an M3 + * *light*-theme red that fails contrast on a dark card) for Sign Out and Reset + * Playback Overrides, and an iOS system red (0xFFFF453A) for Remove All + * Downloads. Web `--destructive`. + */ +val PrairieDestructive = Color(0xFFEF4444) val PrairieError = Color(0xFFB00020) val PrairieOnError = Color(0xFFFFFFFF) -val PrairieSuccess = Color(0xFF34C759) +val PrairieSuccess = Color(0xFF34C759) // SwiftUI .green on dark val PrairieWarning = Color(0xFFFFC107) -// Backwards-compatible aliases. +// Backwards-compatible aliases preserved so downstream code keeps compiling. +// They now resolve to the iOS Plezy values rather than the legacy cream palette. val PrairieWhite = PrairieOnSurface val PrairieWhiteSoft = PrairieOnSurface val PrairieWhiteMuted = PrairieSecondaryText diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt new file mode 100644 index 000000000..7f0d8499a --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Spacing.kt @@ -0,0 +1,240 @@ +package org.prairieserver.prairie.android.ui.theme + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Phone spacing scale. The TV app has carried a `Spacing` scale since its + * first cut; the phone never did, so every dp lived at its use site and + * drifted section by section. This is the phone half of that pair — roughly + * the tvOS scale halved, which is where the iPhone values in + * `PrairieTheme.swift` already sit. + */ +object Spacing { + val xxs = 2.dp + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 20.dp + val xxl = 24.dp + val xxxl = 32.dp + + /** Horizontal inset for scrolling page content. */ + val pageGutter = 16.dp +} + +/** + * Metrics for the grouped-settings surface. + * + * Mirrors the Silo web client's settings pages: a lifted page ground, opaque + * grouped cards with a generous radius, a lettered section heading sitting + * *above* its card, and rows that are tall enough to carry a label over a + * description. Android row mechanics are kept — the control stays trailing + * rather than stacking under the label the way the web layout does. + * + * Every value the settings tree needs lives here so the next visual pass has + * one file to edit instead of twenty-two literals spread across six files. + */ +object SettingsDimens { + /** Page gutter for the settings list. */ + val pageGutter = Spacing.pageGutter + + /** Leading gap above the first section. */ + val pageTopPadding = Spacing.sm + + /** Trailing scroll runway so the last card clears the navigation bar. */ + val pageBottomSpacer = Spacing.xxxl + + /** Gap between two grouped cards, heading included. */ + val sectionGap = 22.dp + + /** Grouped card corner radius. */ + val cardRadius = 20.dp + + /** Inset of the section heading relative to the card's leading edge. */ + val headerStartInset = Spacing.xs + + /** Gap between a section heading and its card. */ + val headerBottomGap = Spacing.sm + + /** Minimum row height. Every row honours this so a row with a description + * and a row without still read as the same list. */ + val rowMinHeight = 60.dp + + /** Row content insets. */ + val rowHorizontalPadding = Spacing.lg + val rowVerticalPadding = Spacing.md + + /** Gap between a row label and its description. */ + val rowLabelGap = Spacing.xxs + + /** + * Gap between the row text block and its trailing control — chevron, + * switch, radio. Never text: a trailing *value* rides on the label's own + * line instead (see [rowLabelValueGap]), so a description can never end up + * a hairsbreadth from it. + */ + val rowTrailingGap = Spacing.md + + /** + * Minimum gap between a row label and the trailing value sharing its line. + * Only binds when the label is long enough to reach the value; a short + * label leaves the value trailing-aligned with slack between them. + */ + val rowLabelValueGap = Spacing.md + + /** + * Cap on a trailing value's width, so a long one (a server URL) cannot + * crush the label it sits beside. The value ellipsizes at this width; the + * label wraps. Sized so the longest authored value on this surface — + * "30 seconds before end" — still renders whole. + */ + val rowValueMaxWidth = 170.dp + + /** Hairline between rows. */ + val dividerThickness = 1.dp + + /** Divider inset, aligned to the row label. */ + val dividerStartInset = Spacing.lg + + /** Disclosure chevron. */ + val chevronSize = 18.dp + + /** Account header avatar. */ + val avatarSize = 56.dp + val avatarIconSize = 30.dp + val avatarGap = 14.dp + + /** Inset for prose blocks that sit inside a card rather than on a row. */ + val proseHorizontalPadding = Spacing.lg + val proseVerticalPadding = Spacing.md + + /** Divider opacity over [PrairieSurfaceContainer]. */ + const val dividerAlpha = 0.55f + + /** Opacity applied to a disabled row's text and controls. */ + const val disabledAlpha = 0.45f +} + +/** + * Metrics for the app's popup menus. + * + * A menu is the settings card's terse sibling — same opaque surface, same + * hairline, same label type — with three deliberate differences: + * + * - No description line, so [SettingsDimens.rowMinHeight]'s 60dp floor (which + * exists to carry that second line) would only pad a menu out. 48dp is the + * platform touch-target minimum and the height a Material menu row already + * uses. + * - No leading icon, for the same reason the settings rows dropped theirs. + * - A tighter corner than [SettingsDimens.cardRadius]: a popup is smaller than + * a full-width card, and the card's 20dp on a ~190dp-wide surface reads as a + * pill rather than as the same shape family. + * + * [rowHorizontalPadding] is deliberately the settings value, so a menu + * hairline drawn at [SettingsDimens.dividerStartInset] lands exactly on the + * label's leading edge the way it does on a settings card. + */ +object MenuDimens { + /** Popup corner radius. */ + val cornerRadius = 16.dp + + /** Menu row height. Clears the 48dp touch-target minimum exactly. */ + val rowMinHeight = 48.dp + + val rowHorizontalPadding = SettingsDimens.rowHorizontalPadding + val rowVerticalPadding = Spacing.sm + + /** + * Floor on the popup's width. A menu of short labels ("Settings") would + * otherwise size down to a sliver; Material's own menus carry a similar + * minimum. + */ + val minWidth = 184.dp + + /** Outline separating the popup from whatever artwork sits behind it. */ + val borderThickness = SettingsDimens.dividerThickness +} + +/** + * The hairline colour shared by every grouped row and menu row. + */ +val PrairieRowDividerColor = PrairieBorder.copy(alpha = SettingsDimens.dividerAlpha) + +/** + * Draws the standard inter-row hairline along this element's top edge, inset + * from the leading edge so it starts at the row's label. + * + * Lives in the theme package rather than beside the settings rows because the + * popup menus draw the same line, and two implementations of one hairline is + * exactly the drift these tokens exist to prevent. + */ +fun Modifier.siloRowTopDivider(show: Boolean): Modifier = + if (!show) { + this + } else { + drawBehind { + val start = SettingsDimens.dividerStartInset.toPx() + drawRect( + color = PrairieRowDividerColor, + topLeft = Offset(start, 0f), + size = Size(size.width - start, SettingsDimens.dividerThickness.toPx()), + ) + } + } + +/** + * Type ramp for the grouped-settings surface. + * + * The M3 ramp in [PrairieTypography] mirrors the iOS point sizes and has no slot + * for a row description, so the settings rows previously had a single 16sp + * label and nothing else. These four styles are the hierarchy the web client + * uses: a lettered caps heading, a medium-weight label, a muted description, + * and a smaller trailing value that reads as a picker's current choice rather + * than as a second label. + */ +object SettingsTextStyles { + val sectionHeader = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + lineHeight = 16.sp, + letterSpacing = 0.9.sp, + ) + + val rowLabel = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.5.sp, + lineHeight = 19.sp, + ) + + val rowDescription = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.5.sp, + lineHeight = 17.sp, + ) + + val rowValue = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 13.5.sp, + lineHeight = 18.sp, + ) + + val accountName = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 19.sp, + lineHeight = 24.sp, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Theme.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Theme.kt index 433f57770..1db173ef6 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Theme.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Theme.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat // iOS pins .preferredColorScheme(.dark) in ContentView.swift, so Android matches by -// always emitting the Prairie Dusk scheme regardless of system or preference toggles. +// always emitting the Plezy OLED scheme regardless of system or preference toggles. private val PrairieDarkColorScheme = darkColorScheme( primary = PrairiePrimary, onPrimary = PrairieBackground, @@ -33,6 +33,18 @@ private val PrairieDarkColorScheme = darkColorScheme( onSurface = PrairieOnSurface, surfaceVariant = PrairieSurfaceVariant, onSurfaceVariant = PrairieSecondaryText, + // `darkColorScheme` leaves the container ladder on M3's purple-tinted + // baseline, so anything reaching for `surfaceContainer*` used to land off + // the Prairie palette entirely (the cast bars did, and the settings cards + // avoided the roles by borrowing `primaryContainer`). Populated so the + // roles mean what they say. + surfaceContainerLowest = PrairieSurfaceContainerLowest, + surfaceContainerLow = PrairieSurfaceContainerLow, + surfaceContainer = PrairieSurfaceContainer, + surfaceContainerHigh = PrairieSurfaceContainerHigh, + surfaceContainerHighest = PrairieSurfaceContainerHighest, + surfaceDim = PrairieSurfaceDim, + surfaceBright = PrairieSurfaceBright, outline = PrairieOutline, outlineVariant = PrairieOutline, inverseSurface = PrairieOnSurface, diff --git a/androidApp/src/androidMain/res/drawable/prairie_wordmark.png b/androidApp/src/androidMain/res/drawable/prairie_wordmark.png index 57afa0a25..c9694d5db 100644 Binary files a/androidApp/src/androidMain/res/drawable/prairie_wordmark.png and b/androidApp/src/androidMain/res/drawable/prairie_wordmark.png differ diff --git a/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png index 18ccdd48b..da35bc74d 100644 Binary files a/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png and b/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png b/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png index 54f64ccc7..02a22d83a 100644 Binary files a/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png and b/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png index 3bbf210b1..0f63dde1f 100644 Binary files a/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png and b/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png b/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png index 5a510f036..bf9c55bbe 100644 Binary files a/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png and b/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png index 1c17c3518..149c10b6d 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png and b/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png b/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png index d5dbe3b5e..2ffff98b4 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png and b/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png index 372fb058a..e1a598ca3 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png and b/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png index 23b1f277b..8083390b7 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png index e39ac3a33..e6df8d7b3 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png and b/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 7d693b1ff..a551885a5 100644 Binary files a/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index 2e9be0f28..8aaea8c0f 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -17,11 +17,27 @@ Sign In Sign Out + + Keep Remote Control connected + Allow Silo to run without battery restrictions so Remote Control can stay connected after you leave the app. In Battery optimization, choose Silo and set it to Not optimized. + Battery settings + Not now + Loading… Something went wrong Nothing here yet + + Skip Intro + Watch Intro + Intro skipped + Skip intros + Never + Ask to skip + Skip automatically + Coming soon diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/AndroidManifestPolicyTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/AndroidManifestPolicyTest.kt index cf80dc359..63578ad90 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/AndroidManifestPolicyTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/AndroidManifestPolicyTest.kt @@ -16,9 +16,9 @@ class AndroidManifestPolicyTest { } @Test - fun mobileKeepsAndroid7InstallFloor() { + fun mobileKeepsAndroid7InstallFloorAndTargetsApi36() { assertTrue(buildFile.contains("minSdk = 24")) - assertTrue(buildFile.contains("targetSdk = 35")) + assertTrue(buildFile.contains("targetSdk = 36")) assertTrue(buildFile.contains("compileSdk = 36")) } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarterTest.kt new file mode 100644 index 000000000..05fef1f62 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/cast/PrairieCastMediaSessionStarterTest.kt @@ -0,0 +1,44 @@ +package org.prairieserver.prairie.android.cast + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PrairieCastMediaSessionStarterTest { + @Test + fun `active playback starts a foreground service only while app is foregrounded`() { + val active = RemoteServiceState(hasMedia = true, needsForegroundStart = true) + + assertEquals( + RemoteMediaServiceAction.StartForeground, + resolveRemoteMediaServiceAction(active, appForeground = true), + ) + assertEquals( + RemoteMediaServiceAction.None, + resolveRemoteMediaServiceAction(active, appForeground = false), + ) + } + + @Test + fun `paused media uses an ordinary service start only while app is foregrounded`() { + val paused = RemoteServiceState(hasMedia = true, needsForegroundStart = false) + + assertEquals( + RemoteMediaServiceAction.Start, + resolveRemoteMediaServiceAction(paused, appForeground = true), + ) + assertEquals( + RemoteMediaServiceAction.None, + resolveRemoteMediaServiceAction(paused, appForeground = false), + ) + } + + @Test + fun `cleared media stops the service even while app is backgrounded`() { + val empty = RemoteServiceState(hasMedia = false, needsForegroundStart = false) + + assertEquals( + RemoteMediaServiceAction.Stop, + resolveRemoteMediaServiceAction(empty, appForeground = false), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt index 607bf7204..b064c10dd 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/pip/MobilePictureInPictureSourceTest.kt @@ -46,6 +46,12 @@ class MobilePictureInPictureSourceTest { @Test fun mobileSettingsExposePipToggle() { - assertTrue(settings.contains("Picture-in-Picture")) + // Pin the binding, not just the label: the label is copy and moves + // with the settings voice (it was "Picture-in-Picture" before the + // sentence-case pass), while a switch row bound to the PiP preference + // is what actually makes the toggle reachable. + assertTrue(settings.contains("checked = pictureInPictureEnabled")) + assertTrue(settings.contains("onCheckedChange = onPictureInPictureEnabledChanged")) + assertTrue(settings.contains("Picture-in-picture")) } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationAttributionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationAttributionTest.kt new file mode 100644 index 000000000..8ae9fc342 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationAttributionTest.kt @@ -0,0 +1,120 @@ +package org.prairieserver.prairie.android.push + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A notification must be attributed to its issuer completely or not at all — a + * missing component is a wildcard at delivery, so a half-attributed + * notification can act under an identity that never generated it. + */ +class PushNotificationAttributionTest { + + @Test + fun `a stable identity with a fetched row attributes`() { + assertEquals( + PushNotificationAttribution(serverId = "server-a", profileId = "kids"), + pushNotificationAttribution( + rowProfileId = "kids", + serverIdBefore = "server-a", + identityGenerationBefore = 7L, + serverIdAfter = "server-a", + identityGenerationAfter = 7L, + ), + ) + } + + /** + * The original misattribution: a push issued by A arriving while B is active + * misses its lookup, and falling back to the active profile stamped it as + * B's and navigated into B's library. + */ + @Test + fun `a lookup miss does not fall back to the active profile`() { + assertNull( + pushNotificationAttribution( + rowProfileId = null, + serverIdBefore = "server-b", + identityGenerationBefore = 7L, + serverIdAfter = "server-b", + identityGenerationAfter = 7L, + ), + ) + } + + @Test + fun `a server switch during the fetch abandons attribution`() { + assertNull( + pushNotificationAttribution( + rowProfileId = "kids", + serverIdBefore = "server-a", + identityGenerationBefore = 7L, + serverIdAfter = "server-b", + identityGenerationAfter = 8L, + ), + ) + } + + /** + * A→B→A leaves the server id looking untouched, which is why generations + * are compared rather than ids alone. + */ + @Test + fun `an A to B to A round trip is detected`() { + assertNull( + pushNotificationAttribution( + rowProfileId = "kids", + serverIdBefore = "server-a", + identityGenerationBefore = 7L, + serverIdAfter = "server-a", + identityGenerationAfter = 9L, + ), + ) + } + + /** + * Persistent credential writes move `credentialEpoch` without changing who + * the user is. This pins that the predicate ignores the epoch entirely — + * only the server and the identity generation decide. + */ + @Test + fun `credential churn does not abandon attribution`() { + assertEquals( + PushNotificationAttribution(serverId = "server-a", profileId = "kids"), + pushNotificationAttribution( + rowProfileId = "kids", + serverIdBefore = "server-a", + identityGenerationBefore = 7L, + serverIdAfter = "server-a", + identityGenerationAfter = 7L, + ), + ) + } + + @Test + fun `no identity at all does not attribute`() { + assertNull( + pushNotificationAttribution( + rowProfileId = "kids", + serverIdBefore = null, + identityGenerationBefore = null, + serverIdAfter = null, + identityGenerationAfter = null, + ), + ) + } + + @Test + fun `a blank profile is not an identity`() { + assertNull( + pushNotificationAttribution( + rowProfileId = " ", + serverIdBefore = "server-a", + identityGenerationBefore = 7L, + serverIdAfter = "server-a", + identityGenerationAfter = 7L, + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationDeliveryIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationDeliveryIntegrationTest.kt new file mode 100644 index 000000000..46d5ccee6 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/push/PushNotificationDeliveryIntegrationTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.android.push + +import org.prairieserver.prairie.android.ui.navigation.ExternalRouteScope +import org.prairieserver.prairie.android.ui.navigation.notificationExternalRouteOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class PushNotificationDeliveryIntegrationTest { + @Test + fun `posted notification retains its identity through route delivery`() { + // Model the extras contract shared by PushNotificationPresenter and + // MainActivity without starting Android, the Application, or its Koin + // graph. + val postedExtras = mapOf( + PushNotificationPresenter.EXTRA_NAV_ROUTE to "item/episode-1", + PushNotificationPresenter.EXTRA_SERVER_ID to "server-a", + PushNotificationPresenter.EXTRA_PROFILE_ID to "kids", + ) + + val (route, scope) = requireNotNull( + notificationExternalRouteOrNull( + route = postedExtras[PushNotificationPresenter.EXTRA_NAV_ROUTE], + serverId = postedExtras[PushNotificationPresenter.EXTRA_SERVER_ID], + profileId = postedExtras[PushNotificationPresenter.EXTRA_PROFILE_ID], + ), + ) + + assertEquals("item/episode-1", route) + assertEquals( + ExternalRouteScope.Identity( + serverId = "server-a", + profileId = "kids", + // Process-local generations cannot be persisted in PendingIntents. + identityGeneration = null, + ), + scope, + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActionsTest.kt new file mode 100644 index 000000000..5d22c7eeb --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/components/PrairieKeyboardActionsTest.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.ui.text.input.ImeAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +class PrairieKeyboardActionsTest { + + /** Records whether a field fell through to Compose's default handling. */ + private class RecordingScope : KeyboardActionScope { + val defaulted = mutableListOf() + override fun defaultKeyboardAction(imeAction: ImeAction) { + defaulted += imeAction + } + } + + @Test + fun `a field with no callback installs no handler, leaving Compose's defaults`() { + // The bug: an unconditional onAny took ownership of the event and ran a + // no-op, so Next did nothing at all on every multi-field phone form. + // Leaving the slots null is what hands the action back to Compose's + // KeyboardActionRunner, which moves focus for Next. That traversal is + // Compose's behavior, not this helper's, so it is not asserted here. + val actions = siloKeyboardActions(onImeAction = null) + + assertSame(KeyboardActions.Default, actions) + assertNull(actions.onNext) + assertNull(actions.onDone) + assertNull(actions.onGo) + } + + @Test + fun `a supplied callback is invoked exactly once`() { + var invocations = 0 + val actions = siloKeyboardActions(onImeAction = { invocations++ }) + + val onNext = assertNotNull(actions.onNext) + val scope = RecordingScope() + scope.onNext() + + assertEquals(1, invocations) + // An explicit callback owns the event; it must not also fall through. + assertEquals(emptyList(), scope.defaulted) + } + + @Test + fun `the callback owns every action the field can raise`() { + // Go, Done and Search all route to the same supplied submit callback, + // which is what the auth screens rely on for their final field. + var invocations = 0 + val actions = siloKeyboardActions(onImeAction = { invocations++ }) + val scope = RecordingScope() + + // onAny fans out to every per-action slot, so no action can fall + // through to a default the caller did not ask for. + assertNotNull(actions.onGo).invoke(scope) + assertNotNull(actions.onDone).invoke(scope) + assertNotNull(actions.onSearch).invoke(scope) + assertNotNull(actions.onSend).invoke(scope) + assertNotNull(actions.onNext).invoke(scope) + assertNotNull(actions.onPrevious).invoke(scope) + + assertEquals(6, invocations) + assertEquals(emptyList(), scope.defaulted) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkEncodingTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkEncodingTest.kt new file mode 100644 index 000000000..59f476394 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ContentDeepLinkEncodingTest.kt @@ -0,0 +1,77 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * `URI.path` is already percent-decoded. The parser used to take that decoded + * value and interpolate it straight back into a route string, so the decoded + * bytes were re-read as route syntax: an id carrying an encoded `?` became a + * query argument, and one carrying an encoded `/` truncated. + * + * Plain JVM: both sides use java.net encoding precisely so routes stay + * testable without Robolectric — `android.net.Uri` is stubbed here and would + * silently yield "item/null". + */ +class ContentDeepLinkEncodingTest { + + @Test + fun `an id containing an encoded separator cannot inject a route argument`() { + val route = contentDeepLinkRouteOrNull("prairie://item/abc%3FseasonNumber%3D9") + + assertEquals( + Route.ItemDetail("abc?seasonNumber=9").route, + route, + "the whole decoded id must stay one path segment", + ) + // The literal injection the old code produced. + assertNotEquals("item/abc?seasonNumber=9", route) + } + + @Test + fun `an ordinary id is unchanged through the round trip`() { + assertEquals( + Route.ItemDetail("tt0111161").route, + contentDeepLinkRouteOrNull("prairie://item/tt0111161"), + ) + } + + @Test + fun `a plus in an id survives as a plus`() { + // URLDecoder would turn this into a space; a path segment must not. + val route = contentDeepLinkRouteOrNull("prairie://item/a%2Bb") + assertEquals(Route.ItemDetail("a+b").route, route) + } + + @Test + fun `a play link round trips through the player route`() { + assertEquals( + Route.Player(contentId = "abc/def").route, + contentDeepLinkRouteOrNull("prairie://play/abc%2Fdef"), + ) + } + + @Test + fun `a non silo scheme is not a deep link`() { + assertNull(contentDeepLinkRouteOrNull("https://example.com/item/abc")) + } + + /** + * The player route is parsed back to compare against the live player. An + * encoded id would never equal the decoded target, so an already-showing + * player would look like a new request and restart. + */ + @Test + fun `the player route parses back to the original id`() { + val route = Route.Player(contentId = "abc?x=1").route + + assertEquals("abc?x=1", playerRouteIntentOrNull(route)?.contentId) + } + + private fun assertNotEquals(unexpected: String, actual: String?) { + if (unexpected == actual) { + throw AssertionError("expected not to equal <$unexpected>") + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt index 235e9c382..fae85102f 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginRouteParserTest.kt @@ -1,13 +1,15 @@ package org.prairieserver.prairie.android.ui.navigation import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.assertFalse import kotlin.test.assertEquals import kotlin.test.assertNull class DeviceLoginRouteParserTest { @Test - fun customPrairieTokenUrlRoutesToPairDevice() { + fun customSiloTokenUrlRoutesToPairDevice() { assertEquals( "pair_device?token=t1", deviceLoginPairRouteOrNull("prairie://device?token=t1"), @@ -15,7 +17,7 @@ class DeviceLoginRouteParserTest { } @Test - fun customPrairieCodeUrlRoutesToPairDevice() { + fun customSiloCodeUrlRoutesToPairDevice() { assertEquals( "pair_device?code=ABCD-1234", deviceLoginPairRouteOrNull("prairie://device?code=ABCD-1234"), @@ -24,17 +26,19 @@ class DeviceLoginRouteParserTest { @Test fun serverHttpsDeviceTokenUrlRoutesToPairDevice() { + // The issuing origin rides along so the pairing screen can refuse a + // code that belongs to a server the user is not currently on. assertEquals( - "pair_device?token=t1", - deviceLoginPairRouteOrNull("https://prairie.example/device?token=t1"), + "pair_device?token=t1&serverOrigin=https%3A%2F%2Fsilo.example", + deviceLoginPairRouteOrNull("https://silo.example/device?token=t1"), ) } @Test fun serverHttpsAuthDeviceCodeUrlRoutesToPairDevice() { assertEquals( - "pair_device?code=ABCD", - deviceLoginPairRouteOrNull("https://prairie.example/auth/device?code=ABCD"), + "pair_device?code=ABCD&serverOrigin=https%3A%2F%2Fsilo.example", + deviceLoginPairRouteOrNull("https://silo.example/auth/device?code=ABCD"), ) } @@ -48,7 +52,7 @@ class DeviceLoginRouteParserTest { @Test fun unrelatedUrlReturnsNull() { - assertNull(deviceLoginPairRouteOrNull("https://prairie.example/item/abc")) + assertNull(deviceLoginPairRouteOrNull("https://silo.example/item/abc")) } @Test @@ -57,4 +61,43 @@ class DeviceLoginRouteParserTest { assertNull(deviceLoginPairRouteOrNull("")) assertNull(deviceLoginPairRouteOrNull("prairie://device?token=&code=")) } + + // --- origin --- + + @Test + fun `an app scheme device link names no server`() { + assertEquals(DeviceLoginScope.Unscoped, deviceLoginScope("prairie://device?code=ABCD")) + // No origin in the route either. + assertEquals("pair_device?code=ABCD", deviceLoginPairRouteOrNull("prairie://device?code=ABCD")) + } + + @Test + fun `an https device link carries its issuing origin`() { + assertEquals( + DeviceLoginScope.Origin("https://server-b.example"), + deviceLoginScope("https://server-b.example/device?code=ABCD"), + ) + } + + /** + * A device-SHAPED link whose origin cannot be read must not parse at all. + * It used to produce a route with no origin, which downstream reads as + * "names no server" and pairs against whichever server is active — the + * exact bypass the origin check exists to stop. + */ + @Test + fun `an https device link with no host does not parse`() { + assertEquals(DeviceLoginScope.Invalid, deviceLoginScope("https:///device?code=ABCD")) + assertNull(deviceLoginPairRouteOrNull("https:///device?code=ABCD")) + } + + /** Port 0 is not a valid origin, so the link is not usable either. */ + @Test + fun `a device link with an invalid port does not parse`() { + assertEquals( + DeviceLoginScope.Invalid, + deviceLoginScope("https://silo.example:0/device?code=A"), + ) + assertNull(deviceLoginPairRouteOrNull("https://silo.example:0/device?code=A")) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatchTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatchTest.kt new file mode 100644 index 000000000..6643329e9 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/DeviceLoginServerMatchTest.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.android.ui.navigation + +import org.prairieserver.prairie.model.server.ServerEntry +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A pairing code is only meaningful on the server that issued it. Looking it up + * against whichever server happens to be active reports a valid request as + * invalid; refusing it silently is just as bad a dead end. These pin the third + * option — deliver it, and say which server it belongs to. + */ +class DeviceLoginServerMatchTest { + + private val serverA = ServerEntry(id = "a", url = "https://a.example", fetchedName = "Server A") + private val serverB = ServerEntry(id = "b", url = "https://b.example", fetchedName = "Server B") + private val entries = listOf(serverA, serverB) + + @Test + fun `a link for the active server proceeds`() { + assertEquals( + DeviceLoginServerMatch.Active, + deviceLoginServerMatch( + requiredOrigin = "https://a.example", + activeServerUrl = "https://a.example", + entries = entries, + ), + ) + } + + @Test + fun `a link for another configured server offers the switch`() { + assertEquals( + DeviceLoginServerMatch.SwitchRequired(serverB), + deviceLoginServerMatch( + requiredOrigin = "https://b.example", + activeServerUrl = "https://a.example", + entries = entries, + ), + ) + } + + @Test + fun `a link for a server the user does not have says so`() { + assertEquals( + DeviceLoginServerMatch.UnknownServer("https://c.example"), + deviceLoginServerMatch( + requiredOrigin = "https://c.example", + activeServerUrl = "https://a.example", + entries = entries, + ), + ) + } + + /** An app-scheme link names no server, so it is about the active one. */ + @Test + fun `a link naming no server proceeds`() { + assertEquals( + DeviceLoginServerMatch.Active, + deviceLoginServerMatch( + requiredOrigin = null, + activeServerUrl = "https://a.example", + entries = entries, + ), + ) + } + + /** Nothing to contradict yet — the user is on their way to adding one. */ + @Test + fun `a link proceeds when no server is configured`() { + assertEquals( + DeviceLoginServerMatch.Active, + deviceLoginServerMatch( + requiredOrigin = "https://b.example", + activeServerUrl = null, + entries = emptyList(), + ), + ) + } + + /** Default ports must not make the same server look like a different one. */ + @Test + fun `an explicit default port still matches`() { + assertEquals( + DeviceLoginServerMatch.Active, + deviceLoginServerMatch( + requiredOrigin = "https://a.example:443", + activeServerUrl = "https://a.example", + entries = entries, + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt new file mode 100644 index 000000000..c110ae784 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/ExternalRouteNavigationTest.kt @@ -0,0 +1,522 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteIntent +import org.prairieserver.prairie.android.ui.screens.player.MobilePlayerRouteTarget +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ExternalRouteNavigationTest { + @Test + fun contentRouteWaitsForAuthenticationAndIsDeliveredOnce() = runTest { + val events = mutableListOf() + val destinations = flow { + emit(Route.Login.route) + emit(Route.ProfileSelection.route) + emit(Route.Home.route) + error("external-route collector remained active after the eligible destination") + } + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest( + generation = 1, + route = "player/movie-tmdb-463015", + ), + currentDestinationRoutes = destinations, + navigate = { route -> events += "navigate:$route" }, + onConsumed = { request -> events += "consumed:${request.generation}" }, + ) + + assertEquals( + listOf("navigate:player/movie-tmdb-463015", "consumed:1"), + events, + ) + } + + @Test + fun inviteClaimCanNavigateFromTheSignedOutGraph() = runTest { + val navigated = mutableListOf() + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest( + generation = 1, + route = "invite_claim?server=example&token=test-token", + ), + currentDestinationRoutes = flowOf(Route.Login.route), + navigate = navigated::add, + onConsumed = {}, + ) + + assertEquals(listOf("invite_claim?server=example&token=test-token"), navigated) + } + + @Test + fun blankRouteDoesNotSubscribeOrNavigate() = runTest { + var navigations = 0 + + consumeExternalRouteOnce( + pendingExternalRoute = ExternalRouteRequest(generation = 1, route = " "), + currentDestinationRoutes = flow { error("blank route must not collect destinations") }, + navigate = { navigations++ }, + onConsumed = { error("blank route must not be consumed") }, + ) + + assertEquals(0, navigations) + } + + @Test + fun repeatedIdenticalRoutesReceiveDistinctMonotonicIdentities() { + val requests = ExternalRouteRequestFactory() + + val first = requests.create("player/movie-tmdb-463015") + val second = requests.create("player/movie-tmdb-463015") + + assertEquals(first.route, second.route) + assertNotEquals(first.generation, second.generation) + assertEquals(first.generation + 1, second.generation) + } + + @Test + fun playerTargetProviderIsInvokedOnlyForItsOwningBackStackEntry() { + var providerCalls = 0 + val registration = PlayerTargetProviderRegistration( + backStackEntryId = "player-a", + target = { + providerCalls += 1 + null + }, + ) + + assertNull( + currentPlayerTargetOrNull( + currentBackStackEntryId = "player-b", + registration = registration, + ), + ) + assertEquals(0, providerCalls) + + assertNull( + currentPlayerTargetOrNull( + currentBackStackEntryId = "player-a", + registration = registration, + ), + ) + assertEquals(1, providerCalls) + } + + @Test + fun staleConsumptionDoesNotClearANewerRequest() { + val requests = ExternalRouteRequestFactory() + val first = requests.create("player/movie-tmdb-463015") + val second = requests.create("player/movie-tmdb-463015") + + assertEquals( + second, + clearConsumedExternalRouteRequest( + pendingRequest = second, + consumedRequest = first, + ), + ) + assertNull( + clearConsumedExternalRouteRequest( + pendingRequest = second, + consumedRequest = second, + ), + ) + } + + @Test + fun exactCurrentTargetIsConsumedWithoutRenavigating() = runTest { + val request = ExternalRouteRequest( + generation = 4, + route = "player/movie-tmdb-463015", + ) + val events = mutableListOf() + + consumeExternalRouteOnce( + pendingExternalRoute = request, + currentDestinationRoutes = flowOf(Route.Player.ROUTE), + isAlreadyAtRoute = { route -> route == request.route }, + navigate = { route -> events += "navigate:$route" }, + onConsumed = { consumed -> events += "consumed:${consumed.generation}" }, + ) + + assertEquals(listOf("consumed:4"), events) + } + + @Test + fun differentPlayerTargetReplacesTheCurrentPlayerEntry() { + assertEquals( + true, + shouldReplaceCurrentPlayer( + currentDestinationRoute = Route.Player.ROUTE, + targetRoute = "player/movie-tmdb-463015?quality=original", + ), + ) + assertEquals( + false, + shouldReplaceCurrentPlayer( + currentDestinationRoute = Route.Home.route, + targetRoute = "player/movie-tmdb-463015?quality=original", + ), + ) + } + + @Test + fun externalTabOnlyPopsAPlayerThatIsOnTop() { + val playerAbsent = listOf(Route.Home.route, Route.ItemDetail.ROUTE) + val playerOnTop = listOf(Route.Home.route, Route.Player.ROUTE) + val playerBelowAnotherEntry = listOf(Route.Home.route, Route.Player.ROUTE, Route.ItemDetail.ROUTE) + + assertFalse(shouldPopPlayerBeforeExternalTab(playerAbsent.lastOrNull())) + assertTrue(shouldPopPlayerBeforeExternalTab(playerOnTop.lastOrNull())) + // A player below an item entry is deliberately left in history: an + // inclusive pop to the player would also discard the item above it. + assertFalse(shouldPopPlayerBeforeExternalTab(playerBelowAnotherEntry.lastOrNull())) + } + + @Test + fun externalSingleTopPolicyCoversEveryProducedRoute() { + val cases = listOf( + // Notification producers. + Triple(Route.Inbox.route, Route.Inbox.route, true), + Triple(Route.Home.route, Route.Inbox.route, false), + Triple(Route.ItemDetail.ROUTE, "item/movie-1", true), + Triple(Route.ItemDetail.ROUTE, "item/movie-2", false), + // Content-link producers. Downloads takes the separate tab path; + // item and player still exercise this policy. + Triple(Route.Home.route, "item/movie-1", false), + Triple(Route.Home.route, "player/movie-1", false), + Triple(Route.Player.ROUTE, "player/movie-1?quality=original", true), + // Device-login and invitation producers. Distinct argument sets + // must get distinct entries even though they share a graph node. + Triple(Route.PairDevice.ROUTE, "pair_device?code=123&serverOrigin=https%3A%2F%2Fa", false), + Triple(Route.InviteClaim.ROUTE, "invite_claim?server=https%3A%2F%2Fa&token=one", false), + ) + + cases.forEach { (currentRoute, targetRoute, expected) -> + assertEquals( + expected, + shouldLaunchExternalRouteSingleTop( + currentDestinationRoute = currentRoute, + currentContentId = if (currentRoute == Route.ItemDetail.ROUTE) "movie-1" else null, + targetRoute = targetRoute, + ), + "$currentRoute -> $targetRoute", + ) + } + } + + @Test + fun canonicalPlayerTargetParsesEveryPlaybackChoice() { + assertEquals( + MobilePlayerRouteIntent( + contentId = "movie-1", + fileId = 121, + quality = "original", + audioTrackIndex = 2, + subtitleTrackIndex = -1, + resumePositionSeconds = 42.0, + ), + playerRouteIntentOrNull( + "player/movie-1?fileId=121&quality=original&audioTrackIndex=2&subtitleTrackIndex=-1&resumePosition=42.0", + ), + ) + assertNull(playerRouteIntentOrNull("item/movie-1")) + assertNull(playerRouteIntentOrNull("player/movie-1?fileId=invalid")) + assertNull(playerRouteIntentOrNull("player/movie-1?resumePosition=invalid")) + assertNull(playerRouteIntentOrNull("player/movie-1?roomId=room-1")) + } + + @Test + fun bareRouteRemainsExactAfterItsInitialAutomaticResolution() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1") + val current = target( + intent = intent, + contentId = "movie-1", + fileId = 121, + quality = "auto", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + + assertTrue(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(current)) + } + + @Test + fun bareRouteStopsMatchingAfterAnExplicitVersionSwitch() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1", fileId = 222) + val current = target(intent = intent, contentId = "movie-1", fileId = 222) + + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(current)) + assertTrue(requireNotNull(playerRouteIntentOrNull("player/movie-1?fileId=222")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1?fileId=121")).matches(current)) + } + + @Test + fun liveContentQualityAndTracksMustMatchExplicitRequest() { + val intent = MobilePlayerRouteIntent( + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + val current = target( + intent = intent, + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + ) + + assertTrue( + requireNotNull( + playerRouteIntentOrNull( + "player/episode-2?fileId=222&quality=720p&audioTrackIndex=1&subtitleTrackIndex=-1", + ), + ).matches(current), + ) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-1?fileId=222")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?quality=1080p")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?audioTrackIndex=0")).matches(current)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/episode-2?subtitleTrackIndex=0")).matches(current)) + } + + @Test + fun resumePositionIsExactIntentRatherThanTheAdvancingPlaybackClock() { + val resumed = target( + intent = MobilePlayerRouteIntent( + contentId = "movie-1", + resumePositionSeconds = 42.0, + ), + contentId = "movie-1", + resumePositionSeconds = 42.0, + ) + val fromStart = target( + intent = MobilePlayerRouteIntent( + contentId = "movie-1", + resumePositionSeconds = 0.0, + ), + contentId = "movie-1", + resumePositionSeconds = 0.0, + ) + + assertTrue( + requireNotNull(playerRouteIntentOrNull("player/movie-1?resumePosition=42.0")) + .matches(resumed), + ) + assertFalse( + requireNotNull(playerRouteIntentOrNull("player/movie-1?resumePosition=43.0")) + .matches(resumed), + ) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(resumed)) + assertFalse(requireNotNull(playerRouteIntentOrNull("player/movie-1")).matches(fromStart)) + } + + private fun target( + intent: MobilePlayerRouteIntent, + contentId: String, + fileId: Int? = null, + quality: String? = null, + audioTrackIndex: Int? = null, + subtitleTrackIndex: Int? = null, + resumePositionSeconds: Double? = null, + ) = MobilePlayerRouteTarget( + intent = intent, + contentId = contentId, + fileId = fileId, + quality = quality, + audioTrackIndex = audioTrackIndex, + subtitleTrackIndex = subtitleTrackIndex, + resumePositionSeconds = resumePositionSeconds, + ) + + /** + * A notification PendingIntent can be tapped days after it was posted, and + * several profile switches later. Its route means something different — or + * nothing — under another identity, so the scope is re-checked at delivery + * and a mismatch must not navigate. + */ + @Test + fun aRequestWhoseServerNoLongerMatchesIsNotDelivered() = runTest { + val request = ExternalRouteRequestFactory() + .create( + route = "inbox", + scope = ExternalRouteScope.Identity(serverId = "server-b", profileId = "kids"), + ) + var navigated: String? = null + var consumed = 0 + + consumeExternalRouteOnce( + pendingExternalRoute = request, + currentDestinationRoutes = flowOf("home"), + isStillValidForScope = { false }, + navigate = { navigated = it }, + onConsumed = { consumed++ }, + ) + + assertNull(navigated, "a notification must never act on a different profile's session") + // Still consumed: leaving it queued would only let it fire later, at an + // equally wrong moment. + assertEquals(1, consumed) + } + + @Test + fun aRequestWhoseServerStillMatchesIsDelivered() = runTest { + val request = ExternalRouteRequestFactory() + .create( + route = "inbox", + scope = ExternalRouteScope.Identity(serverId = "server-b", profileId = "kids"), + ) + var navigated: String? = null + var consumed = 0 + + consumeExternalRouteOnce( + pendingExternalRoute = request, + currentDestinationRoutes = flowOf("home"), + isStillValidForScope = { true }, + navigate = { navigated = it }, + onConsumed = { consumed++ }, + ) + + assertEquals("inbox", navigated) + assertEquals(1, consumed) + } + + /** An unscoped request must not be gated on any server. */ + @Test + fun anUnscopedRequestIgnoresTheServerCheck() = runTest { + val request = ExternalRouteRequestFactory().create(route = "item/abc") + var navigated: String? = null + + consumeExternalRouteOnce( + pendingExternalRoute = request, + currentDestinationRoutes = flowOf("home"), + isStillValidForScope = { scope -> + assertEquals(ExternalRouteScope.Unscoped, scope) + true + }, + navigate = { navigated = it }, + onConsumed = { }, + ) + + assertEquals("item/abc", navigated) + } + + // --- identity scope matching --- + + /** + * A link that arrived with a server but no profile — configured server, + * nobody signed in — must still deliver once a profile IS chosen. Requiring + * the profile to still be null dropped exactly the link the user was + * signing in to open. + */ + @Test + fun aScopeCapturedBeforeSignInStillMatchesAfterIt() { + val scope = ExternalRouteScope.Identity(serverId = "server-a", profileId = null) + + assertTrue(scope.matches(serverId = "server-a", profileId = "profile-1", identityGeneration = null)) + assertTrue(scope.matches(serverId = "server-a", profileId = null, identityGeneration = null)) + } + + @Test + fun aScopeDoesNotMatchAnotherServer() { + val scope = ExternalRouteScope.Identity(serverId = "server-a", profileId = null) + + assertFalse(scope.matches(serverId = "server-b", profileId = "profile-1", identityGeneration = null)) + } + + /** A fully-specified notification scope must match both components. */ + @Test + fun aFullyPinnedScopeRequiresBothComponents() { + val scope = ExternalRouteScope.Identity(serverId = "server-a", profileId = "kids") + + assertTrue(scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = null)) + assertFalse(scope.matches(serverId = "server-a", profileId = "adults", identityGeneration = null)) + assertFalse(scope.matches(serverId = "server-b", profileId = "kids", identityGeneration = null)) + } + + /** Nothing known constrains nothing — the signed-out arrival case. */ + @Test + fun anEmptyScopeMatchesAnything() { + val scope = ExternalRouteScope.Identity(serverId = null, profileId = null) + + assertTrue(scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = null)) + } + /** + * Signing out and back into the SAME account is a new session, and a route + * authored for the old one must not act on it. Ids alone cannot see that; + * only the generation can. + */ + @Test + fun aScopePinnedToAGenerationDoesNotMatchALaterSession() { + val scope = ExternalRouteScope.Identity( + serverId = "server-a", + profileId = "kids", + identityGeneration = 7L, + ) + + assertTrue( + scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = 7L), + ) + assertFalse( + scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = 8L), + ) + } + + /** An unknown generation constrains nothing, exactly like an unknown id. */ + @Test + fun aScopeWithNoGenerationIgnoresIt() { + val scope = ExternalRouteScope.Identity(serverId = "server-a", profileId = "kids") + + assertTrue( + scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = 99L), + ) + } + /** + * The defect this branch exists to fix, on the external-link path: a + * notification for item B while item A's detail is showing must not reuse + * A's entry, or Back skips A. + */ + @Test + fun anExternalItemLinkIsSingleTopOnlyForTheSameItem() { + assertTrue( + isSameItemDetail( + currentDestinationRoute = Route.ItemDetail.ROUTE, + currentContentId = "movie-1", + targetRoute = "item/movie-1", + ), + ) + assertFalse( + isSameItemDetail( + currentDestinationRoute = Route.ItemDetail.ROUTE, + currentContentId = "movie-1", + targetRoute = "item/movie-2", + ), + ) + // Encoded ids must still compare equal to the decoded entry argument. + assertTrue( + isSameItemDetail( + currentDestinationRoute = Route.ItemDetail.ROUTE, + currentContentId = "tt 1/2", + targetRoute = "item/tt%201%2F2", + ), + ) + // Not on a detail screen at all. + assertFalse( + isSameItemDetail( + currentDestinationRoute = Route.Player.ROUTE, + currentContentId = "movie-1", + targetRoute = "item/movie-1", + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRouteTest.kt new file mode 100644 index 000000000..07226caa5 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/NotificationExternalRouteTest.kt @@ -0,0 +1,71 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A notification route is only honoured if it says whose it is. + * + * Missing extras used to produce `Identity(null, null)`, which matches every + * identity — so the route ran against whoever happened to be signed in. Two + * ways that arrives: a notification posted by a build from before the extras + * existed, and an explicit Intent crafted against the exported Activity. + */ +class NotificationExternalRouteTest { + + @Test + fun `a fully attributed notification is accepted`() { + assertEquals( + "item/abc" to ExternalRouteScope.Identity(serverId = "server-a", profileId = "kids"), + notificationExternalRouteOrNull( + route = "item/abc", + serverId = "server-a", + profileId = "kids", + ), + ) + } + + @Test + fun `a notification with no identity is rejected`() { + assertNull( + notificationExternalRouteOrNull(route = "item/abc", serverId = null, profileId = null), + ) + } + + /** Half an identity is worse than none — the missing half is a wildcard. */ + @Test + fun `a half attributed notification is rejected`() { + assertNull( + notificationExternalRouteOrNull( + route = "item/abc", + serverId = "server-a", + profileId = null, + ), + ) + assertNull( + notificationExternalRouteOrNull( + route = "item/abc", + serverId = null, + profileId = "kids", + ), + ) + } + + @Test + fun `blank is not an identity`() { + assertNull( + notificationExternalRouteOrNull(route = "item/abc", serverId = " ", profileId = "kids"), + ) + assertNull( + notificationExternalRouteOrNull(route = " ", serverId = "server-a", profileId = "kids"), + ) + } + + @Test + fun `no route is nothing to deliver`() { + assertNull( + notificationExternalRouteOrNull(route = null, serverId = "server-a", profileId = "kids"), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt index 2f4ee75a6..9612c1271 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/performance/MobilePlayerLifecyclePerformanceSourceTest.kt @@ -26,7 +26,16 @@ class MobilePlayerLifecyclePerformanceSourceTest { assertTrue(viewModel.contains("val scope = finalPositionScope")) assertTrue(viewModel.contains("scope = scope,")) assertTrue(viewModel.contains("finalPlaybackPositionWriter.submit(")) - assertTrue(viewModel.contains("sessionLifecycle.stopAsync()")) + // Still the non-blocking teardown this test exists to protect, now + // qualified by the session this view model owned — phone navigation + // replaces the player entry, so an unqualified stop could kill the + // session a newer screen had already adopted. It goes through the + // one-shot gate as well: the ordered stop and this one target the same + // session, and the second to run would otherwise bump the lifecycle's + // stop epoch and supersede whichever screen started next. + assertTrue(viewModel.contains("lifecycleTeardown.stopDetached(expectedSessionId =")) + assertTrue(viewModel.contains("lifecycleTeardown.stopOrdered(expectedSessionId =")) + assertTrue(viewModel.contains("PlaybackTeardownGate(sessionLifecycle)")) assertTrue(!viewModel.contains("runBlocking(")) assertTrue(!screen.contains("onDispose { viewModel.onExit() }")) assertTrue(screen.contains("viewModel.claimInitialRouteLoad()")) diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModelTest.kt deleted file mode 100644 index 7964bd9d3..000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminEntryViewModelTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import org.prairieserver.prairie.model.auth.User -import org.prairieserver.prairie.model.profile.Profile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * The admin stats dashboard is visible to acting admins (Apple parity); - * everything still folds through the gateProvider seam. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class AdminEntryViewModelTest { - - private val dispatcher = UnconfinedTestDispatcher() - - @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @AfterTest fun tearDown() { Dispatchers.resetMain() } - - private fun user(role: String) = User(id = 1, username = "u", email = "e@x.io", role = role) - private fun profile(primary: Boolean) = - Profile(id = "p1", name = "Primary", isPrimary = primary) - - private fun vm(@Suppress("UNUSED_PARAMETER") user: User?, @Suppress("UNUSED_PARAMETER") profile: Profile?) = - AdminEntryViewModel(gateProvider = { true }) - - @Test fun `acting admin gate makes the surface visible`() = runTest(dispatcher) { - assertTrue(AdminEntryViewModel(gateProvider = { true }).uiState.value.isAdminVisible) - } - - @Test fun `non-admin gate keeps the surface hidden`() = runTest(dispatcher) { - assertFalse(AdminEntryViewModel(gateProvider = { false }).uiState.value.isAdminVisible) - } - - @Test fun `not loading after refresh`() = runTest(dispatcher) { - assertFalse(vm(user("admin"), profile(true)).uiState.value.isLoading) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQueryTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQueryTest.kt deleted file mode 100644 index 302e21639..000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminLogQueryTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class AdminLogQueryTest { - - @Test fun blankFieldsAreOmitted() { - val q = buildLogQuery(level = null, query = " ", component = "", limit = 100) - assertFalse(q.containsKey("level")); assertFalse(q.containsKey("q")) - assertFalse(q.containsKey("component")); assertEquals("100", q["limit"]) - } - - @Test fun setFieldsAreTrimmedAndIncluded() { - val q = buildLogQuery(level = "error", query = " timeout ", component = "scanner", limit = 50) - assertEquals("error", q["level"]); assertEquals("timeout", q["q"]) - assertEquals("scanner", q["component"]); assertEquals("50", q["limit"]) - } - - @Test fun limitIsClampedToServerMax() { - assertEquals("200", buildLogQuery(null, null, null, 500)["limit"]) - assertEquals("1", buildLogQuery(null, null, null, 0)["limit"]) - } - - @Test fun allLevelSentinelIsTreatedAsNoFilter() { - assertFalse(buildLogQuery(level = "All", query = null, component = null, limit = 100).containsKey("level")) - } - - @Test fun auditRowDetailLineCombinesMethodPathStatus() { - assertEquals("GET /api/v1/admin/stats → 200", auditSummaryLine("get", "/api/v1/admin/stats", 200)) - } - - @Test fun appLevelSeverityOrderingForBadgeColorSelection() { - assertTrue(logLevelRank("error") > logLevelRank("warn")) - assertTrue(logLevelRank("warn") > logLevelRank("info")) - assertEquals(0, logLevelRank("trace")) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormattersTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormattersTest.kt deleted file mode 100644 index fef0d3cda..000000000 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/admin/AdminSessionFormattersTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.prairieserver.prairie.android.ui.screens.admin - -import kotlin.test.Test -import kotlin.test.assertEquals - -class AdminSessionFormattersTest { - - @Test - fun directPlayShowsDirectWithBitrateAndResolution() { - val line = sessionSummaryLine( - isTranscoding = false, playMethod = "DirectPlay", - bitrateBps = 12_000_000, widthTarget = 1920, heightTarget = 1080, - videoCodecSource = "h264", videoCodecTarget = "h264", - ) - assertEquals("Direct Play • 12.0 Mbps • 1080p", line) - } - - @Test - fun transcodeShowsCodecArrowAndResolution() { - val line = sessionSummaryLine( - isTranscoding = true, playMethod = "Transcode", - bitrateBps = 4_500_000, widthTarget = 1280, heightTarget = 720, - videoCodecSource = "hevc", videoCodecTarget = "h264", - ) - assertEquals("Transcode hevc→h264 • 4.5 Mbps • 720p", line) - } - - @Test - fun missingBitrateAndResolutionAreOmitted() { - val line = sessionSummaryLine( - isTranscoding = false, playMethod = "DirectStream", - bitrateBps = null, widthTarget = null, heightTarget = null, - videoCodecSource = null, videoCodecTarget = null, - ) - assertEquals("Direct Stream", line) - } - - @Test - fun resolutionBucketsToNearestStandardLabel() { - assertEquals("4K", resolutionLabel(3840, 2160)) - assertEquals("1080p", resolutionLabel(1920, 1080)) - assertEquals("720p", resolutionLabel(1280, 720)) - assertEquals("480p", resolutionLabel(854, 480)) - assertEquals("576p", resolutionLabel(720, 576)) - } - - @Test - fun bitrateRendersMbpsWithOneDecimal() { - assertEquals("4.5 Mbps", bitrateLabel(4_500_000)) - assertEquals("950 Kbps", bitrateLabel(950_000)) - } - - @Test - fun progressLabelIsPositionOfDuration() { - assertEquals("0:30 / 1:00:00", sessionProgressLabel(30.0, 3600.0)) - assertEquals("0:30", sessionProgressLabel(30.0, 0.0)) - } -} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogLetterIndexViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogLetterIndexViewModelTest.kt index 2552982db..27c148702 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogLetterIndexViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogLetterIndexViewModelTest.kt @@ -1,7 +1,6 @@ package org.prairieserver.prairie.android.ui.screens.browse import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesSubtab import org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModel import org.prairieserver.prairie.android.ui.screens.reading.ReadingHubViewModel @@ -21,16 +20,18 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -40,11 +41,11 @@ class CatalogLetterIndexViewModelTest { @Test fun browseLetterSelectionUsesServerNamePrefixAndResetsPagination() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(BrowseViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = BrowseViewModel( catalogRepository = repositories.catalog, savedStateHandle = SavedStateHandle(mapOf("libraryId" to "1")), - )) + ) awaitState { viewModel.uiState.value.items.map { it.contentId } == listOf("all-1") } viewModel.loadMore() @@ -62,11 +63,11 @@ class CatalogLetterIndexViewModelTest { @Test fun browseDensitySelectionUpdatesLayoutWithoutReloadingCatalog() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(BrowseViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = BrowseViewModel( catalogRepository = repositories.catalog, savedStateHandle = SavedStateHandle(mapOf("libraryId" to "1")), - )) + ) awaitState { viewModel.uiState.value.items.map { it.contentId } == listOf("all-1") } val catalogRequestCount = requests.catalogRequestCount() @@ -79,12 +80,12 @@ class CatalogLetterIndexViewModelTest { @Test fun librariesBrowseLetterSelectionUsesServerNamePrefix() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(LibrariesViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = LibrariesViewModel( personalDataRepository = repositories.personal, sectionRepository = repositories.sections, catalogRepository = repositories.catalog, - )) + ) awaitState { !viewModel.uiState.value.isLoadingLibraries } viewModel.selectTab(LibrariesSubtab.Browse) @@ -100,12 +101,12 @@ class CatalogLetterIndexViewModelTest { @Test fun librariesDensitySelectionUpdatesLayoutWithoutReloadingCatalog() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(LibrariesViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = LibrariesViewModel( personalDataRepository = repositories.personal, sectionRepository = repositories.sections, catalogRepository = repositories.catalog, - )) + ) awaitState { !viewModel.uiState.value.isLoadingLibraries } viewModel.selectTab(LibrariesSubtab.Browse) awaitState { viewModel.uiState.value.catalogItems.map { it.contentId } == listOf("all-1") } @@ -120,12 +121,12 @@ class CatalogLetterIndexViewModelTest { @Test fun readingBrowseLetterSelectionUsesServerNamePrefix() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(ReadingHubViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = ReadingHubViewModel( personalDataRepository = repositories.personal, sectionRepository = repositories.sections, catalogRepository = repositories.catalog, - )) + ) awaitState { !viewModel.uiState.value.isLoadingLibraries } viewModel.selectTab(LibrariesSubtab.Browse) @@ -141,12 +142,12 @@ class CatalogLetterIndexViewModelTest { @Test fun readingDensitySelectionUpdatesLayoutWithoutReloadingCatalog() = runCatalogTest { val requests = mutableListOf() - val repositories = repositoriesFor(requests) - val viewModel = track(ReadingHubViewModel( + val repositories = repositoriesFor(requests, StandardTestDispatcher(testScheduler)) + val viewModel = ReadingHubViewModel( personalDataRepository = repositories.personal, sectionRepository = repositories.sections, catalogRepository = repositories.catalog, - )) + ) awaitState { !viewModel.uiState.value.isLoadingLibraries } viewModel.selectTab(LibrariesSubtab.Browse) awaitState { viewModel.uiState.value.catalogItems.map { it.contentId } == listOf("all-1") } @@ -158,34 +159,44 @@ class CatalogLetterIndexViewModelTest { assertEquals(catalogRequestCount, requests.catalogRequestCount()) } - // Cancel viewModelScope coroutines BEFORE resetting Main: a coroutine - // still parked on Dispatchers.Main when a later test calls setMain/resetMain - // throws IllegalStateException from TestMainDispatcher. - private val createdViewModels = mutableListOf() - - private fun track(viewModel: T): T { - createdViewModels += viewModel - return viewModel - } - - private fun runCatalogTest(block: suspend () -> Unit) = runTest { + private fun runCatalogTest(block: suspend TestScope.() -> Unit) = runTest { Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) try { block() } finally { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() Dispatchers.resetMain() } } - private suspend fun awaitState(predicate: () -> Boolean) { - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(5_000) { + /** + * Wait for the view model to reach a state, in REAL time. + * + * Real time is not a shortcut here, it is forced: the Ktor engine backing + * these repositories completes on its own dispatcher, so the work is on + * actual threads and the test scheduler can neither see it nor advance it. + * Draining the scheduler instead — which is what determinism would + * require — returns before any response has arrived. + * + * The flake this replaces was the budget, not the mechanism. Five seconds + * is ample on an idle machine and not always ample when Gradle is running + * several test modules in parallel on the same cores, so the failure was + * load-dependent rather than logical. The budget below is generous because + * the only cost of generosity is how long a genuinely broken test takes to + * report, while the cost of tightness is a red build that means nothing. + * + * Making this properly deterministic needs the engine dispatcher to be + * injectable the way SectionRepository's already is — a production-side + * change, not a test one. + */ + private suspend fun awaitState(description: String = "expected state", predicate: () -> Boolean) { + withContext(Dispatchers.IO) { + val deadline = withTimeoutOrNull(AwaitStateBudgetMillis) { while (!predicate()) { delay(10) } + true } + assertTrue(deadline == true, "view model never reached $description") } } @@ -207,7 +218,16 @@ class CatalogLetterIndexViewModelTest { private fun List.catalogRequestCount(): Int = count { it.path == "/api/v1/catalog" } - private fun repositoriesFor(requests: MutableList): Repositories { + /** + * [homeRequestDispatcher] is the seam that makes this deterministic. + * SectionRepository otherwise fans its library requests out on + * Dispatchers.Default, which is a real thread pool the test scheduler + * cannot see or wait for. + */ + private fun repositoriesFor( + requests: MutableList, + homeRequestDispatcher: CoroutineDispatcher, + ): Repositories { val client = HttpClient( MockEngine { request -> requests += RequestRecord( @@ -231,7 +251,7 @@ class CatalogLetterIndexViewModelTest { } return Repositories( personal = PersonalDataRepository(PersonalDataApi(client)), - sections = SectionRepository(SectionApi(client)), + sections = SectionRepository(SectionApi(client), homeRequestDispatcher = homeRequestDispatcher), catalog = CatalogRepository(CatalogApi(client)), ) } @@ -256,4 +276,12 @@ class CatalogLetterIndexViewModelTest { } """.trimIndent() } + + private companion object { + /** + * Deliberately far beyond what the work needs. It exists to catch a + * hang, not to police latency on a loaded build machine. + */ + const val AwaitStateBudgetMillis = 30_000L + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsSourceTest.kt index 0a40ecc7d..7eaaeede6 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MobileDetailActionsSourceTest.kt @@ -1,27 +1,29 @@ package org.prairieserver.prairie.android.ui.screens.detail import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.downloads.DownloadEnqueuer +import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData +import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.download.DownloadsListResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.network.api.DownloadsApi import org.prairieserver.prairie.network.api.EbookReaderApi import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.RecommendationApi import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.DownloadsRepository import org.prairieserver.prairie.repository.EbookReaderRepository import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.RecommendationRepository import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher @@ -102,6 +104,34 @@ class MobileDetailActionsSourceTest { ) } + @Test + fun cachedSeasonSwitchDoesNotReloadEpisodes() = runItemDetailTest { + val catalogRequests = mutableListOf() + val catalogRepository = CatalogRepository( + CatalogApi( + HttpClient( + MockEngine { request -> + catalogRequests += request.url.encodedPath + respond("{}") + }, + ), + ), + ) + val viewModel = itemDetailViewModel( + personalDataRepository = RecordingPersonalDataRepository(mutableListOf()), + catalogRepository = catalogRepository, + ) + viewModel.seedSeriesDetail() + + viewModel.selectSeason(2) + viewModel.selectSeason(1) + advanceUntilIdle() + + assertEquals(emptyList(), catalogRequests) + assertEquals(1, viewModel.uiState.value.selectedSeasonNumber) + assertEquals(listOf("season-1-episode-1"), viewModel.uiState.value.episodes.map { it.contentId }) + } + @Test fun moviePlayPinsDisplayedVersionWhenTrackOverrideIsSelected() { assertTrue(itemDetail.contains("val playbackFileId = explicitFileId ?: detail.versions")) @@ -112,37 +142,70 @@ class MobileDetailActionsSourceTest { assertTrue(itemDetail.contains("explicitSubtitleIndex,")) } - // Cancel viewModelScope coroutines BEFORE resetting Main: they dispatch - // on Dispatchers.Main, and one still alive when resetMain/setMain runs - // throws IllegalStateException from TestMainDispatcher — the CI flake - // that failed watchedToggleRevertsOnLatestFailureOnly. - private val createdViewModels = mutableListOf() - private fun runItemDetailTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) try { block() } finally { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() Dispatchers.resetMain() } } private fun itemDetailViewModel( personalDataRepository: RecordingPersonalDataRepository, + catalogRepository: CatalogRepository = CatalogRepository(CatalogApi(dummyHttpClient())), ): ItemDetailViewModel = ItemDetailViewModel( - catalogRepository = CatalogRepository(CatalogApi(dummyHttpClient())), + catalogRepository = catalogRepository, personalDataRepository = personalDataRepository, downloadsRepository = DownloadsRepository(EmptyDownloadsApi()), downloadEnqueuer = unsafeInstance(), ebookReaderRepository = EbookReaderRepository(EbookReaderApi(dummyHttpClient())), + recommendationRepository = RecommendationRepository(RecommendationApi(dummyHttpClient())), metadataAiRepository = org.prairieserver.prairie.repository.MetadataAiRepository( org.prairieserver.prairie.network.api.DefaultMetadataAiApi(dummyHttpClient()), ), savedStateHandle = SavedStateHandle(), - ).also { createdViewModels += it } + ) + + @Suppress("UNCHECKED_CAST") + private fun ItemDetailViewModel.seedSeriesDetail() { + val field = ItemDetailViewModel::class.java.getDeclaredField("_uiState") + field.isAccessible = true + val flow = field.get(this) as MutableStateFlow + val seasonOneEpisodes = listOf( + EpisodeListItem( + contentId = "season-1-episode-1", + seasonNumber = 1, + episodeNumber = 1, + ), + ) + val seasonTwoEpisodes = listOf( + EpisodeListItem( + contentId = "season-2-episode-1", + seasonNumber = 2, + episodeNumber = 1, + ), + ) + flow.value = ItemDetailUiState( + isLoading = false, + detail = ItemDetail( + contentId = "series-1", + type = "series", + title = "Series", + ), + seasons = listOf( + Season(contentId = "season-1", seasonNumber = 1), + Season(contentId = "season-2", seasonNumber = 2), + ), + selectedSeasonNumber = 1, + episodes = seasonOneEpisodes, + episodesBySeason = mapOf( + 1 to seasonOneEpisodes, + 2 to seasonTwoEpisodes, + ), + ) + } @Suppress("UNCHECKED_CAST") private fun ItemDetailViewModel.seedDetail(played: Boolean) { diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt new file mode 100644 index 000000000..fba213ca5 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class PhoneDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt", + ).readText() + private val movie = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + + @Test + fun phoneMovieHeroUsesSharedDirectorCredit() { + assertTrue(hero.contains("directorText: String? = null")) + assertTrue(movie.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun phoneCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt new file mode 100644 index 000000000..c0f908ed9 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonPresentationLabelTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.android.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.Season +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonPresentationLabelTest { + @Test + fun seasonZeroWithoutSpecialsFlagUsesSpecialsLabel() { + assertEquals( + "Specials", + phoneSeasonLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun nonzeroSeasonWithSpecialsFlagUsesSpecialsLabel() { + assertEquals( + "Specials", + phoneSeasonLabel( + Season(contentId = "bonus", seasonNumber = 99, isSpecials = true), + ), + ) + } + + @Test + fun specialsOnlySelectionUsesSpecialsSectionHeader() { + assertEquals( + "Specials", + seriesSeasonSectionLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun specialsDownloadAccessibilityCopyNeverUsesSeasonZero() { + val specials = Season(contentId = "specials", seasonNumber = 0) + + assertEquals("Specials downloaded", seasonDownloadContentDescription(specials, true)) + assertEquals("Download specials", seasonDownloadContentDescription(specials, false)) + } + + @Test + fun specialsEpisodeEyebrowNeverUsesSeasonZero() { + val detail = ItemDetail( + contentId = "episode-special", + type = "episode", + title = "Bonus", + seasonNumber = 0, + episodeNumber = 3, + ) + + assertEquals("Specials · Episode 3", HeroMetadata.episodeEyebrow(detail)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt new file mode 100644 index 000000000..2e59be6e5 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt @@ -0,0 +1,445 @@ +package org.prairieserver.prairie.android.ui.screens.libraries + +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withTimeout +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import androidx.test.core.app.ApplicationProvider +import org.prairieserver.prairie.android.ui.screens.browse.BrowsePrefsStore +import org.prairieserver.prairie.catalog.filter.CatalogFacet +import org.prairieserver.prairie.catalog.filter.CatalogFilterState +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.SectionRepository +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +// BrowsePrefsStore writes through real SharedPreferences, so the persistence +// regression below needs an Android context. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class LibrariesViewModelTest { + @Test + fun recommendedResponseFromPreviousLibraryCannotReplaceCurrentLibraryRows() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("sections:1", "sections:2"), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectLibrary(2) + fixture.awaitRequest("sections:2") + fixture.complete("sections:2", sectionsBody("current")) + viewModel.uiState.first { it.sections.map { section -> section.id } == listOf("current") } + + fixture.complete("sections:1", sectionsBody("stale")) + staleRequest.join() + + assertEquals(2, viewModel.uiState.value.selectedLibraryId) + assertEquals(listOf("current"), viewModel.uiState.value.sections.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun browseResponseFromPreviousSortCannotReplaceCurrentQueryGrid() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf( + "catalog:1:added_at:desc", + "catalog:1:title:asc", + ), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectBrowseSort(LibraryBrowseSort.Title) + fixture.awaitRequest("catalog:1:title:asc") + fixture.complete("catalog:1:title:asc", catalogBody("current")) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("current") + } + + fixture.complete("catalog:1:added_at:desc", catalogBody("stale")) + staleRequest.join() + + assertEquals(LibraryBrowseSort.Title, viewModel.uiState.value.browseSort) + assertEquals(listOf("current"), viewModel.uiState.value.catalogItems.map { it.contentId }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun browseResponseFromPreviousFilterStateCannotReplaceCurrentQueryGrid() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf( + "catalog:1:added_at:desc", + "catalog:1:added_at:desc:filtered", + ), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + viewModel.viewModelScope.launch { delay(1) } + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc") + val staleRequest = viewModel.onlyActiveRequest() + + val dramaOnly = CatalogFilterState( + selections = mapOf(CatalogFacet.Genre to setOf("Drama")), + ) + viewModel.applyFilterState(dramaOnly) + fixture.awaitRequest("catalog:1:added_at:desc:filtered") + fixture.complete( + "catalog:1:added_at:desc:filtered", + catalogBody("current-filter"), + ) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("current-filter") + } + + fixture.complete("catalog:1:added_at:desc", catalogBody("stale-unfiltered")) + staleRequest.join() + + assertEquals(dramaOnly, viewModel.uiState.value.filterState) + assertEquals( + listOf("current-filter"), + viewModel.uiState.value.catalogItems.map { it.contentId }, + ) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun delayedFilterVocabularyStillAppliesAfterLoadingNextPage() = runTest { + val firstPageKey = "catalog:1:added_at:desc:0" + val secondPageKey = "catalog:1:added_at:desc:1" + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("filters", firstPageKey, secondPageKey), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("filters") + fixture.awaitRequest(firstPageKey) + val filterRequest = viewModel.onlyActiveRequest() + fixture.complete(firstPageKey, catalogPageBody("page-1", hasMore = true)) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("page-1") && + it.catalogHasMore + } + + viewModel.loadMoreCatalog() + fixture.awaitRequest(secondPageKey) + fixture.complete(secondPageKey, catalogPageBody("page-2", hasMore = false)) + viewModel.uiState.first { + it.catalogItems.map { item -> item.contentId } == listOf("page-1", "page-2") + } + + fixture.complete("filters", filtersBody("Drama")) + filterRequest.join() + + assertEquals(listOf("Drama"), viewModel.uiState.value.availableFilters?.genres) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun collectionsResponseFromPreviousLibraryCannotReplaceCurrentLibraryCollections() = runTest { + val fixture = DeferredLibrariesFixture( + deferredKeys = setOf("collections:1", "collections:2"), + ) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val viewModel = fixture.viewModel() + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Collections) + fixture.awaitRequest("collections:1") + val staleRequest = viewModel.onlyActiveRequest() + + viewModel.selectLibrary(2) + fixture.awaitRequest("collections:2") + fixture.complete("collections:2", collectionsBody("current")) + viewModel.uiState.first { + it.collections.map { collection -> collection.id } == listOf("current") + } + + fixture.complete("collections:1", collectionsBody("stale")) + staleRequest.join() + + assertEquals(2, viewModel.uiState.value.selectedLibraryId) + assertEquals(listOf("current"), viewModel.uiState.value.collections.map { it.id }) + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + @Test + fun browseSortIsPersistedAndRestoredOnAFreshViewModel() = runTest { + val fixture = DeferredLibrariesFixture(deferredKeys = emptySet()) + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val browsePrefs = BrowsePrefsStore( + context = ApplicationProvider.getApplicationContext(), + serverRegistry = FakeServerRegistry(), + ) + val viewModel = fixture.viewModel(browsePrefs = browsePrefs) + val store = ViewModelStore().also { it.put("libraries", viewModel) } + try { + fixture.awaitRequest("sections:1") + viewModel.uiState.first { !it.isLoadingSections } + viewModel.selectTab(LibrariesSubtab.Browse) + fixture.awaitRequest("catalog:1:added_at:desc") + + viewModel.selectBrowseSort(LibraryBrowseSort.Title) + // The sort is persisted before the reload is issued, so awaiting + // the re-sorted request is a sufficient sync point. + fixture.awaitRequest("catalog:1:title:asc") + + val restored = fixture.viewModel(browsePrefs = browsePrefs) + val restoredStore = ViewModelStore().also { it.put("libraries-restored", restored) } + try { + val state = restored.uiState.first { + !it.isLoadingLibraries && it.selectedLibraryId == 1 + } + assertEquals(LibraryBrowseSort.Title, state.browseSort) + assertEquals("title", state.filterState.sort) + assertEquals("asc", state.filterState.order) + } finally { + restoredStore.clear() + } + } finally { + store.clear() + Dispatchers.resetMain() + fixture.close() + } + } + + private suspend fun LibrariesViewModel.onlyActiveRequest(): Job = withTimeout(5_000) { + while (true) { + val activeRequests = viewModelScope.coroutineContext[Job] + ?.children + ?.filter { it.isActive } + ?.toList() + .orEmpty() + when (activeRequests.size) { + 0 -> error("Expected an active Libraries request") + 1 -> return@withTimeout activeRequests.single() + else -> delay(1) + } + } + error("Unreachable") + } + + /** BrowsePrefsStore persists nothing without an active server + profile. */ + private class FakeServerRegistry : ServerRegistry { + private val entry = ServerEntry( + id = "server-1", + url = "https://silo.test", + profileId = "profile-1", + ) + override val entries: StateFlow> = MutableStateFlow(listOf(entry)) + override val activeServerId: StateFlow = MutableStateFlow(entry.id) + override val activeEntry: StateFlow = MutableStateFlow(entry) + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = entry.id + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) = Unit + override suspend fun touchActive() = Unit + } + + private class DeferredLibrariesFixture( + private val deferredKeys: Set, + ) { + private val requests = Channel(Channel.UNLIMITED) + private val pendingRequests = mutableListOf() + private val responses = deferredKeys.associateWith { CompletableDeferred() } + private val client = HttpClient( + MockEngine { request -> + val key = when (request.url.encodedPath) { + "/api/v1/user/libraries" -> "libraries" + "/api/v1/catalog/filters" -> "filters" + "/api/v1/catalog" -> { + val baseKey = "catalog:${request.url.parameters["library_id"]}:" + + "${request.url.parameters["sort"]}:${request.url.parameters["order"]}" + val filterSuffix = if ( + request.url.parameters.names().any { it.startsWith("groups[") } + ) { + ":filtered" + } else { + "" + } + val offsetSuffix = ":${request.url.parameters["offset"]}" + listOf( + baseKey + filterSuffix + offsetSuffix, + baseKey + filterSuffix, + baseKey + offsetSuffix, + baseKey, + ).firstOrNull(responses::containsKey) ?: (baseKey + filterSuffix) + } + else -> { + val segments = request.url.encodedPath.split('/') + val family = segments.last() + "$family:${segments[4]}" + } + } + requests.send(key) + val body = responses[key]?.await() ?: immediateBody(key) + respondJson(body) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + fun viewModel(browsePrefs: BrowsePrefsStore? = null) = LibrariesViewModel( + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + sectionRepository = SectionRepository(SectionApi(client)), + catalogRepository = CatalogRepository(CatalogApi(client)), + browsePrefs = browsePrefs, + ) + + suspend fun awaitRequest(expected: String) { + val pendingIndex = pendingRequests.indexOf(expected) + if (pendingIndex >= 0) { + pendingRequests.removeAt(pendingIndex) + return + } + while (true) { + val actual = requests.receive() + if (actual == expected) return + pendingRequests += actual + } + } + + fun complete(key: String, body: String) { + checkNotNull(responses[key]) { "No deferred response for $key" }.complete(body) + } + + fun close() { + client.close() + } + + private fun immediateBody(key: String): String = when { + key == "libraries" -> """ + [ + {"id":1,"name":"First","type":"movies","sort_order":0}, + {"id":2,"name":"Second","type":"movies","sort_order":1} + ] + """.trimIndent() + key == "filters" -> + """{"genres":[],"studios":[],"networks":[],"countries":[],"content_ratings":[]}""" + key.startsWith("sections:") -> """{"sections":[]}""" + key.startsWith("collections:") -> """{"collections":[]}""" + key.startsWith("catalog:") -> catalogBody("immediate") + else -> error("Unexpected request key $key") + } + + private fun MockRequestHandleScope.respondJson(body: String) = respond( + content = body, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + companion object { + private fun sectionsBody(id: String) = """ + { + "sections":[{ + "id":"$id", + "section_type":"recently_added", + "title":"$id", + "items":[{"content_id":"$id-item","type":"movie","title":"$id"}] + }] + } + """.trimIndent() + + private fun catalogBody(id: String) = """ + { + "total":1, + "has_more":false, + "items":[{"content_id":"$id","type":"movie","title":"$id"}] + } + """.trimIndent() + + private fun catalogPageBody(id: String, hasMore: Boolean) = """ + { + "total":2, + "has_more":$hasMore, + "items":[{"content_id":"$id","type":"movie","title":"$id"}] + } + """.trimIndent() + + private fun filtersBody(genre: String) = + """{"genres":["$genre"],"studios":[],"networks":[],"countries":[],"content_ratings":[]}""" + + private fun collectionsBody(id: String) = + """{"collections":[{"id":"$id","name":"$id"}]}""" + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt new file mode 100644 index 000000000..9de746626 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.android.ui.screens.libraries + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LibraryChromeInsetSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val libraries = source( + "org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt", + ) + private val catalogGrid = source( + "org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt", + ) + + /** + * The chrome floats over the viewport (composed after it, so it draws on + * top and reads the viewport as its blur source) and every tab clears the + * chrome's *measured* height rather than a hard-coded runway. + */ + @Test + fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { + val viewport = libraries.indexOf("LibraryContentViewport(") + val chrome = libraries.indexOf("LibrariesFloatingChrome(", viewport) + assertTrue(viewport >= 0) + assertTrue(chrome > viewport) + assertTrue(libraries.contains(".hazeSource(chromeHaze)")) + assertTrue(libraries.contains(".clipToBounds()")) + assertTrue(libraries.contains("onSizeChanged { chromeHeightPx = it.height }")) + // Each subtab receives the measured inset. + assertTrue(Regex("RecommendedTabContent\\([\\s\\S]*?topInset = topInset").containsMatchIn(libraries)) + assertTrue(Regex("BrowseTabContent\\([\\s\\S]*?topInset = topInset").containsMatchIn(libraries)) + assertTrue(Regex("CollectionsTabContent\\([\\s\\S]*?topInset = topInset").containsMatchIn(libraries)) + } + + @Test + fun tabsDoNotCarryOverlayClearanceRunways() { + assertFalse(libraries.contains("LibrariesChromeContentHeight")) + assertFalse(libraries.contains("extraTopInset = 50.dp")) + assertFalse(libraries.contains(".windowInsetsPadding(WindowInsets.statusBars)")) + } + + @Test + fun browseCatalogAndAlphabetRailReserveMeasuredBottomChromeInset() { + assertTrue(libraries.contains("bottomContentInset = LocalBottomChromeInset.current")) + assertTrue(libraries.contains("topContentInset = topInset")) + assertTrue(catalogGrid.contains("bottomContentInset: Dp = 0.dp")) + assertTrue(catalogGrid.contains("bottom = 8.dp + bottomContentInset")) + // The letter index keeps clear of both the floating chrome and the + // bottom pill. + assertTrue(catalogGrid.contains(".padding(top = topContentInset + 8.dp, bottom = bottomContentInset + 8.dp)")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/people/PersonDetailViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/people/PersonDetailViewModelTest.kt index 37474035b..f92de4cad 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/people/PersonDetailViewModelTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/people/PersonDetailViewModelTest.kt @@ -141,8 +141,8 @@ class PersonDetailViewModelTest { viewModel: PersonDetailViewModel, predicate: (PersonDetailUiState) -> Boolean, ) { - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(5_000) { + withContext(Dispatchers.IO) { + withTimeout(AWAIT_POLL_TIMEOUT_MS) { while (!predicate(viewModel.uiState.value)) { delay(10) } @@ -257,3 +257,13 @@ class PersonDetailViewModelTest { private fun item(id: String, title: String, type: String): String = """{"content_id":"$id","title":"$title","type":"$type"}""" } + +/** + * Wall-clock backstop for the polling waits above. + * + * It exists to turn a hang into a failure, not to assert latency: a passing + * test settles in milliseconds. Short deadlines here failed on a loaded CI + * runner while the work was merely slow, which looks exactly like the race the + * wait was written to catch. + */ +private const val AWAIT_POLL_TIMEOUT_MS = 30_000L diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPostureTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPostureTest.kt new file mode 100644 index 000000000..f11ea43eb --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/FoldablePlayerPostureTest.kt @@ -0,0 +1,94 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import androidx.window.layout.FoldingFeature +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FoldablePlayerPostureTest { + + @Test + fun `half-open horizontal separating fold is tabletop`() { + assertTrue( + isTabletopPlayerPosture( + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.HORIZONTAL, + isSeparating = true, + ), + ) + } + + @Test + fun `book posture and flat folds retain the normal player`() { + assertFalse( + isTabletopPlayerPosture( + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.VERTICAL, + isSeparating = true, + ), + ) + assertFalse( + isTabletopPlayerPosture( + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.HORIZONTAL, + isSeparating = true, + ), + ) + assertFalse( + isTabletopPlayerPosture( + state = FoldingFeature.State.HALF_OPENED, + orientation = FoldingFeature.Orientation.HORIZONTAL, + isSeparating = false, + ), + ) + } + + @Test + fun `pane layout reserves the physical hinge and guard space`() { + assertEquals( + TabletopPlayerPaneLayout( + videoHeightPx = 480, + controlsHeightPx = 480, + ), + calculateTabletopPlayerPaneLayout( + rootTopPx = 0, + rootBottomPx = 1000, + foldTopPx = 490, + foldBottomPx = 510, + foldGuardPx = 10, + ), + ) + } + + @Test + fun `zero-height crease still gets guarded`() { + assertEquals( + TabletopPlayerPaneLayout( + videoHeightPx = 492, + controlsHeightPx = 492, + ), + calculateTabletopPlayerPaneLayout( + rootTopPx = 100, + rootBottomPx = 1100, + foldTopPx = 600, + foldBottomPx = 600, + foldGuardPx = 8, + ), + ) + } + + @Test + fun `fold outside usable root does not produce tabletop panes`() { + assertNull( + calculateTabletopPlayerPaneLayout( + rootTopPx = 100, + rootBottomPx = 1100, + foldTopPx = 50, + foldBottomPx = 80, + foldGuardPx = 8, + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteTest.kt new file mode 100644 index 000000000..a314cce36 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/LetterboxMatteTest.kt @@ -0,0 +1,369 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val WINDOW_WIDTH = 3120 +private const val WINDOW_HEIGHT = 1440 + +/** Landscape width of the S26 Ultra punch-hole, per `dumpsys window displays`. */ +private const val CUTOUT_PX = 139 + +/** The default box: the display less a symmetric inset clear of the camera. */ +private const val CLEAR_BOX_WIDTH = WINDOW_WIDTH - 2 * CUTOUT_PX + +/** + * Reference title one: a 2.39:1 scope film in a 3840x2160 frame. Its picture is + * WIDER than the display, so fitting the content rect binds on width. + */ +private const val SCOPE_CODED_ASPECT = 3840f / 2160f +private const val SCOPE_CONTENT_ASPECT = 2.393f +private const val SCOPE_MATTE = 277.65f / 2160f + +/** + * Reference title two: a 1.90:1 film in a 1920x1080 frame, measured live on the + * device. Its picture is NARROWER than the display, so fitting the content rect + * binds on HEIGHT — the case the old fill-the-width rule silently declined. + */ +private const val FLAT_CODED_ASPECT = 1920f / 1080f +private const val FLAT_CONTENT_ASPECT = 1920f / 1009.5f +private const val FLAT_MATTE = 35.25f / 1080f + +private fun frame( + matteFraction: Float, + width: Int = 8, + height: Int = 144, + pictureChannel: Int = 200, +): IntArray { + val bar = (matteFraction * height).toInt() + val pixels = IntArray(width * height) + for (row in 0 until height) { + val black = row < bar || row >= height - bar + val value = if (black) 0xFF000000.toInt() else colour(pictureChannel) + for (column in 0 until width) pixels[row * width + column] = value + } + return pixels +} + +private fun colour(channel: Int): Int = + (0xFF shl 24) or (channel shl 16) or (channel shl 8) or channel + +private fun sample(matte: Float) = MatteSample(matte, matte) + +class LetterboxMatteTest { + + // ---- measureMatte ------------------------------------------------------- + + @Test + fun measuresBarsOnBothEdges() { + val measured = measureMatte(frame(matteFraction = 0.125f), width = 8, height = 144) + assertNotNull(measured) + assertEquals(18f / 144f, measured.topFraction, 0.001f) + assertEquals(18f / 144f, measured.bottomFraction, 0.001f) + } + + @Test + fun reportsNoBarsForAFullFrameImage() { + val measured = measureMatte(frame(matteFraction = 0f), width = 8, height = 144) + assertNotNull(measured) + assertEquals(0f, measured.topFraction) + assertEquals(0f, measured.bottomFraction) + } + + @Test + fun refusesAFadeToBlack() { + val black = IntArray(8 * 144) { 0xFF000000.toInt() } + assertNull(measureMatte(black, width = 8, height = 144)) + } + + @Test + fun refusesAFrameThatIsMostlyBlack() { + assertNull(measureMatte(frame(matteFraction = 0.45f), width = 8, height = 144)) + } + + @Test + fun aBrightPixelKeepsItsRowOutOfTheMatte() { + val pixels = frame(matteFraction = 0.125f) + pixels[3 * 8 + 4] = colour(240) + val measured = measureMatte(pixels, width = 8, height = 144) + assertNotNull(measured) + assertEquals(3f / 144f, measured.topFraction, 0.001f) + assertEquals(18f / 144f, measured.bottomFraction, 0.001f) + } + + @Test + fun toleratesCodecRingingInTheBar() { + val pixels = frame(matteFraction = 0.125f) + pixels[3 * 8 + 4] = colour(MATTE_BLACK_CHANNEL_MAX) + val measured = measureMatte(pixels, width = 8, height = 144) + assertNotNull(measured) + assertEquals(18f / 144f, measured.topFraction, 0.001f) + } + + @Test + fun rejectsMalformedInput() { + assertNull(measureMatte(IntArray(0), width = 0, height = 0)) + assertNull(measureMatte(IntArray(4), width = 8, height = 144)) + } + + // ---- the safety property ------------------------------------------------ + + @Test + fun fittingTheContentRectNeverClipsMoreThanTheMatte() { + // The proof in LetterboxMatte.kt, executed: across coded aspects, matte + // thicknesses and box shapes — including boxes narrower and wider than + // the content — the clip never exceeds the black that defined the rect. + val codedAspects = listOf(4f / 3f, 1.5f, FLAT_CODED_ASPECT, 2.0f, 2.39f) + val mattes = listOf(0.001f, 0.01f, FLAT_MATTE, 0.08f, SCOPE_MATTE, 0.24f) + val boxAspects = listOf(0.6f, 1f, 1.6f, 1.9736f, 2.1667f, 3.2f) + for (coded in codedAspects) { + for (matte in mattes) { + val safe = safeMatteFraction(matte) + val fitted = contentAspect(coded, safe) + for (box in boxAspects) { + val clip = verticalClipFraction(coded, fitted, box) + assertTrue( + clip <= safe + 1e-5f, + "clip $clip exceeded safe matte $safe (coded=$coded box=$box)", + ) + assertTrue( + clip <= matte, + "clip $clip exceeded measured matte $matte (coded=$coded box=$box)", + ) + } + } + } + } + + @Test + fun holdsBackHeadroomProportionalToTheMatte() { + // A flat fraction of frame height would be a rounding error on a scope + // matte and two thirds of a 1.90:1 one, which is why this scales. + assertEquals(SCOPE_MATTE * (1f - MATTE_MARGIN_FRACTION), safeMatteFraction(SCOPE_MATTE), 1e-5f) + // Below the crossover the floor governs, covering row quantisation. + assertEquals(FLAT_MATTE - MATTE_MARGIN_FLOOR, safeMatteFraction(FLAT_MATTE), 1e-5f) + // A matte thinner than the floor is not worth acting on at all. + assertEquals(0f, safeMatteFraction(MATTE_MARGIN_FLOOR / 2f)) + assertEquals(0f, safeMatteFraction(0f)) + } + + @Test + fun contentWithNoStoredBarsIsLeftExactlyAlone() { + // A 16:9 episode on this panel: nothing to discount, so the content rect + // IS the coded frame, the scale is a plain fit and nothing moves. + assertEquals(FLAT_CODED_ASPECT, contentAspect(FLAT_CODED_ASPECT, safeMatteFraction(0f))) + val estimator = LetterboxFillEstimator() + repeat(MATTE_SAMPLES_TO_SETTLE * 10) { estimator.onSample(sample(0f), FLAT_CODED_ASPECT) } + assertEquals(FLAT_CODED_ASPECT, estimator.contentAspectFor(FLAT_CODED_ASPECT)) + } + + // ---- reference geometry ------------------------------------------------- + + @Test + fun scopeFilmBindsOnWidthAndKeepsGenuineLetterbox() { + val fitted = contentAspect(SCOPE_CODED_ASPECT, safeMatteFraction(SCOPE_MATTE)) + val image = expandedImageSize( + boxWidth = CLEAR_BOX_WIDTH, + boxHeight = WINDOW_HEIGHT, + contentAspect = fitted, + trueContentAspect = SCOPE_CONTENT_ASPECT, + ) + assertNotNull(image) + assertEquals(2842, image.width) + assertEquals(1188, image.height) + assertEquals(CUTOUT_PX, (WINDOW_WIDTH - image.width) / 2) + } + + @Test + fun scopeFilmAtFullWidthReachesBothEdges() { + val fitted = contentAspect(SCOPE_CODED_ASPECT, safeMatteFraction(SCOPE_MATTE)) + val image = expandedImageSize( + boxWidth = WINDOW_WIDTH, + boxHeight = WINDOW_HEIGHT, + contentAspect = fitted, + trueContentAspect = SCOPE_CONTENT_ASPECT, + ) + assertNotNull(image) + assertEquals(WINDOW_WIDTH, image.width) + assertEquals(1304, image.height) + } + + @Test + fun flatFilmBindsOnHeightAndFillsTopToBottom() { + // The regression this rule was generalised for: 1.90:1 is NARROWER than + // the 2.167:1 display, so filling the width is impossible but filling + // the HEIGHT is free — and the old rule only ever asked about width. + val fitted = contentAspect(FLAT_CODED_ASPECT, safeMatteFraction(FLAT_MATTE)) + val image = expandedImageSize( + boxWidth = CLEAR_BOX_WIDTH, + boxHeight = WINDOW_HEIGHT, + contentAspect = fitted, + trueContentAspect = FLAT_CONTENT_ASPECT, + ) + assertNotNull(image) + assertEquals(2679, image.width) + assertEquals(1409, image.height) + // Comfortably taller than the 1346 a plain fit of the coded frame gives. + assertTrue(image.height > 1346) + } + + @Test + fun flatFilmIsUnaffectedByTheCameraInset() { + // It binds on height, so the width it wants is well inside the inset + // box — the default costs this title nothing at all. + val fitted = contentAspect(FLAT_CODED_ASPECT, safeMatteFraction(FLAT_MATTE)) + val clear = expandedImageSize( + CLEAR_BOX_WIDTH, WINDOW_HEIGHT, fitted, FLAT_CONTENT_ASPECT, + ) + val full = expandedImageSize( + WINDOW_WIDTH, WINDOW_HEIGHT, fitted, FLAT_CONTENT_ASPECT, + ) + assertNotNull(clear) + assertNotNull(full) + assertEquals(clear, full) + assertTrue(clear.width < CLEAR_BOX_WIDTH) + } + + // ---- cutout ------------------------------------------------------------- + + @Test + fun insetsSymmetricallyForEitherLandscapeRotation() { + // The punch-hole lands against the left edge at ROTATION_90 and the + // right at ROTATION_270. Both inset the same, or flipping the phone end + // for end would shift the picture sideways. + assertEquals(CUTOUT_PX, cutoutSafeHorizontalInset(CUTOUT_PX, 0)) + assertEquals(CUTOUT_PX, cutoutSafeHorizontalInset(0, CUTOUT_PX)) + } + + @Test + fun leavesAScreenWithoutASideCutoutAlone() { + // Portrait reports the cutout on the top edge, which this ignores: the + // video is nowhere near it and must not be pushed down. + assertEquals(0, cutoutSafeHorizontalInset(0, 0)) + } + + // ---- estimator ---------------------------------------------------------- + + private fun feed( + estimator: LetterboxFillEstimator, + matte: Float?, + times: Int = 1, + codedAspect: Float = SCOPE_CODED_ASPECT, + ): Float { + var aspect = codedAspect + repeat(times) { + aspect = estimator.onSample(matte?.let(::sample), codedAspect) + } + return aspect + } + + @Test + fun appliesNothingUntilEnoughFramesAgree() { + val estimator = LetterboxFillEstimator() + assertEquals( + SCOPE_CODED_ASPECT, + feed(estimator, SCOPE_MATTE, times = MATTE_SAMPLES_TO_SETTLE - 1), + ) + assertTrue(feed(estimator, SCOPE_MATTE) > SCOPE_CODED_ASPECT) + assertTrue(estimator.isSettled) + } + + @Test + fun narrowsOnTheVeryFirstFrameThatDisagreesAndStaysNarrow() { + val estimator = LetterboxFillEstimator() + val expanded = feed(estimator, SCOPE_MATTE, times = MATTE_SAMPLES_TO_SETTLE) + // An IMAX sequence opening up: one frame, and the crop is given back. + val narrowed = feed(estimator, FLAT_MATTE) + assertTrue(narrowed < expanded) + // A monotone minimum cannot oscillate, so the picture never breathes — + // this is what instant revert and latch-off both reduce to. + assertEquals(narrowed, feed(estimator, SCOPE_MATTE, times = 40)) + } + + @Test + fun holdsTheEstimateWhileThereIsNoEvidence() { + val estimator = LetterboxFillEstimator() + val expanded = feed(estimator, SCOPE_MATTE, times = MATTE_SAMPLES_TO_SETTLE) + assertEquals(expanded, feed(estimator, null, times = 10)) + + // And an unusable frame is not progress towards settling either. + val cold = LetterboxFillEstimator() + assertEquals(SCOPE_CODED_ASPECT, feed(cold, null, times = 10)) + } + + @Test + fun theThinnerEdgeGoverns() { + // An off-centre image is not a letterbox; cropping to the thicker edge + // would cut the picture on the thinner one. + val estimator = LetterboxFillEstimator() + repeat(MATTE_SAMPLES_TO_SETTLE) { + estimator.onSample(MatteSample(SCOPE_MATTE, 0f), SCOPE_CODED_ASPECT) + } + assertEquals(SCOPE_CODED_ASPECT, estimator.contentAspectFor(SCOPE_CODED_ASPECT)) + } + + @Test + fun aRememberedMatteAppliesBeforeAnyFrameArrives() { + val estimator = LetterboxFillEstimator() + estimator.seed(SCOPE_MATTE) + // The point of the cache: expanded on the first presented frame. + assertTrue(estimator.contentAspectFor(SCOPE_CODED_ASPECT) > SCOPE_CODED_ASPECT) + } + + @Test + fun liveFramesReplaceARememberedMatteEntirely() { + val estimator = LetterboxFillEstimator() + estimator.seed(SCOPE_MATTE) + // A stale entry claiming a thick matte is corrected by measurement + // rather than governing the session — and never written back. + val settled = feed(estimator, FLAT_MATTE, times = MATTE_SAMPLES_TO_SETTLE) + assertEquals(contentAspect(SCOPE_CODED_ASPECT, safeMatteFraction(FLAT_MATTE)), settled, 1e-5f) + assertEquals(FLAT_MATTE, estimator.observedMatte) + } + + @Test + fun onlyLiveFramesAreEverRememberedBack() { + val estimator = LetterboxFillEstimator() + estimator.seed(SCOPE_MATTE) + assertNull(estimator.observedMatte) + } + + @Test + fun resetClearsEverythingForTheNextItem() { + val estimator = LetterboxFillEstimator() + estimator.seed(SCOPE_MATTE) + feed(estimator, SCOPE_MATTE, times = MATTE_SAMPLES_TO_SETTLE) + estimator.reset() + assertNull(estimator.observedMatte) + assertTrue(!estimator.isSettled) + assertEquals(SCOPE_CODED_ASPECT, estimator.contentAspectFor(SCOPE_CODED_ASPECT)) + } + + // ---- cache key ---------------------------------------------------------- + + @Test + fun cacheKeyNamesTheExactStreamNotTheTitle() { + val base = letterboxMatteCacheKey("https://silo", "movie-1", 42, 3840, 2160) + assertNotNull(base) + // A different cut, or the same file arriving transcoded at another + // resolution, must not inherit a crop measured from this one. + assertTrue(base != letterboxMatteCacheKey("https://silo", "movie-1", 43, 3840, 2160)) + assertTrue(base != letterboxMatteCacheKey("https://silo", "movie-1", 42, 1920, 1080)) + assertTrue(base != letterboxMatteCacheKey("https://other", "movie-1", 42, 3840, 2160)) + } + + @Test + fun cacheKeyRefusesMediaItCannotNamePrecisely() { + assertNull(letterboxMatteCacheKey("https://silo", "movie-1", null, 3840, 2160)) + assertNull(letterboxMatteCacheKey("https://silo", null, 42, 3840, 2160)) + assertNull(letterboxMatteCacheKey("https://silo", "movie-1", 42, 0, 0)) + // Content and file ids are scoped to whoever issued them, so without an + // origin the tuple names nothing in particular — two servers' downloads + // would share it. + assertNull(letterboxMatteCacheKey("", "movie-1", 42, 3840, 2160)) + assertNull(letterboxMatteCacheKey(null, "movie-1", 42, 3840, 2160)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt index 59f3931a9..7002e5824 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileAudioTrackSelectionTest.kt @@ -1,27 +1,76 @@ package org.prairieserver.prairie.android.ui.screens.player import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.playback.audioTrackFingerprint +import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull +/** + * Audio is addressed by ORDINAL into `audio_tracks`. The wire carries no index + * for audio tracks — only subtitles get one — so [AudioTrack.index] is its `0` + * default on every row, and the tracks below deliberately leave it unset to + * match what the server actually sends. + */ class MobileAudioTrackSelectionTest { private val tracks = listOf( - AudioTrack(index = 2, language = "eng"), - AudioTrack(index = 7, language = "fra"), + AudioTrack(language = "eng"), + AudioTrack(language = "fra"), ) @Test - fun `player ordinal maps to stable server audio index`() { - assertEquals(7, selectedServerAudioTrackIndex(selectedOrdinal = 1, audioTracks = tracks)) + fun `picker ordinal is what the server is asked for`() { + // This used to map through AudioTrack.index and evaluate to 0 for every + // row, so choosing French requested English. + assertEquals(0, selectedServerAudioTrackIndex(selectedOrdinal = 0, audioTracks = tracks)) + assertEquals(1, selectedServerAudioTrackIndex(selectedOrdinal = 1, audioTracks = tracks)) } @Test - fun `server audio index maps back to player ordinal`() { - assertEquals(1, selectedAudioTrackOrdinal(selectedServerIndex = 7, audioTracks = tracks)) + fun `an ordinal outside the catalog is not a usable request`() { + assertNull(selectedServerAudioTrackIndex(selectedOrdinal = 5, audioTracks = tracks)) + assertNull(selectedServerAudioTrackIndex(selectedOrdinal = 0, audioTracks = emptyList())) } @Test - fun `legacy ordinal response remains usable when no server index matches`() { + fun `the server value maps back to the same picker row`() { + assertEquals(1, selectedAudioTrackOrdinal(selectedServerIndex = 1, audioTracks = tracks)) assertEquals(0, selectedAudioTrackOrdinal(selectedServerIndex = 0, audioTracks = tracks)) } + + @Test + fun `an out of range server value falls back to the first row`() { + assertEquals(0, selectedAudioTrackOrdinal(selectedServerIndex = 9, audioTracks = tracks)) + } + + /** + * The committed value is an ordinal. Resolving it against AudioTrack.index + * matched nothing above row zero, so a committed choice was silently never + * written and reopening the item lost it. + */ + @Test + fun `a committed ordinal persists that row's fingerprint`() { + val update = mobileAudioTrackPersistenceUpdate( + committedAudioTrackIndex = 1, + audioTracks = tracks, + ) + + assertEquals( + TrackSelectionFingerprintUpdate.Set(audioTrackFingerprint(tracks[1])), + update, + ) + } + + @Test + fun `no committed audio preserves whatever was stored`() { + assertEquals( + TrackSelectionFingerprintUpdate.Preserve, + mobileAudioTrackPersistenceUpdate(committedAudioTrackIndex = null, audioTracks = tracks), + ) + assertEquals( + TrackSelectionFingerprintUpdate.Preserve, + mobileAudioTrackPersistenceUpdate(committedAudioTrackIndex = 9, audioTracks = tracks), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestoreTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestoreTest.kt index a155d00e7..579977dba 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestoreTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileFreshSubtitleRestoreTest.kt @@ -17,6 +17,37 @@ import kotlin.test.assertIs import kotlin.test.assertNull class MobileFreshSubtitleRestoreTest { + @Test + fun authoritativeV3InventoryIsNotRebuiltFromTheDownloadedCatalog() = runTest { + val authoritative = PlayerSubtitleInfo( + index = 3, + language = "es", + codec = "ass", + label = "Spanish", + source = "downloaded", + url = "/stream/fresh-session/subtitles/3.ass", + serverTrackId = "file:7:subtitle:3", + serverDelivery = "sidecar", + ) + var catalogRead = false + + val result = prepareMobileFreshSubtitleRestore( + mediaFileId = 7, + mountedSubtitles = listOf(authoritative), + sessionId = "fresh-session", + serverUrl = "https://silo.test", + persistedPreference = null, + authoritativeInventory = true, + loadDownloadedSubtitles = { + catalogRead = true + ApiResult.Success(DownloadedSubtitlesResponse(listOf(downloadedTrack(312)))) + }, + ) + + assertEquals(false, catalogRead) + assertEquals(listOf(authoritative), result.subtitleTracks) + } + @Test fun `fresh playback hydrates downloads before resolving typed download id`() = runTest { val result = prepareMobileFreshSubtitleRestore( diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt new file mode 100644 index 000000000..417a3fbc2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobilePlayerRouteTargetTest.kt @@ -0,0 +1,344 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.playback.decodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.encodeCatalogSubtitlePreference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MobilePlayerRouteTargetTest { + @Test + fun readyTargetUsesLiveContentFileAudioAndCatalogSubtitleOrdinal() { + val catalogSubtitles = listOf( + SubtitleTrack(index = 10, language = "en", title = "English", external = true), + SubtitleTrack(index = 20, language = "fr", title = "French", external = true), + ) + val version = FileVersion( + fileId = 222, + audioTracks = listOf( + AudioTrack(index = 3, language = "en"), + AudioTrack(index = 7, language = "fr"), + ), + subtitleTracks = catalogSubtitles, + ) + val state = PlayerViewModel.PlayerUiState( + isLoading = false, + contentId = "episode-2", + streamUrl = "https://example.test/video.m3u8", + versions = listOf(version), + selectedVersionIndex = 0, + audioTracks = version.audioTracks.orEmpty(), + selectedAudioIndex = 1, + // Mounted order differs from the catalog order. Route values are + // catalog ordinals, so selectedSubtitleIndex must not be compared. + subtitleTracks = listOf( + PlayerSubtitleInfo(index = 1, language = "fr", label = "French", url = "fr.vtt"), + PlayerSubtitleInfo(index = 0, language = "en", label = "English", url = "en.vtt"), + ), + selectedSubtitleIndex = 1, + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 0), + position = 95.0, + ) + + val target = requireNotNull( + mobilePlayerRouteTarget( + intent = MobilePlayerRouteIntent( + contentId = "episode-2", + resumePositionSeconds = 42.0, + ), + state = state, + ), + ) + + assertEquals("episode-2", target.contentId) + assertEquals(222, target.fileId) + assertEquals(1, target.audioTrackIndex) + assertEquals(0, target.subtitleTrackIndex) + assertEquals(42.0, target.resumePositionSeconds) + } + + @Test + fun pendingLoadUsesNewIntentInsteadOfStaleMountedState() { + val intent = MobilePlayerRouteIntent( + contentId = "episode-2", + fileId = 222, + quality = "720p", + audioTrackIndex = 1, + subtitleTrackIndex = -1, + resumePositionSeconds = 42.0, + ) + val target = requireNotNull( + mobilePlayerRouteTarget( + intent = intent, + state = PlayerViewModel.PlayerUiState( + isLoading = true, + contentId = "episode-2", + streamUrl = "https://example.test/stale-video.m3u8", + versions = listOf(FileVersion(fileId = 111)), + ), + ), + ) + + assertEquals(222, target.fileId) + assertEquals("720p", target.quality) + assertEquals(1, target.audioTrackIndex) + assertEquals(-1, target.subtitleTrackIndex) + assertEquals(42.0, target.resumePositionSeconds) + } + + @Test + fun catalogSubtitleOrdinalUsesStableIdentityAfterCatalogReorder() { + val originalCatalog = listOf( + SubtitleTrack( + index = 10, + language = "en", + title = "English", + codec = "srt", + external = true, + ), + SubtitleTrack( + index = 20, + language = "fr", + title = "French", + codec = "srt", + external = true, + ), + ) + val french = requireNotNull( + decodeSubtitleIdentityPreference( + encodeCatalogSubtitlePreference(originalCatalog, selectedOrdinal = 1), + ), + ) + val state = PlayerViewModel.PlayerUiState( + versions = listOf( + FileVersion( + fileId = 222, + subtitleTracks = listOf( + SubtitleTrack( + index = 20, + language = "fr", + title = "French", + codec = "srt", + external = true, + ), + SubtitleTrack( + index = 10, + language = "en", + title = "English", + codec = "srt", + external = true, + ), + ), + ), + ), + selectedVersionIndex = 0, + ) + + assertEquals(0, catalogSubtitleRouteOrdinal(state, french)) + assertEquals(-1, catalogSubtitleRouteOrdinal(state, SubtitleIdentity.Off)) + assertNull( + catalogSubtitleRouteOrdinal( + state, + SubtitleIdentity.Downloaded( + downloadId = 99, + media = SubtitleMediaIdentity(label = "French", language = "fr"), + ), + ), + ) + } + + @Test + fun versionSelectionChangesOnlyFileIntentAndRecoveryRestoresPriorIntent() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = 42.0, + preserveCurrent = false, + ) + val initial = requireNotNull(state.current) + + state.beginVersionSelection(contentId = "movie-1", fileId = 222) + + val switched = requireNotNull(state.current) + assertEquals(222, switched.fileId) + assertTrue(switched.fileIsExplicit) + assertFalse(switched.qualityIsExplicit) + assertFalse(switched.audioTrackIsExplicit) + assertFalse(switched.subtitleTrackIsExplicit) + assertEquals(42.0, switched.resumePositionSeconds) + + state.recoverVersionSelection(contentId = "movie-1") + assertEquals(initial, state.current) + } + + @Test + fun preservedExplicitQualityIsReappliedToTheActualLoad() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = "720p", + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + state.beginVersionSelection(contentId = "movie-1", fileId = 222) + state.beginLoad( + contentId = "movie-1", + fileId = 222, + quality = null, + audioTrackIndex = 1, + subtitleTrackIndex = -1, + resumePositionSeconds = null, + preserveCurrent = true, + ) + + assertEquals( + "720p", + state.qualityForLoad( + contentId = "movie-1", + normalizedRequestedQuality = null, + preserveCurrent = true, + ), + ) + } + + @Test + fun internalTrackRestoreStaysAutomaticAndUserIntentCommitsOnlyOnSuccess() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "movie-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + val french = SubtitleIdentity.ServerSidecar(serverIndex = 20) + + // A persisted/automatic restore has no staged user provenance. + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 7, + committedSubtitleIdentity = french, + transactionFailed = false, + transactionActive = false, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.beginAudioSelection(contentId = "movie-1", routeOrdinal = 1, serverIndex = 7) + state.beginSubtitleSelection(contentId = "movie-1", routeOrdinal = 0, identity = french) + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 3, + committedSubtitleIdentity = SubtitleIdentity.Off, + transactionFailed = false, + transactionActive = true, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 3, + committedSubtitleIdentity = SubtitleIdentity.Off, + transactionFailed = true, + transactionActive = false, + ) + assertFalse(requireNotNull(state.current).audioTrackIsExplicit) + assertFalse(requireNotNull(state.current).subtitleTrackIsExplicit) + + state.beginAudioSelection(contentId = "movie-1", routeOrdinal = 1, serverIndex = 7) + state.beginSubtitleSelection(contentId = "movie-1", routeOrdinal = 0, identity = french) + state.applyCommittedTracks( + contentId = "movie-1", + committedAudioServerIndex = 7, + committedSubtitleIdentity = french, + transactionFailed = false, + transactionActive = false, + ) + val committed = requireNotNull(state.current) + assertEquals(1, committed.audioTrackIndex) + assertTrue(committed.audioTrackIsExplicit) + assertEquals(0, committed.subtitleTrackIndex) + assertTrue(committed.subtitleTrackIsExplicit) + } + + @Test + fun operationalRestartPositionDoesNotBecomeFreshRouteResumeIntent() { + val state = MobilePlayerRouteIntentState() + state.beginLoad( + contentId = "episode-1", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = 42.0, + preserveCurrent = false, + ) + state.beginLoad( + contentId = "episode-1", + fileId = 111, + quality = null, + audioTrackIndex = 0, + subtitleTrackIndex = -1, + resumePositionSeconds = null, + preserveCurrent = true, + ) + assertEquals(42.0, requireNotNull(state.current).resumePositionSeconds) + + // Auto-advance can operationally start episode 2 at 0 while its route + // remains bare because it supplies no route resume provenance. + state.beginLoad( + contentId = "episode-2", + fileId = null, + quality = null, + audioTrackIndex = null, + subtitleTrackIndex = null, + resumePositionSeconds = null, + preserveCurrent = false, + ) + assertNull(requireNotNull(state.current).resumePositionSeconds) + } + + @Test + fun failedOrExitedPlayerDoesNotClaimAnExternalTarget() { + val intent = MobilePlayerRouteIntent(contentId = "movie-1") + + assertNull( + mobilePlayerRouteTarget( + intent, + PlayerViewModel.PlayerUiState( + isLoading = false, + error = "Playback failed", + contentId = "movie-1", + ), + ), + ) + assertNull( + mobilePlayerRouteTarget( + intent, + PlayerViewModel.PlayerUiState( + isLoading = false, + contentId = "movie-1", + streamUrl = null, + ), + ), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt index 5659d8df1..d878b2c5d 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleAutoSelectionTest.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.android.ui.screens.player import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import kotlin.test.Test import kotlin.test.assertEquals @@ -50,7 +51,7 @@ class MobileSubtitleAutoSelectionTest { val persisted = SubtitleIdentity.Downloaded( downloadId = 312, media = org.prairieserver.prairie.model.playback.SubtitleMediaIdentity( - trackId = "prairie-downloaded-subtitle:312", + trackId = "silo-downloaded-subtitle:312", label = "English", language = "en", codecFamily = "webvtt", @@ -88,6 +89,39 @@ class MobileSubtitleAutoSelectionTest { assertEquals(null, resolveMobileSubtitleOrdinal(persisted, duplicates)) } + @Test + fun legacyDownloadedPreferenceMigratesToAUniqueAuthoritativePlanRow() { + val persisted = SubtitleIdentity.Downloaded( + downloadId = 312, + media = SubtitleMediaIdentity( + trackId = "silo-downloaded-subtitle:312", + label = "English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + val authoritative = subtitle( + index = 4, + label = "English", + language = "en", + codec = "vtt", + forced = false, + ).copy( + source = "downloaded", + downloadId = null, + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ) + + assertEquals(0, resolveMobileSubtitleOrdinal(persisted, listOf(authoritative))) + assertEquals( + "file:22:subtitle:4", + (mobileSubtitleIdentity(authoritative) as SubtitleIdentity.ServerSidecar).media?.trackId, + ) + } + @Test fun genericLabelKeepsHearingImpairedMetadataUnknown() { val identity = mobileSubtitleIdentity( @@ -415,7 +449,7 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Select(1), resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 0, language = "ja")), + audioTracks = listOf(audio(language = "ja")), selectedAudioIndex = 0, subtitles = subtitles, preferredLanguage = "en", @@ -434,7 +468,7 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Select(0), resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 0, language = "ja")), + audioTracks = listOf(audio(language = "ja")), selectedAudioIndex = 0, subtitles = subtitles, preferredLanguage = "en", @@ -454,7 +488,27 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Select(0), resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 0, language = "ja")), + audioTracks = listOf(audio(language = "ja")), + selectedAudioIndex = 0, + subtitles = subtitles, + preferredLanguage = "en", + subtitleMode = "auto", + showForcedSubtitles = true, + ), + ) + } + + @Test + fun autoSubtitlePreferenceDoesNotTreatHindiCodeAsHearingImpaired() { + val subtitles = listOf( + subtitle(index = 4, label = "EN - HI", language = "en"), + subtitle(index = 7, label = "English", language = "en"), + ) + + assertEquals( + MobileSubtitleAutoSelection.Select(0), + resolveMobileAutoSubtitleSelection( + audioTracks = listOf(audio(language = "ja")), selectedAudioIndex = 0, subtitles = subtitles, preferredLanguage = "en", @@ -469,8 +523,11 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Disable, resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 2, language = "eng")), - selectedAudioIndex = 2, + // selectedAudioIndex is an ORDINAL. The wire sends no audio + // index, so a fixture keying on one tested a shape that cannot + // occur; two rows make the ordinal meaningful. + audioTracks = listOf(audio(language = "nld"), audio(language = "eng")), + selectedAudioIndex = 1, subtitles = listOf(subtitle(index = 1, label = "English", language = "en")), preferredLanguage = "en", subtitleMode = "auto", @@ -489,8 +546,11 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Select(1), resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 2, language = "eng")), - selectedAudioIndex = 2, + // selectedAudioIndex is an ORDINAL. The wire sends no audio + // index, so a fixture keying on one tested a shape that cannot + // occur; two rows make the ordinal meaningful. + audioTracks = listOf(audio(language = "nld"), audio(language = "eng")), + selectedAudioIndex = 1, subtitles = subtitles, preferredLanguage = "en", subtitleMode = "auto", @@ -504,7 +564,7 @@ class MobileSubtitleAutoSelectionTest { assertEquals( MobileSubtitleAutoSelection.Select(0), resolveMobileAutoSubtitleSelection( - audioTracks = listOf(audio(index = 0, language = "en")), + audioTracks = listOf(audio(language = "en")), selectedAudioIndex = 0, subtitles = listOf(subtitle(index = 1, label = "English", language = "en")), preferredLanguage = "en", @@ -583,9 +643,8 @@ class MobileSubtitleAutoSelectionTest { ) private fun audio( - index: Int, language: String?, - ): AudioTrack = AudioTrack(index = index, language = language) + ): AudioTrack = AudioTrack(language = language) private fun subtitle( index: Int, diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt index 53c745912..3e66a3d43 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt @@ -63,7 +63,8 @@ class MobileSubtitleTransactionAdapterTest { @Test fun `A remains committed while B stages and commits`() = runTest { - val harness = harness(backgroundScope) + val adoption = AdoptionControl() + val harness = harness(backgroundScope, adoption = adoption) harness.adapter.select(sidecar(4)) runCurrent() @@ -80,6 +81,7 @@ class MobileSubtitleTransactionAdapterTest { assertNull(harness.adapter.snapshot.pendingIdentity) assertEquals(listOf("b"), harness.port.committed) assertEquals(listOf(sidecar(4)), harness.persistence.persisted.map { it.identity }) + assertEquals(listOf(42.0), adoption.requestedSourcePositions) } @Test @@ -148,6 +150,58 @@ class MobileSubtitleTransactionAdapterTest { assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) } + @Test + fun `adapted edition commits returned audio and subtitle identities`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + val adapted = candidate( + id = "adapted", + selectedIndex = 1, + selectedAudioIndex = 5, + effectiveMediaFileId = 22, + selectedSubtitleIdentity = sidecar(1), + ) + assertEquals(sidecar(1), adapted.selectedSubtitleIdentity) + harness.port.completeStage(adapted) + runCurrent() + + assertEquals(listOf("adapted"), harness.port.committed) + assertEquals(1, harness.committedPlaybacks.size) + assertEquals(sidecar(1), harness.adapter.snapshot.committedIdentity) + assertEquals(5, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(22, harness.committedPlaybacks.single().effectiveMediaFileId) + } + + @Test + fun `same file commit preserves the catalog version identity`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage( + candidate( + id = "same-file", + selectedIndex = 4, + sessionId = "s-same-file", + effectiveMediaFileId = 11, + ), + ) + runCurrent() + val owner = harness.adapter.beginRefresh() + + harness.adapter.updatePlaybackContext( + context( + mediaFileId = 11, + versionId = "version-1", + sessionId = "s-same-file", + ), + ) + + assertTrue(harness.adapter.ownsRefresh(owner)) + } + @Test fun `local then audio before mount keeps one client-owned transaction`() = runTest { val downloaded = downloadedIdentity() @@ -192,7 +246,17 @@ class MobileSubtitleTransactionAdapterTest { assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) assertEquals( - listOf(CommittedSubtitle(downloaded, audioTrackIndex = 7, qualityPreference = "auto")), + listOf( + CommittedSubtitle( + downloaded, + audioTrackIndex = 7, + qualityPreference = "auto", + // This scenario changes AUDIO explicitly, which is now + // recorded so a subtitle-only commit cannot be mistaken for + // the viewer choosing the audio it happened to carry. + audioPreferenceSpecified = true, + ), + ), harness.persistence.persisted, ) } @@ -604,6 +668,16 @@ class MobileSubtitleTransactionAdapterTest { ) runCurrent() assertEquals(local, harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "ready-local-restore-miss", + settled = true, + ) + runCurrent() assertNull(harness.adapter.snapshot.localMountIdentity) assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) } @@ -782,7 +856,7 @@ class MobileSubtitleTransactionAdapterTest { } @Test - fun `settled local mount miss rolls back immediately without persistence`() = runTest { + fun `first settled local mount snapshot remains provisional`() = runTest { val harness = harness(backgroundScope) val local = SubtitleIdentity.LocalMedia3( media(label = "English", language = "en", codec = "webvtt"), @@ -797,12 +871,65 @@ class MobileSubtitleTransactionAdapterTest { ) runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertTrue(harness.persistence.persisted.isEmpty()) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "ready-track-catalog", + settled = true, + ) + runCurrent() + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) assertNull(harness.adapter.snapshot.pendingIdentity) assertTrue(harness.persistence.persisted.isEmpty()) assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) } + @Test + fun `changed local mount snapshot must stabilize again before failure`() = runTest { + val harness = harness(backgroundScope) + val local = SubtitleIdentity.LocalMedia3( + media(label = "English", language = "en", codec = "webvtt"), + ) + + harness.adapter.select(local) + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "initial-track-catalog", + settled = true, + ) + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "changed-track-catalog", + settled = true, + ) + runCurrent() + + assertEquals(local, harness.adapter.snapshot.pendingIdentity) + assertEquals(local, harness.adapter.snapshot.localMountIdentity) + assertNull(harness.adapter.snapshot.failureMessage) + + harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "changed-track-catalog", + settled = true, + ) + runCurrent() + + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", ignoreCase = true) == true) + } + @Test fun `repeated and empty local mount snapshots do not exhaust retry bound`() = runTest { val harness = harness(backgroundScope) @@ -1340,6 +1467,7 @@ class MobileSubtitleTransactionAdapterTest { durablePersistenceScope = durablePersistenceScope, onCommittedPlayback = { adoptionRequest -> adoption.started += 1 + adoption.requestedSourcePositions += adoptionRequest.requestedSourcePositionSeconds if (adoption.suspendAdoption) adoption.completions.receive() adoption.failure?.let { throw it } if (!adoptionRequest.isCurrent()) { @@ -1418,6 +1546,7 @@ class MobileSubtitleTransactionAdapterTest { val failure: Throwable? = null, ) { var started: Int = 0 + val requestedSourcePositions = mutableListOf() val completions = Channel(Channel.UNLIMITED) suspend fun complete() { @@ -1457,6 +1586,8 @@ class MobileSubtitleTransactionAdapterTest { mode == PlaybackSubtitleModeV3.CONVERT, sessionId: String = "s-$id", tracks: List = emptyList(), + effectiveMediaFileId: Int? = null, + selectedSubtitleIdentity: SubtitleIdentity? = null, ): MobileStagedSubtitleCandidate = MobileStagedSubtitleCandidate( id = id, sessionId = sessionId, @@ -1465,6 +1596,8 @@ class MobileSubtitleTransactionAdapterTest { subtitleMode = mode, hasSidecar = hasSidecar, subtitleTracks = tracks, + effectiveMediaFileId = effectiveMediaFileId, + selectedSubtitleIdentity = selectedSubtitleIdentity, ) private fun clientOwnedCandidate( @@ -1582,6 +1715,7 @@ class MobileSubtitleTransactionAdapterTest { MobileSubtitleCommittedPlayback( sessionId = candidate.sessionId, subtitleTracks = candidate.subtitleTracks, + effectiveMediaFileId = candidate.effectiveMediaFileId, ), ) } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerBackendLifecycleSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerBackendLifecycleSourceTest.kt new file mode 100644 index 000000000..a34ba3fc3 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerBackendLifecycleSourceTest.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertTrue + +class PlayerBackendLifecycleSourceTest { + private val sourceFile = java.io.File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt", + ) + + @Test + fun backendOwnershipFollowsTheActualPlayerRatherThanPlaybackRouteState() { + val source = sourceFile.readText() + + assertTrue(source.contains("val backendPlayer = sessionPlayer ?: mediaController")) + assertTrue(source.contains("val videoBackend = remember(backendPlayer, backendFactory)")) + } + + @Test + fun subtitleRestorationWaitsForTheMatchingMediaMountGeneration() { + val source = sourceFile.readText() + + assertTrue( + source.contains("mountedMediaGeneration = uiState.mediaMountGeneration"), + "the synchronous Media3 mount must publish the generation it applied", + ) + assertTrue( + source.contains("mountedMediaGeneration != uiState.mediaMountGeneration"), + "subtitle restoration must not run against a predecessor media item", + ) + } + + @Test + fun v3ServerSidecarIsSelectedFromTheExistingMediaMount() { + val source = sourceFile.readText() + + assertTrue( + source.contains("this is SubtitleIdentity.ServerSidecar ||"), + "a planned server sidecar must use stable mounted-track selection", + ) + assertTrue( + source.contains("subtitleIdentity = uiState.localSubtitleMountIdentity"), + "the complete picker inventory must not be passed to Media3", + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicyTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicyTest.kt new file mode 100644 index 000000000..049c9554a --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOrientationPolicyTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlayerOrientationPolicyTest { + @Test + fun android16LargeScreensFollowTheDeviceOrientation() { + assertFalse(supportsPlayerOrientationLock(sdkInt = 36, smallestScreenWidthDp = 600)) + assertFalse(supportsPlayerOrientationLock(sdkInt = 36, smallestScreenWidthDp = 840)) + assertFalse(supportsPlayerOrientationLock(sdkInt = 37, smallestScreenWidthDp = 600)) + } + + @Test + fun phonesAndOlderAndroidReleasesKeepThePlayerLock() { + assertTrue(supportsPlayerOrientationLock(sdkInt = 36, smallestScreenWidthDp = 599)) + assertTrue(supportsPlayerOrientationLock(sdkInt = 35, smallestScreenWidthDp = 840)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBarTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBarTest.kt new file mode 100644 index 000000000..b7ab48ec0 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBarTest.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlayerProgressBarTest { + @Test + fun unknownDurationDoesNotRenderFalseRemainingTime() { + assertEquals("−−:−−", remainingTimeLabel(position = 300.0, duration = 0.0)) + } + + @Test + fun knownDurationRendersRemainingTime() { + assertEquals("−5:00", remainingTimeLabel(position = 300.0, duration = 600.0)) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt new file mode 100644 index 000000000..1094608ef --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlayerVerticalDragModeTest { + @Test + fun `left edge leaves system brightness authoritative`() { + assertEquals( + VerticalDragMode.None, + verticalDragMode(startX = 40f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `right edge retains volume routing`() { + assertEquals( + VerticalDragMode.Volume, + verticalDragMode(startX = 950f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `center retains dismiss routing`() { + assertEquals( + VerticalDragMode.DismissCandidate, + verticalDragMode(startX = 500f, width = 1_000f, edgeZonePx = 88f), + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt index a4818a9e9..5949edcdf 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt @@ -38,6 +38,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.isActive import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -58,10 +62,12 @@ import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.player.AudioCapabilityManager import org.prairieserver.prairie.common.player.FinalPlaybackPositionWriter import org.prairieserver.prairie.common.player.PlaybackAnalyticsListener +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.SleepTimerController +import org.prairieserver.prairie.common.player.StartParams import org.prairieserver.prairie.common.player.VideoSessionStartV3 import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest @@ -69,14 +75,20 @@ import org.prairieserver.prairie.common.player.video.VideoPlaybackStartResult import org.prairieserver.prairie.common.player.video.VideoPlaybackStarter import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.domain.player.IntroAutoSkipController +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.libass.LibassBridge +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.playback.ClientCodecCapabilities +import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDelivery -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol import org.prairieserver.prairie.model.playback.PlaybackStreamV3 +import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 +import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.server.ServerEntry import org.prairieserver.prairie.model.settings.SubtitleAppearance @@ -95,7 +107,11 @@ import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.PlaybackRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SubtitlesRepository +import org.prairieserver.prairie.repository.port.LocalTrackSelection import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort +import org.prairieserver.prairie.repository.port.UserItemStatePort +import org.prairieserver.prairie.playback.audioTrackFingerprint +import org.prairieserver.prairie.playback.encodeCatalogSubtitlePreference @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -104,7 +120,14 @@ class PlayerViewModelLoadOwnershipIntegrationTest { @get:Rule val tmp = TemporaryFolder() - private val dispatcher = UnconfinedTestDispatcher() + // StandardTestDispatcher, NOT Unconfined. Unconfined resumes continuations + // inline on whichever thread completed the suspending call, and reentrant + // resumptions land in that thread's internal unconfined event loop — a queue + // the test scheduler cannot reach. Waiting for such a continuation from + // another dispatcher was a genuine race: measured 2 failures in 6 idle runs. + // A standard dispatcher gives every continuation an explicit scheduler queue + // that `runTest` drains while the test body is suspended. + private val dispatcher = StandardTestDispatcher() private lateinit var db: PrairieDatabase @BeforeTest @@ -206,7 +229,13 @@ class PlayerViewModelLoadOwnershipIntegrationTest { message = "stale failure", ), ) - fixture.viewModel.awaitState { it.sessionId == "new-session" } + // Drain, do not wait on a predicate. `awaitState { sessionId == + // "new-session" }` was already true the moment it was called, so it + // returned without the stale error having been handled at all — the + // assertions below then proved nothing. Draining the scheduler makes + // "the stale error was processed AND still did not overwrite" the + // thing actually under test. + advanceUntilIdle() val state = fixture.viewModel.uiState.value assertEquals("new", state.contentId) @@ -274,6 +303,7 @@ class PlayerViewModelLoadOwnershipIntegrationTest { context, AudioCapabilityManager(context), LibassBridge(false), + PrairieClientBuildIdentity(buildNumber = "5", channel = "release"), ) return PlayerFixture( viewModel = PlayerViewModel( @@ -295,7 +325,6 @@ class PlayerViewModelLoadOwnershipIntegrationTest { introAutoSkipController = IntroAutoSkipController(scope), sessionLifecycle = PlaybackSessionLifecycle( manager, - profileRepository, healthApi, personalDataRepository, scope, @@ -369,11 +398,11 @@ class MobileVideoPlaybackStarterCancellationTest { context, AudioCapabilityManager(context), LibassBridge(false), + PrairieClientBuildIdentity(buildNumber = "5", channel = "release"), ), playerSettingsStore = FakePlayerSettingsStore(), sessionLifecycle = PlaybackSessionLifecycle( manager, - profileRepository, HealthApi(client), PersonalDataRepository(PersonalDataApi(client)), backgroundScope, @@ -453,6 +482,259 @@ class MobileVideoPlaybackStarterCancellationTest { } } +/** + * The phone starter must take its subtitle preferences from the server's + * resolved `effective_*` fields, the way TvVideoPlaybackStarter does. + * + * The settings screens write these preferences canonically now + * (`PUT /settings/values/{key}?scope=profile`); nothing on the server mirrors + * a canonical write back into `user_profiles`, so the profile columns + * `GET /profiles` serves are stale from the first edit. Reading them here is + * how the same profile ends up auto-selecting a different subtitle track on + * the phone than on the TV. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class MobileVideoPlaybackStarterSubtitlePreferenceTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun serverResolvedSubtitlePreferencesWinOverTheStaleProfileColumns() = runTest(dispatcher) { + val ready = start( + effective = """ + "effective_subtitle_language": "ja", + "effective_subtitle_mode": "always", + "effective_show_forced_subtitles": false, + """.trimIndent(), + // What GET /profiles still serves after a canonical-only write. + profile = Profile( + id = PROFILE_ID, + name = "Profile", + subtitleLanguage = "en", + subtitleMode = "off", + showForcedSubtitles = true, + ), + ) + + assertEquals("ja", ready.preferredTextLanguage) + assertEquals("always", ready.preferredSubtitleMode) + assertFalse(ready.showForcedSubtitles) + } + + @Test + fun profileColumnsRemainTheFallbackWhenTheServerSendsNoResolvedValues() = + runTest(dispatcher) { + val ready = start( + effective = "", + profile = Profile( + id = PROFILE_ID, + name = "Profile", + subtitleLanguage = "de", + subtitleMode = "always", + showForcedSubtitles = false, + ), + ) + + assertEquals("de", ready.preferredTextLanguage) + assertEquals("always", ready.preferredSubtitleMode) + assertFalse(ready.showForcedSubtitles) + } + + @Test + fun persistedPerFileTracksAreIncludedInTheInitialV3Allocation() = runTest(dispatcher) { + val audioTracks = listOf( + AudioTrack(codec = "aac", language = "en", title = "Stereo"), + AudioTrack(codec = "truehd", language = "en", title = "Atmos"), + ) + // Catalog order intentionally differs from the server's combined + // external-then-embedded subtitle index space. English is catalog + // ordinal 0 but combined index 1. + val subtitleTracks = listOf( + SubtitleTrack(index = 12, codec = "subrip", language = "en", title = "English"), + SubtitleTrack(index = 0, codec = "srt", language = "fr", title = "French", external = true), + ) + val persisted = LocalTrackSelection( + audioFingerprint = audioTrackFingerprint(audioTracks[1]), + subtitleFingerprint = encodeCatalogSubtitlePreference(subtitleTracks, 0), + ) + val localState = object : UserItemStatePort by NoOpUserItemStatePort { + override suspend fun localTrackSelection(contentId: String, fileId: Int) = persisted + } + var allocation: MobileVideoSessionAllocation? = null + + start( + effective = "", + profile = Profile(id = PROFILE_ID, name = "Profile"), + versionFields = """ + "audio_tracks": [ + {"codec":"aac","language":"en","title":"Stereo"}, + {"codec":"truehd","language":"en","title":"Atmos"} + ], + "subtitle_tracks": [ + {"index":12,"codec":"subrip","language":"en","title":"English","external":false}, + {"index":0,"codec":"srt","language":"fr","title":"French","external":true} + ] + """.trimIndent(), + userItemStatePort = localState, + onAllocation = { allocation = it }, + ) + + assertEquals(1, allocation?.audioTrackIndex) + assertEquals(1, allocation?.subtitleTrackIndex) + } + + @Test + fun explicitPlaybackSubtitleIndexWinsAndPassesThroughUnchanged() = runTest(dispatcher) { + var allocation: MobileVideoSessionAllocation? = null + + start( + effective = "", + profile = Profile(id = PROFILE_ID, name = "Profile"), + versionFields = """ + "subtitle_tracks": [ + {"index":12,"codec":"subrip","language":"en","title":"English","external":false}, + {"index":0,"codec":"srt","language":"fr","title":"French","external":true} + ] + """.trimIndent(), + explicitSubtitleTrackIndex = 1, + onAllocation = { allocation = it }, + ) + + assertEquals(1, allocation?.subtitleTrackIndex) + } + + @Test + fun serverSelectedSubtitleIndexIsRetainedForSessionRenewal() = runTest(dispatcher) { + var adoptedParams: StartParams? = null + + start( + effective = "", + profile = Profile(id = PROFILE_ID, name = "Profile"), + readyStart = allocatedReady("subtitle-session", selectedSubtitleIndex = 4), + onAdoption = { adoptedParams = it }, + ) + + assertEquals(4, adoptedParams?.subtitleTrackIndex) + } + + @Test + fun unknownSourceDurationStaysUnknownAtTheStarterBoundary() = runTest(dispatcher) { + val ready = start( + effective = "", + profile = Profile(id = PROFILE_ID, name = "Profile"), + ) + + assertNull(ready.durationSeconds) + } + + private suspend fun TestScope.start( + effective: String, + profile: Profile, + versionFields: String = "", + explicitSubtitleTrackIndex: Int? = null, + userItemStatePort: UserItemStatePort = NoOpUserItemStatePort, + onAllocation: (MobileVideoSessionAllocation) -> Unit = {}, + readyStart: VideoSessionStartV3.Ready = allocatedReady("subtitle-session"), + onAdoption: (StartParams) -> Unit = {}, + ): VideoPlaybackStartResult.Ready { + val client = catalogClient(effective, versionFields) + val tokenManager = FakeTokenManager() + val profileRepository = FakeProfileRepository(client, tokenManager, profile) + val manager = RecordingPlaybackSessionManager(client, tokenManager) + val context = ApplicationProvider.getApplicationContext() + val starter = MobileVideoPlaybackStarter( + catalogRepository = CatalogRepository(CatalogApi(client)), + playbackSessionManager = manager, + profileRepository = profileRepository, + capabilityDetector = PlaybackCapabilityDetector( + context, + AudioCapabilityManager(context), + LibassBridge(false), + PrairieClientBuildIdentity(buildNumber = "5", channel = "release"), + ), + playerSettingsStore = FakePlayerSettingsStore(), + sessionLifecycle = PlaybackSessionLifecycle( + manager, + HealthApi(client), + PersonalDataRepository(PersonalDataApi(client)), + backgroundScope, + ), + reachabilityMonitor = ServerReachabilityMonitor(HealthApi(client), backgroundScope), + userItemStatePort = userItemStatePort, + sessionAllocator = { + onAllocation(it) + ApiResult.Success(readyStart) + }, + sessionAdopter = { params, _ -> onAdoption(params) }, + ) + + val result = starter.start( + VideoPlaybackStartRequest( + contentId = "starter", + preferredFileId = 41, + roomId = null, + resumePositionOverride = null, + subtitleTrackIndex = explicitSubtitleTrackIndex, + ), + ) + assertTrue(result is VideoPlaybackStartResult.Ready, "expected a ready start, got $result") + return result + } + + private fun catalogClient(effective: String, versionFields: String): HttpClient { + val extraVersionFields = versionFields + .trim() + .takeIf(String::isNotEmpty) + ?.let { ",\n$it" } + .orEmpty() + return HttpClient( + MockEngine { request -> + if (request.url.encodedPath == "/api/v1/watch/starter") { + respond( + content = """ + { + "content_id": "starter", + "type": "movie", + "title": "Starter", + $effective + "versions": [ + { + "file_id": 41, + "container": "mkv", + "duration": 120.0 + $extraVersionFields + } + ] + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + } else { + respond( + content = """{"error":"not_found","message":"not found"}""", + status = HttpStatusCode.NotFound, + headers = JSON_HEADERS, + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + } +} + private class DeferredNonCooperativeStarter : VideoPlaybackStarter { private data class Pending( val request: VideoPlaybackStartRequest, @@ -462,10 +744,14 @@ private class DeferredNonCooperativeStarter : VideoPlaybackStarter { private val pending = mutableListOf() + /** Replayable so a request that lands before the wait begins is still seen. */ + private val requestCount = MutableStateFlow(0) + override suspend fun start(request: VideoPlaybackStartRequest): VideoPlaybackStartResult = suspendCoroutine { continuation -> - synchronized(pending) { + requestCount.value = synchronized(pending) { pending += Pending(request, continuation) + pending.size } } @@ -483,9 +769,7 @@ private class DeferredNonCooperativeStarter : VideoPlaybackStarter { } suspend fun awaitRequestCount(count: Int) { - awaitCondition { - synchronized(pending) { pending.size >= count } - } + awaitRealTime { requestCount.first { it >= count } } } } @@ -498,6 +782,7 @@ private class RecordingPlaybackSessionManager( ) { private val stopped = mutableListOf() private val stopActiveContexts = mutableListOf() + private val stoppedSignal = MutableStateFlow>(emptySet()) val stoppedSessions: List get() = synchronized(stopped) { stopped.toList() } @@ -511,22 +796,23 @@ private class RecordingPlaybackSessionManager( stopped += sessionId stopActiveContexts += contextActive } + stoppedSignal.update { it + sessionId } return ApiResult.Success(Unit) } suspend fun awaitStopped(sessionId: String) { - awaitCondition { sessionId in stoppedSessions } + awaitRealTime { stoppedSignal.first { sessionId in it } } } } private class FakeProfileRepository( client: HttpClient, tokenManager: TokenManager, + private val profile: Profile = Profile(id = PROFILE_ID, name = "Profile"), ) : ProfileRepository(ProfileApi(client), tokenManager) { override suspend fun getActiveProfileId(): String = PROFILE_ID - override suspend fun listProfiles(): ApiResult> = - ApiResult.Success(listOf(Profile(id = PROFILE_ID, name = "Profile"))) + override suspend fun listProfiles(): ApiResult> = ApiResult.Success(listOf(profile)) } private class FakeTokenManager : TokenManager { @@ -540,7 +826,7 @@ private class FakeTokenManager : TokenManager { override suspend fun setProfileId(profileId: String?) = Unit override suspend fun getProfileToken(): String? = null override suspend fun setProfileToken(token: String?) = Unit - override suspend fun getServerUrl(): String = "https://prairie.test" + override suspend fun getServerUrl(): String = "https://silo.test" override suspend fun setServerUrl(url: String) = Unit override suspend fun getCurrentServerId(): String = SERVER_ID override suspend fun switchActiveServer(serverId: String?) = Unit @@ -562,7 +848,7 @@ private class FakeServerRegistry : ServerRegistry { } private class FakePlayerSettingsStore : PlayerSettingsStore { - override val autoSkipIntroFlow: Flow = flowOf(false) + override val introSkipModeFlow: Flow = flowOf(IntroSkipMode.ASK) override val autoSkipCreditsFlow: Flow = flowOf(false) override val autoPlayNextFlow: Flow = flowOf(true) override val hdrEnabledFlow: Flow = flowOf(true) @@ -576,12 +862,12 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val playbackSpeedFlow: Flow = flowOf(1.0) override val audioSyncMsFlow: Flow = flowOf(0) override val subtitleSyncMsFlow: Flow = flowOf(0) - override fun subtitleSyncMsFor(contentId: String?): Flow = subtitleSyncMsFlow override val nextUpPromptSecondsFlow: Flow = flowOf(30) override val sleepTimerDefaultMinutesFlow: Flow = flowOf(30) override val resumeRewindSecondsFlow: Flow = flowOf(7) override val passOutThresholdFlow: Flow = flowOf(3) override val preferredQualityFlow: Flow = flowOf("auto") + override val maxBitrateKbpsFlow: Flow = flowOf(null) override val audioLanguageFlow: Flow = flowOf("") override val videoGravityFlow: Flow = flowOf("fit") override val orientationModeFlow: Flow = flowOf("auto") @@ -594,7 +880,7 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override val effectiveSubtitleAppearanceFlow: Flow = flowOf(SubtitleAppearance.DEFAULT) - override suspend fun setAutoSkipIntro(value: Boolean) = Unit + override suspend fun setIntroSkipMode(value: IntroSkipMode) = Unit override suspend fun setAutoSkipCredits(value: Boolean) = Unit override suspend fun setAutoPlayNext(value: Boolean) = Unit override suspend fun setHdrEnabled(value: Boolean) = Unit @@ -608,16 +894,17 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun setPlaybackSpeed(value: Double) = Unit override suspend fun setAudioSyncMs(value: Int) = Unit override suspend fun setSubtitleSyncMs(value: Int) = Unit - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit override suspend fun setNextUpPromptSeconds(value: Int) = Unit override suspend fun setSleepTimerDefaultMinutes(value: Int) = Unit override suspend fun setResumeRewindSeconds(value: Int) = Unit override suspend fun setPassOutThreshold(value: Int) = Unit override suspend fun setPreferredQuality(value: String) = Unit + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) = Unit override suspend fun setAudioLanguage(value: String) = Unit override suspend fun setVideoGravity(value: String) = Unit override suspend fun setOrientationMode(value: String) = Unit override suspend fun setSubtitleAppearance(value: SubtitleAppearance) = Unit + override suspend fun flushProjectedSubtitleAppearance() = Unit override suspend fun refreshFromServer() = Unit override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) = Unit override suspend fun setSubtitleMatchesDevice(enabled: Boolean) = Unit @@ -627,19 +914,26 @@ private class FakePlayerSettingsStore : PlayerSettingsStore { override suspend fun flushPendingDeviceSettings() = Unit } -private fun allocatedReady(sessionId: String): VideoSessionStartV3.Ready { +private fun allocatedReady( + sessionId: String, + selectedSubtitleIndex: Int? = null, +): VideoSessionStartV3.Ready { + val selectedSubtitle = selectedSubtitleIndex?.let { index -> + PlaybackTrackIdentityV3("file:41:subtitle:$index", index) + } val plan = PlaybackPlanV3( planId = "plan-$sessionId", + planAttemptKey = "v3:test:$sessionId", sessionId = sessionId, delivery = PlaybackDelivery.ORIGINAL_HTTP, - engine = PlaybackEngineKind.MEDIA3_DIRECT, stream = PlaybackStreamV3( - url = "https://prairie.test/stream/$sessionId", + url = "https://silo.test/stream/$sessionId", protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, container = "mkv", ), decisionReason = "test", effectiveMediaFileId = 41, + selectedTracks = SelectedPlaybackTracksV3(subtitle = selectedSubtitle), ) return VideoSessionStartV3.Ready( session = PlaybackSessionResponse( @@ -654,6 +948,8 @@ private fun allocatedReady(sessionId: String): VideoSessionStartV3.Ready { playbackAttemptId = "playback-attempt", planAttemptId = "plan-attempt", planAttemptKey = "plan-key", + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), ) } @@ -673,18 +969,34 @@ private fun noOpClient(): HttpClient = private suspend fun PlayerViewModel.awaitState( predicate: (PlayerViewModel.PlayerUiState) -> Boolean, ) { - awaitCondition { predicate(uiState.value) } + awaitRealTime { uiState.first(predicate) } } -private suspend fun awaitCondition(predicate: () -> Boolean) { - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(5_000) { - while (!predicate()) { - delay(5) - } - } +/** + * Runs [block] under a REAL, generous deadline. + * + * The deadline has to be real: this load path does unavoidable work on + * `Dispatchers.IO` before it ever reaches the fake starter — the offline + * preflight in `PlayerViewModel.tryLocalPlayback` and, beneath it, + * `LegacyDownloadImporter` both hard-code that dispatcher — and virtual time + * cannot advance a real thread. A purely virtual timeout raced straight past + * that work and failed every test in this class. + * + * What must NOT come back is polling. Waiters here suspend on a signal, so a + * result that arrives before the wait begins is still seen, and a waiter can no + * longer give up on work that simply had not been dispatched yet. + */ +private suspend fun awaitRealTime(block: suspend () -> T): T = + withContext(Dispatchers.Default) { + // The deadline exists to turn a hang into a failure, not to police + // latency — a passing test signals in milliseconds and never waits. + // Five seconds was tight enough that a full-suite run, with dozens of + // Robolectric classes competing for the same JVM, could blow it while + // the work was merely slow. That looked exactly like the race this + // helper was written to remove, which is worse than useless. + withTimeout(30_000) { block() } } -} + private const val SERVER_ID = "server" private const val PROFILE_ID = "profile" diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt new file mode 100644 index 000000000..3a4bcb469 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class RoomDeliveryKeySourceTest { + private val reportingStartAnchor = "// Drift reporting loop" + private val reportingEndAnchor = "// ready / buffering during the waiting barrier." + + private val controllerSource = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt", + ).readText() + + @Test + fun stateReportRequiresAnExplicitNonNullDeliveryKey() { + val reportingBlock = reportingBlock(controllerSource) + val guardIndex = reportingBlock.indexOf("deliveryKey != null") + val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") + + assertFalse(reportingBlock.contains("deliveryKey!!")) + assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") + assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") + assertTrue(guardIndex < stateReportIndex) + } + + @Test + fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { + assertFailsWith { + reportingBlock(controllerSource.replace(reportingStartAnchor, "")) + } + assertFailsWith { + reportingBlock(controllerSource.replace(reportingEndAnchor, "")) + } + } + + private fun reportingBlock(source: String): String { + val startAnchorIndex = source.indexOf(reportingStartAnchor) + assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") + val blockStart = startAnchorIndex + reportingStartAnchor.length + val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) + assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") + return source.substring(blockStart, endAnchorIndex) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt new file mode 100644 index 000000000..6a20f2369 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class SubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryAnchor = "PlayerView(ctx).apply {" + val updateAnchor = "update = { view ->" + val endAnchor = "modifier = Modifier" + val factoryIndex = source.indexOf(factoryAnchor) + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf(updateAnchor, factoryIndex) + require(updateIndex > factoryIndex) { "PlayerView update lambda is missing or misordered" } + val endIndex = source.indexOf(endAnchor, updateIndex) + require(endIndex > updateIndex) { "PlayerView update lambda terminator is missing or misordered" } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterResizeModeUpdate() { + val source = source( + "org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + assertTrue(update.contains("view.resizeMode = resizeMode")) + assertTrue(update.contains("subtitleManager.syncSubtitleVideoBounds(view)")) + assertTrue( + update.indexOf("view.resizeMode = resizeMode") < + update.indexOf("subtitleManager.syncSubtitleVideoBounds(view)") + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt new file mode 100644 index 000000000..954db2f14 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.android.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class SubtitleAspectSyncWiringTest { + @Test + fun phoneSubtitleManagerUsesPhonePresentation() { + val source = source("org/prairieserver/prairie/android/di/AndroidModule.kt") + + assertTrue(source.contains("SubtitleManager(\n libassBridge = get(),\n presentation = AndroidSubtitlePresentation.Phone,")) + } + + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleTrackSelectionTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleTrackSelectionTest.kt index c529a1b30..d062a9bf2 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleTrackSelectionTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleTrackSelectionTest.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.android.ui.screens.player +import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.subtitles.DownloadedSubtitle import kotlin.test.Test @@ -63,4 +64,20 @@ class SubtitleTrackSelectionTest { assertEquals(-1, selectedServerSubtitleTrackIndex(-1, tracks)) assertNull(selectedServerSubtitleTrackIndex(2, tracks)) } + + @Test + fun missingSessionRenewalPreservesAuthoritativeDownloadedInventorySelection() { + val mounted = listOf( + track(0), + track(1), + track(4, "downloaded"), + ) + assertEquals( + 2, + authoritativePlaybackSubtitleOrdinal( + serverIndex = 4, + playbackTracks = mounted, + ), + ) + } } diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt new file mode 100644 index 000000000..23e171602 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/AvatarOptionsTest.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AvatarOptionsTest { + @Test + fun builtInAvatarsUseServerAcceptedDiceBearReferences() { + assertEquals(20, AvatarOptions.presets.distinct().size) + AvatarOptions.presets.forEach { avatarRef -> + val parts = avatarRef.split(':', limit = 4) + assertEquals(listOf("preset", "dicebear", "fun-emoji"), parts.take(3)) + assertTrue(parts[3].matches(Regex("[A-Za-z0-9-]{1,64}"))) + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionGridScopeTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionGridScopeTest.kt new file mode 100644 index 000000000..07a520fe1 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/profiles/ProfileSelectionGridScopeTest.kt @@ -0,0 +1,152 @@ +package org.prairieserver.prairie.android.ui.screens.profiles + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.ProfileIdentity +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.repository.ProfileRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileSelectionGridScopeTest { + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `failed reload under a new scope does not qualify the old grid as new`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopeTokenManager(barrier) + val oldProfile = Profile(id = "old-profile", name = "Old profile") + val repository = QueueProfileRepository( + tokenManager = tokens, + barrier = barrier, + results = ArrayDeque( + listOf( + ApiResult.Success(listOf(oldProfile)), + ApiResult.Error(500, "failed", "Reload failed"), + ApiResult.Error(500, "failed", "Reload still failed"), + ), + ), + ) + val viewModel = ProfileSelectionViewModel(repository) + advanceUntilIdle() + + // Establish the init load before changing identity. Otherwise the + // Unconfined dispatcher can let the init response observe the switch + // below and correctly reject what the test intended as its old grid. + assertEquals(listOf(oldProfile), viewModel.uiState.value.profiles) + + barrier.changing(IdentityTransitionKind.SERVER_SWITCH) { + tokens.serverId = "server-b" + } + viewModel.loadProfiles() + advanceUntilIdle() + + // A failed reload leaves the previous grid visible, but must not move + // that grid's scope to server-b. + assertEquals(listOf(oldProfile), viewModel.uiState.value.profiles) + + viewModel.onProfileTapped(oldProfile) + advanceUntilIdle() + + // The retained card is still qualified by server-a. Selecting it under + // server-b therefore fails closed and drops the now-stale grid. + assertEquals(emptyList(), viewModel.uiState.value.profiles) + assertEquals(emptyList(), tokens.committedProfiles) + assertNull(viewModel.uiState.value.selectedProfileId) + } + + @Test + fun `tap dispatched after a scope mismatch cannot select from the cleared grid`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val tokens = ScopeTokenManager(barrier) + val oldProfile = Profile(id = "old-profile", name = "Old profile") + val repository = QueueProfileRepository( + tokenManager = tokens, + barrier = barrier, + results = ArrayDeque( + listOf( + ApiResult.Success(listOf(oldProfile)), + ApiResult.Success(emptyList()), + ), + ), + beforeResult = { call -> + if (call == 2) { + barrier.changing(IdentityTransitionKind.SERVER_SWITCH) { + tokens.serverId = "server-b" + } + } + }, + ) + val viewModel = ProfileSelectionViewModel(repository) + + viewModel.loadProfiles() + viewModel.onProfileTapped(oldProfile) + + assertEquals(emptyList(), viewModel.uiState.value.profiles) + assertEquals(emptyList(), tokens.committedProfiles) + } +} + +private class QueueProfileRepository( + tokenManager: TokenManager, + barrier: DefaultIdentityTransitionBarrier, + private val results: ArrayDeque>>, + private val beforeResult: suspend (Int) -> Unit = {}, +) : ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") })), + tokenManager = tokenManager, + identityTransitions = barrier, +) { + private var calls = 0 + + override suspend fun listProfiles(): ApiResult> { + beforeResult(++calls) + return results.removeFirst() + } + + override suspend fun getActiveProfileId(): String? = null +} + +private class ScopeTokenManager( + private val barrier: DefaultIdentityTransitionBarrier, +) : TokenManager by TokenManagerImpl(barrier) { + var serverId: String = "server-a" + val committedProfiles = mutableListOf() + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = AuthScopeSnapshot( + serverId = serverId, + profileId = null, + serverUrl = "https://$serverId", + profileToken = null, + identityGeneration = barrier.generation.value, + ) + + override suspend fun getCurrentServerId(): String = serverId + + override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) { + committedProfiles += ProfileIdentity(profileId, profileToken) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/ReaderViewModelReaderTargetSourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/ReaderViewModelReaderTargetSourceTest.kt index 70f45e6de..5de81c99b 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/ReaderViewModelReaderTargetSourceTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/reader/ReaderViewModelReaderTargetSourceTest.kt @@ -300,8 +300,8 @@ class ReaderViewModelReaderTargetSourceTest { ) private suspend fun ReaderViewModel.awaitLoaded(): ReaderUiState { - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(5_000) { + withContext(Dispatchers.IO) { + withTimeout(AWAIT_POLL_TIMEOUT_MS) { while (uiState.value.isLoading) { delay(10) } @@ -311,8 +311,8 @@ class ReaderViewModelReaderTargetSourceTest { } private suspend fun ReaderViewModel.awaitSyncIdle(): ReaderUiState { - withContext(Dispatchers.Default.limitedParallelism(1)) { - withTimeout(5_000) { + withContext(Dispatchers.IO) { + withTimeout(AWAIT_POLL_TIMEOUT_MS) { while (uiState.value.isSyncing) { delay(10) } @@ -475,3 +475,13 @@ class ReaderViewModelReaderTargetSourceTest { } } } + +/** + * Wall-clock backstop for the polling waits above. + * + * It exists to turn a hang into a failure, not to assert latency: a passing + * test settles in milliseconds. Short deadlines here failed on a loaded CI + * runner while the work was merely slow, which looks exactly like the race the + * wait was written to catch. + */ +private const val AWAIT_POLL_TIMEOUT_MS = 30_000L diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt index 5e8ac5f2a..47841dd1b 100644 --- a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryDestinationTest.kt @@ -14,7 +14,7 @@ import org.robolectric.annotation.Config // Asserts on real Route.route strings, which call android.net.Uri.encode — // Robolectric provides the real Android impl under plain JVM unit tests. -// Pinned to SDK 34 (the project targetSdk 35 is newer than this Robolectric +// Pinned to SDK 34 (the project targetSdk 36 is newer than this Robolectric // release ships an emulated runtime for). @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt new file mode 100644 index 000000000..36e5c9b1e --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt @@ -0,0 +1,145 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchTogetherEntryViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.hostEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + } + + @Test + fun resumeUsesCurrentRoomWithoutCreateOrJoin() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun identityClearRemovesResumeState() = runTest(dispatcher) { + val gateway = FakeGateway(RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby)) + val viewModel = WatchTogetherEntryViewModel(gateway) + + gateway.roomSnapshot.value = null + + assertNull(viewModel.currentRoom.value) + } + + @Test + fun titleHostStillSetsTheSelectedTitle() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.host(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + @Test + fun joinByCodeTrimsAndUsesExistingErrorMapping() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.joinByCode(" ABCD1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeDestination() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinByCode("EFGH5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt new file mode 100644 index 000000000..16a9f3260 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt @@ -0,0 +1,40 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt", + ).readText() + private val movieDetail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + private val seriesDetail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeriesDetailContent.kt", + ).readText() + + @Test + fun ordinaryBackBrowsesWithoutLeavingAndLeaveIsExplicit() { + val navigationIcon = lobby.substringAfter("navigationIcon = {").substringBefore("actions = {") + assertTrue(navigationIcon.contains("onClick = onBack")) + assertFalse(navigationIcon.contains("viewModel.leave()")) + assertTrue(lobby.contains("Text(\"Leave room\")")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun ownerControlsAndTitleSuggestionRemainReachable() { + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(lobby.contains("viewModel.closeRoom()")) + assertTrue(movieDetail.contains("Suggest to Watch Together")) + assertTrue(seriesDetail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt new file mode 100644 index 000000000..69f78ce0d --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyErrorTest.kt @@ -0,0 +1,177 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class WatchTogetherLobbyErrorTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { + val api = FailingLobbyApi() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val viewModel = WatchTogetherLobbyViewModel("room-1", repository, roomSession) + val messages = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.errors.toList(messages) + } + + viewModel.vote("suggestion-1") + viewModel.promote("suggestion-1") + repository.reportDeliveryFailure("Socket request rejected") + runCurrent() + + assertEquals( + listOf( + "Voting is disabled", + "Only the host can promote", + "Socket request rejected", + ), + messages, + ) + collector.cancel() + } + + @Test + fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { + val repository = WatchTogetherRepository( + api = FailingLobbyApi(), + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val viewModel = SuggestToRoomViewModel(repository) + + viewModel.suggest("movie-1", "movie", "Movie One", null, null) + runCurrent() + + assertEquals("Suggestions are locked", viewModel.uiState.value.error) + viewModel.clearError() + assertEquals(null, viewModel.uiState.value.error) + } + + private class FailingLobbyApi : WatchTogetherApi { + private val roomResponse = + ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse + override suspend fun setSelection( + roomId: String, + roomToken: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun updatePolicy( + roomId: String, + roomToken: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit) + + override suspend fun listSuggestions( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") + + override suspend fun deleteSuggestion( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "voting_disabled", "Voting is disabled") + + override suspend fun unvote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(403, "host_required", "Only the host can promote") + } + + private companion object { + val AUTH_SCOPE = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = "https://example.test", + profileToken = "profile-token", + identityGeneration = 1L, + ) + } +} diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt new file mode 100644 index 000000000..94a3495a2 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherMenuEntrySourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val topBar = source("org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt") + private val topBarActions = source("org/prairieserver/prairie/android/ui/components/TopBarActions.kt") + private val home = source("org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt") + private val libraries = source("org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt") + private val main = source("org/prairieserver/prairie/android/ui/screens/MainScreen.kt") + private val profileMenu = source("org/prairieserver/prairie/android/ui/components/ProfileMenu.kt") + private val menuSheet = source( + "org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt", + ) + + /** + * The three anchors — the floating top bar, Home's own chrome, Libraries' + * own chrome — used to carry a hand-rolled copy of this menu each, which + * is what this test originally had to check three times over. They now + * all delegate to the one shared trailing cluster ([TabTopBarActions]), + * which owns the single [ProfileMenu] anchor, so the ordering is asserted + * once and the delegation is asserted here, which is what stops a fourth + * copy drifting back in. + */ + @Test + fun everyPhoneProfileMenuPlacesWatchTogetherAfterRequestsAndBeforeSettings() { + listOf(topBar, home, libraries).forEach { text -> + assertTrue(text.contains("TabTopBarActions(")) + } + assertTrue(topBarActions.contains("ProfileMenu(")) + listOf(topBar, topBarActions, home, libraries).forEach { text -> + assertFalse(text.contains("\"Watch Together\"")) + assertFalse(text.contains("\"Watch together\"")) + assertFalse(text.contains("\"Switch Profile\"")) + assertFalse(text.contains("\"Switch profile\"")) + } + + val watch = profileMenu.indexOf("label = \"Watch together\"") + val requests = profileMenu.indexOf("label = \"Requests\"") + val settings = profileMenu.indexOf("label = \"Settings\"") + assertTrue(watch >= 0) + assertTrue(requests in 0 until watch) + assertTrue(watch < settings) + + val watchMenuItem = profileMenu.lastIndexOf("PrairieMenuItem(", watch) + assertTrue(watchMenuItem > requests) + assertFalse( + profileMenu.substring( + startIndex = requests + "label = \"Requests\"".length, + endIndex = watchMenuItem, + ).contains("PrairieMenuItem("), + ) + + // Both entries stay behind their gate: `requests_enabled` on the + // server for one, the client-side surface flag for the other. + assertTrue(profileMenu.contains("if (onRequestsClick != null)")) + assertTrue(profileMenu.contains("if (onWatchTogetherClick != null)")) + } + + @Test + fun mainShellOwnsOneTransientEntrySheet() { + assertTrue(main.contains("var showWatchTogetherEntry by rememberSaveable")) + assertTrue(main.contains("WatchTogetherMenuEntrySheet(")) + assertTrue(main.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + assertTrue(main.contains("onWatchTogetherClick = watchTogetherMenuAction")) + } + + @Test + fun sheetUsesOnlyTheExistingControllerAndNeverHandlesCredentials() { + assertTrue(menuSheet.contains("viewModel.hostEmptyVoteRoom()")) + assertTrue(menuSheet.contains("viewModel.resumeCurrentRoom()")) + assertTrue(menuSheet.contains("viewModel.joinByCode(code)")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(menuSheet.contains(label)) + } + assertFalse(menuSheet.contains("WatchTogetherApi")) + assertFalse(menuSheet.contains("room_token")) + assertFalse(menuSheet.contains("roomAccessToken")) + assertFalse(menuSheet.contains("Authorization")) + assertFalse(menuSheet.contains("CleartextOriginConsent")) + assertFalse(menuSheet.contains("HttpClient")) + assertTrue(menuSheet.contains("state.error")) + } +} diff --git a/androidTvApp/build.gradle.kts b/androidTvApp/build.gradle.kts index cbcff19c6..c6228b88b 100644 --- a/androidTvApp/build.gradle.kts +++ b/androidTvApp/build.gradle.kts @@ -1,27 +1,76 @@ +import com.android.build.api.variant.HasHostTestsBuilder +import com.android.build.api.variant.HostTestBuilder + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.multiplatform) + // Consumes the profile generated by :baselineprofile-tv and bakes it into + // the release APK so profileinstaller can AOT-compile hot paths on first + // run — first launches otherwise JIT the Home feed while the user browses. + alias(libs.plugins.androidx.baselineprofile) } -val prairieVersionName = providers - .gradleProperty("prairieVersionName") +// See androidApp's build.gradle.kts for the channel rationale. +val siloReleaseChannels = listOf("internal", "alpha", "beta", "production", "sideload", "dev") + +val siloVersionName = providers + .gradleProperty("siloVersionName") .orElse(providers.environmentVariable("PRAIRIE_VERSION_NAME")) // Release builds override this from the validated Git tag in // android-build.yml. Keep local/dev builds aligned with the latest release. .orElse("0.3.11") -val prairieVersionCode = providers - .gradleProperty("prairieVersionCode") +val siloDisplayVersion = providers + .gradleProperty("siloDisplayVersion") + .orElse(providers.environmentVariable("PRAIRIE_DISPLAY_VERSION")) + .orElse(siloVersionName) + +// The per-marketing-version build counter (TestFlight-style). It is folded into +// the versionCode by CI, but the app also reports it verbatim to the server +// (X-Prairie-Client-Build), so it has to survive as its own value rather than +// being reverse-engineered from the versionCode. +val siloBuildNumber = providers + .gradleProperty("siloBuildNumber") + .orElse(providers.environmentVariable("PRAIRIE_BUILD_NUMBER")) + .map { value -> + val build = value.toIntOrNull() ?: error("siloBuildNumber must be an integer.") + // The same 0..999 window release.yml and the Fastfile enforce, so a + // hand-run build can't stamp a counter the release scheme could never + // produce. 0 is the unstamped local default; CI itself requires 1..999. + require(build in 0..999) { + "siloBuildNumber must be between 0 and 999 (0 marks an unstamped local build)." + } + build.toString() + } + // Local/dev builds have no CI build number; 0 marks "not a release build". + .orElse("0") + +// The Play track this artifact is uploaded to, or how it reaches a device +// without Play. See androidApp's build.gradle.kts. +val siloReleaseChannel = providers + .gradleProperty("siloReleaseChannel") + .orElse(providers.environmentVariable("PRAIRIE_RELEASE_CHANNEL")) + .map { value -> + val channel = value.trim().lowercase() + require(channel in siloReleaseChannels) { + "siloReleaseChannel must be one of ${siloReleaseChannels.joinToString("/")} (got '$value')." + } + channel + } + .orElse("sideload") + +val siloVersionCode = providers + .gradleProperty("siloVersionCode") .orElse(providers.environmentVariable("PRAIRIE_VERSION_CODE")) .map { value -> - val code = value.toIntOrNull() ?: error("prairieVersionCode must be an integer.") - require(code > 0) { "prairieVersionCode must be positive." } + val code = value.toIntOrNull() ?: error("siloVersionCode must be an integer.") + require(code > 0) { "siloVersionCode must be positive." } // The *2 (+1 for TV) form-factor multiplier applied at versionCode // assignment must stay under Google Play's 2_100_000_000 ceiling. require(code <= 1_049_999_999) { - "prairieVersionCode must be <= 1_049_999_999 so the form-factor multiplier " + + "siloVersionCode must be <= 1_049_999_999 so the form-factor multiplier " + "keeps both artifacts under Google Play's 2_100_000_000 versionCode limit." } code @@ -96,14 +145,16 @@ kotlin { implementation(libs.androidx.profileinstaller) } - // First tests in this module — JUnit 4 via kotlin-test-junit, mirroring - // the android-shared setup. Covers the AmbientBackdropTintState stale- - // result guard (A.2). Tests that need android.* APIs would require - // Robolectric; the current suite is pure JVM. + // JUnit 4 behavior tests plus a small Robolectric Compose harness for + // focus and accessibility semantics that cannot be verified from + // source text. androidUnitTest.dependencies { implementation(kotlin("test")) implementation(kotlin("test-junit")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.robolectric) + implementation(libs.androidx.test.core) + implementation(libs.compose.ui.test.junit4) // NotificationRow's constructor default uses JsonObject; the inbox // formatter test constructs rows directly, so json must be on the // test classpath. @@ -133,11 +184,16 @@ android { // collide with the phone module. applicationId = "org.prairieserver.prairie" minSdk = 24 - targetSdk = 35 + targetSdk = 36 // Two artifacts under one listing need distinct versionCodes: phone = // base*2, TV = base*2+1, so each release bumps both by 2 with no reuse. - versionCode = prairieVersionCode.get() * 2 + 1 - versionName = prairieVersionName.get() + versionCode = siloVersionCode.get() * 2 + 1 + versionName = siloVersionName.get() + buildConfigField("String", "DISPLAY_VERSION", "\"${siloDisplayVersion.get()}\"") + // Reported to the server as X-Prairie-Client-Build and shown on the About + // row, so both name the same build the way Play and TestFlight do: + // "Silo Android TV 1.0.0 (5)". + buildConfigField("String", "BUILD_NUMBER", "\"${siloBuildNumber.get()}\"") // Shadow the android-shared BuildConfig field so per-app flavors can // override without rebuilding the shared module. See androidApp's // build.gradle.kts for rationale. @@ -167,7 +223,11 @@ android { } } buildTypes { + debug { + buildConfigField("String", "RELEASE_CHANNEL", "\"dev\"") + } release { + buildConfigField("String", "RELEASE_CHANNEL", "\"${siloReleaseChannel.get()}\"") // Launch-prep: full R8 + resource shrinking, sharing the root // proguard-rules.pro with :androidApp (same reflection/JNI-heavy // shared + android-shared stack). R8 breakage is runtime-only, so a @@ -209,9 +269,10 @@ android { } testOptions { unitTests { - // Default to safe no-op stubs for android.* classes (e.g. android.util.Log.w) - // so tests can exercise code paths that touch them without requiring Robolectric. + // Default to safe no-op stubs for pure JVM tests that touch android.*; + // the focused Compose semantics suite opts into Robolectric explicitly. isReturnDefaultValues = true + isIncludeAndroidResources = true } } packaging { @@ -221,8 +282,50 @@ android { excludes += "/META-INF/versions/*/OSGI-INF/MANIFEST.MF" } } + + // Lint had never run on this project. The two crashes it would have caught + // — a Spatializer call gated at API 31 when the class arrives at 32, and a + // getAddress() call with no guard at all — both shipped from this module, + // which app-level lint does not analyse unless asked. Hence the gate here + // and checkDependencies in the apps. + // + // The baseline holds today's known findings (overwhelmingly desugared + // java.* calls and deliberate media3 @UnstableApi usage) so that only NEW + // violations fail. Delete it and regenerate deliberately; do not add to it + // to make a build pass. + lint { + // Without this, none of android-shared is examined. + checkDependencies = true + baseline = file("lint-baseline.xml") + abortOnError = true + fatal += setOf("NewApi", "InlinedApi") + checkReleaseBuilds = true + } +} + +// The Robolectric suites need the test ComponentActivity in the merged +// manifest, and that dependency is deliberately debug-only so it can never +// reach the release APK. The consequence is that those same tests cannot run +// against the release variant at all — they fail resolving the activity rather +// than telling you anything about release. +// +// Running them once, on debug, is the whole of their value: unit tests are not +// minified, so the release variant exercises no different code. This was found +// by a flaky test in another module aborting `gradlew test` before the release +// task was ever reached. +androidComponents { + beforeVariants(selector().withBuildType("release")) { variant -> + // Host-tests API rather than `variant.enableUnitTest`, which AGP 8.10.1 + // deprecates and AGP 9.0 removes. + (variant as HasHostTestsBuilder) + .hostTests[HostTestBuilder.UNIT_TEST_TYPE] + ?.enable = false + } } dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) + debugImplementation(libs.compose.ui.test.manifest) + // The generated Baseline Profile artifact (see :baselineprofile-tv). + baselineProfile(project(":baselineprofile-tv")) } diff --git a/androidTvApp/gradle.lockfile b/androidTvApp/gradle.lockfile index 722cb893f..b52bfd8e8 100644 --- a/androidTvApp/gradle.lockfile +++ b/androidTvApp/gradle.lockfile @@ -2,20 +2,23 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. androidx.activity:activity-compose:1.12.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.activity:activity-compose:1.7.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.activity:activity-ktx:1.12.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.activity:activity-ktx:1.7.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.activity:activity:1.12.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.annotation:annotation-experimental:1.4.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath +androidx.activity:activity:1.7.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath androidx.annotation:annotation-experimental:1.5.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.annotation:annotation-jvm:1.9.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.annotation:annotation:1.9.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.annotation:annotation:1.9.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.appcompat:appcompat-resources:1.7.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.appcompat:appcompat:1.7.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.arch.core:core-common:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.arch.core:core-runtime:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.autofill:autofill:1.0.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.collection:collection-jvm:1.5.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.collection:collection-ktx:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.collection:collection:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-common:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.autofill:autofill:1.0.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.collection:collection:1.5.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.animation:animation-android:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.animation:animation-core-android:1.8.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.animation:animation-core:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata @@ -36,56 +39,65 @@ androidx.compose.material:material-icons-extended-android:1.7.6=androidDebugAndr androidx.compose.material:material-icons-extended:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material:material-ripple-android:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose.material:material-ripple:1.7.6=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime-android:1.9.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-annotation-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime-annotation-android:1.9.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-annotation:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime-annotation:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-saveable-android:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime-saveable-android:1.9.3=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime-saveable:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime-saveable:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.runtime:runtime:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath +androidx.compose.runtime:runtime:1.9.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath androidx.compose.runtime:runtime:1.9.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-geometry-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-geometry:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui-geometry:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-geometry:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-graphics-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-graphics:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui-graphics:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-graphics:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-text-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-text:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui-text:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-text:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-unit-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui-unit:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-unit:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-util-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-util-android:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui-util:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui-util:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui-util:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.compose.ui:ui:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -androidx.compose.ui:ui:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath -androidx.compose.ui:ui:1.9.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-geometry-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-geometry:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui-geometry:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-geometry:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-graphics-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-graphics:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui-graphics:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-graphics:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-android:1.9.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.9.2=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.9.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-text-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-text:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui-text:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-text:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-unit-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-unit:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui-unit:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-unit:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-util-android:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-util-android:1.9.2=androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui-util:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui-util:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui-util:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.compose.ui:ui:1.7.6=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata +androidx.compose.ui:ui:1.9.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidReleaseCompileClasspath +androidx.compose.ui:ui:1.9.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose:compose-bom:2024.12.01=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.concurrent:concurrent-futures-ktx:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.concurrent:concurrent-futures:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.1.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +androidx.concurrent:concurrent-futures-ktx:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +androidx.concurrent:concurrent-futures:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-ktx:1.15.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.core:core-ktx:1.16.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.core:core-viewtree:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core-viewtree:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.core:core:1.15.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.core:core:1.16.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.cursoradapter:cursoradapter:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.customview:customview-poolingcontainer:1.0.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.customview:customview:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.datastore:datastore-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.datastore:datastore-core-android:1.2.1=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -102,34 +114,49 @@ androidx.datastore:datastore:1.2.1=allInstrumentedTestSourceSetsCompileDependenc androidx.documentfile:documentfile:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.drawerlayout:drawerlayout:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.emoji2:emoji2:1.4.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.exifinterface:exifinterface:1.3.7=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.fragment:fragment-ktx:1.8.8=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.fragment:fragment:1.8.8=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.graphics:graphics-path:1.0.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.interpolator:interpolator:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.legacy:legacy-support-core-utils:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.10.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-livedata:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-process:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-service:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.10.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.9.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.loader:loader:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.media3:media3-common-ktx:1.10.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -160,6 +187,7 @@ androidx.navigationevent:navigationevent:1.0.2=allInstrumentedTestSourceSetsComp androidx.palette:palette-ktx:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.palette:palette:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.print:print:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.recyclerview:recyclerview:1.3.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -168,24 +196,39 @@ androidx.room:room-common:2.8.4=androidDebugRuntimeClasspath,androidReleaseRunti androidx.room:room-ktx:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.room:room-runtime-android:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.room:room-runtime:2.8.4=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-android:1.3.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-android:1.4.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.3.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose-android:1.4.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-compose:1.3.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose:1.4.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.3.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-ktx:1.4.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.savedstate:savedstate:1.3.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath androidx.savedstate:savedstate:1.4.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.security:security-crypto:1.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.sqlite:sqlite-android:2.6.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.sqlite:sqlite-framework-android:2.6.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.sqlite:sqlite-framework:2.6.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.sqlite:sqlite:2.6.2=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.startup:startup-runtime:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test.espresso:espresso-core:3.5.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.7.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test.ext:junit:1.1.5=androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test.services:storage:1.4.2=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test:annotation:1.0.1=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.test:core-ktx:1.6.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:core:1.6.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:monitor:1.8.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.test:runner:1.5.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.tracing:tracing-ktx:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.tracing:tracing:1.2.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.tracing:tracing:1.1.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +androidx.tracing:tracing:1.2.0=androidDebugRuntimeClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.tv:tv-material:1.0.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.tvprovider:tvprovider:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.vectordrawable:vectordrawable-animated:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.vectordrawable:vectordrawable:1.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.versionedparcelable:versionedparcelable:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.viewpager:viewpager:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime-ktx:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime:2.11.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -195,6 +238,7 @@ co.touchlab:stately-concurrent-collections-jvm:2.1.0=androidDebugRuntimeClasspat co.touchlab:stately-concurrent-collections:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath co.touchlab:stately-strict-jvm:2.1.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath co.touchlab:stately-strict:2.1.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.almworks.sqlite4java:sqlite4java:1.0.392=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.android.tools.ddms:ddmlib:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib com.android.tools.emulator:proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control com.android.tools.utp:android-device-provider-ddmlib-proto:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib @@ -228,25 +272,31 @@ com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-pla com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.value:auto-value-annotations:1.11.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.auto:auto-common:1.2.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.code.findbugs:jsr305:2.0.2=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-core com.google.code.gson:gson:2.11.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.crypto.tink:tink-android:1.8.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +com.google.errorprone:error_prone_annotation:2.41.0=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.errorprone:error_prone_annotations:2.36.0=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.guava:failureaccess:1.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.guava:failureaccess:1.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +com.google.guava:failureaccess:1.0.3=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.guava:guava:33.3.1-android=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.guava:guava:33.3.1-android=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath com.google.guava:guava:33.3.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.guava:guava:33.4.8-jre=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -com.google.j2objc:j2objc-annotations:3.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +com.google.j2objc:j2objc-annotations:3.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core com.google.protobuf:protobuf-java-util:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher com.google.protobuf:protobuf-java:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher @@ -258,11 +308,14 @@ com.google.testing.platform:android-test-plugin:0.0.9-alpha03=_internal-unified- com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +com.google.testparameterinjector:test-parameter-injector:1.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.zxing:core:3.5.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.ibm.icu:icu4j:77.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-sse:4.12.0=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp:4.12.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okio:okio-jvm:3.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okio:okio:3.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup:javawriter:2.1.1=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control io.coil-kt.coil3:coil-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath io.coil-kt.coil3:coil-compose-android:3.1.0=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -387,20 +440,24 @@ io.opencensus:opencensus-api:0.31.0=_internal-unified-test-platform-core io.opencensus:opencensus-proto:0.2.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-core io.perfmark:perfmark-api:0.27.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle -javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath junit:junit:4.13.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib -org.bouncycastle:bcprov-jdk18on:1.84=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.bouncycastle:bcprov-jdk18on:1.81=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata +org.bouncycastle:bcprov-jdk18on:1.84=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.bouncycastle:bctls-jdk18on:1.84=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath org.bouncycastle:bcutil-jdk18on:1.84=androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -org.checkerframework:checker-qual:3.43.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.checkerframework:checker-qual:3.43.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,androidDebugRuntimeClasspath,androidReleaseRuntimeClasspath,debugRuntimeClasspath,releaseRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-core org.codehaus.mojo:animal-sniffer-annotations:1.24=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.conscrypt:conscrypt-openjdk-uber:2.5.2=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.hamcrest:hamcrest-core:1.3=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.hamcrest:hamcrest-integration:1.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.0-beta01=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugUnitTestCompileClasspath,androidReleaseCompileClasspath,androidReleaseUnitTestCompileClasspath,debugAndroidTestCompileClasspath org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.5=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -471,10 +528,10 @@ org.jetbrains.kotlin:kotlin-test:2.1.20=allTestSourceSetsCompileDependenciesMeta org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu:0.27.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -503,7 +560,24 @@ org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1=allInstrumentedTestSource org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.7.3=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata org.jetbrains.skiko:skiko:0.9.4=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathAndroidDebug,kotlinCompilerPluginClasspathAndroidDebugAndroidTest,kotlinCompilerPluginClasspathAndroidDebugUnitTest,kotlinCompilerPluginClasspathAndroidRelease,kotlinCompilerPluginClasspathAndroidReleaseUnitTest,kotlinCompilerPluginClasspathMetadataMain,kotlinKlibCommonizerClasspath -org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -org.jspecify:jspecify:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.jspecify:jspecify:1.0.0=allInstrumentedTestSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidInstrumentedTestApiDependenciesMetadata,androidInstrumentedTestCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugApiDependenciesMetadata,androidInstrumentedTestDebugCompileOnlyDependenciesMetadata,androidInstrumentedTestDebugImplementationDependenciesMetadata,androidInstrumentedTestDebugResolvableDependenciesMetadata,androidInstrumentedTestImplementationDependenciesMetadata,androidInstrumentedTestResolvableDependenciesMetadata,androidMainApiDependenciesMetadata,androidMainCompileOnlyDependenciesMetadata,androidMainImplementationDependenciesMetadata,androidMainResolvableDependenciesMetadata,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestDebugApiDependenciesMetadata,androidUnitTestDebugCompileOnlyDependenciesMetadata,androidUnitTestDebugImplementationDependenciesMetadata,androidUnitTestDebugResolvableDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestReleaseApiDependenciesMetadata,androidUnitTestReleaseCompileOnlyDependenciesMetadata,androidUnitTestReleaseImplementationDependenciesMetadata,androidUnitTestReleaseResolvableDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,commonTestApiDependenciesMetadata,commonTestCompileOnlyDependenciesMetadata,commonTestImplementationDependenciesMetadata,commonTestResolvableDependenciesMetadata,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-commons:9.8=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm-tree:9.8=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.ow2.asm:asm:9.8=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:annotations:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:junit:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime-dist-compat:1.0.18=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:nativeruntime:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:pluginapi:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:plugins-maven-dependency-resolver:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:resources:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:robolectric:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:sandbox:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadowapi:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:shadows-framework:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils-reflector:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.robolectric:utils:4.16.1=allTestSourceSetsCompileDependenciesMetadata,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,androidUnitTestApiDependenciesMetadata,androidUnitTestCompileOnlyDependenciesMetadata,androidUnitTestImplementationDependenciesMetadata,androidUnitTestResolvableDependenciesMetadata,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath org.slf4j:slf4j-api:2.0.16=androidDebugAndroidTestCompileClasspath,androidDebugCompileClasspath,androidDebugRuntimeClasspath,androidDebugUnitTestCompileClasspath,androidDebugUnitTestRuntimeClasspath,androidReleaseCompileClasspath,androidReleaseRuntimeClasspath,androidReleaseUnitTestCompileClasspath,androidReleaseUnitTestRuntimeClasspath,debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +org.yaml:snakeyaml:2.4=androidDebugUnitTestRuntimeClasspath,androidReleaseUnitTestRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath empty=androidApis,androidDebugAndroidTestRuntimeClasspath,androidDebugIntransitiveDependenciesMetadata,androidInstrumentedTestDebugIntransitiveDependenciesMetadata,androidInstrumentedTestIntransitiveDependenciesMetadata,androidJdkImage,androidMainIntransitiveDependenciesMetadata,androidReleaseIntransitiveDependenciesMetadata,androidTestUtil,androidUnitTestDebugIntransitiveDependenciesMetadata,androidUnitTestIntransitiveDependenciesMetadata,androidUnitTestReleaseIntransitiveDependenciesMetadata,commonMainIntransitiveDependenciesMetadata,commonTestIntransitiveDependenciesMetadata,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestRuntimeClasspath,debugAnnotationProcessorClasspath,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugWearBundling,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseReverseMetadataValues,releaseUnitTestAnnotationProcessorClasspath,releaseWearBundling diff --git a/androidTvApp/lint-baseline.xml b/androidTvApp/lint-baseline.xml new file mode 100644 index 000000000..cf7a3899f --- /dev/null +++ b/androidTvApp/lint-baseline.xml @@ -0,0 +1,4198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/androidTvApp/src/androidMain/AndroidManifest.xml b/androidTvApp/src/androidMain/AndroidManifest.xml index dddb8ad48..5a4af37a3 100644 --- a/androidTvApp/src/androidMain/AndroidManifest.xml +++ b/androidTvApp/src/androidMain/AndroidManifest.xml @@ -10,6 +10,19 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + + + + + diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt index 04e1491c0..d9afbbec7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt @@ -39,6 +39,7 @@ import org.prairieserver.prairie.common.startup.warmProfileSelectionStartup import org.prairieserver.prairie.common.ui.components.StartupSplashVideo import org.prairieserver.prairie.common.ui.components.StartupSplashResizeMode import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.requiresApproval import org.prairieserver.prairie.repository.AuthRepository @@ -46,7 +47,7 @@ import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver import org.prairieserver.prairie.tv.ui.navigation.TvAppNavigation import org.prairieserver.prairie.tv.ui.navigation.TvRoute import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerRemoteKeyBridge @@ -78,6 +79,14 @@ class MainTvActivity : ComponentActivity() { const val DEEP_LINK_TAG = "PrairieDeepLink" } + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // "Keys do nothing" with no other PrairieTvFocus lines afterwards means + // input is going to whichever window took focus (typically the Google + // TV launcher), not to this app — an OS/emulator condition, not ours. + TvFocusLog.d { "window focus ${if (hasFocus) "GAINED" else "LOST"}" } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -113,6 +122,9 @@ class MainTvActivity : ComponentActivity() { // overlay is up: no input-dispatch-timeout ANR, and // no keys leak into the app pre-rendering below — // even after its content grabs focus. + if (splashVisible) { + TvFocusLog.d { "key swallowed by splash gate" } + } splashVisible }, ) { @@ -193,7 +205,7 @@ class MainTvActivity : ComponentActivity() { if (isAuthenticatedForCast() && lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED) ) { - val receiver = get(TvPrairieCastReceiver::class.java) + val receiver = get(TvSiloCastReceiver::class.java) receiver.start() // The lifecycle check above is a TOCTOU: the activity can stop // between the check and start(), so onStop()'s stop() lands @@ -226,17 +238,17 @@ class MainTvActivity : ComponentActivity() { } /** - * Pushes a Prairie deep-link Uri into the shared [pendingDeepLink] flow for - * [TvAppNavigation] to consume. Non-Prairie schemes (and intents without data) + * Pushes a Silo deep-link Uri into the shared [pendingDeepLink] flow for + * [TvAppNavigation] to consume. Non-Silo schemes (and intents without data) * are ignored so unrelated launch intents don't clobber a queued URI. * Nullable parameter to accommodate the cold-launch call site where the * Activity's intent may be null. */ private fun handleIntent(intent: Intent?) { val data = intent?.data ?: return - // `prairie` is the only scheme the manifest registers; anything else is + // `silo` is the only scheme the manifest registers; anything else is // an unrelated launch intent and must not clobber a queued URI. - if (data.scheme == "prairie") { + if (data.scheme == "silo") { Log.i(DEEP_LINK_TAG, "deep link queued: ${data.host}/${data.lastPathSegment}") pendingDeepLink.value = data } @@ -252,7 +264,7 @@ class MainTvActivity : ComponentActivity() { super.onStop() val monitor = get(ServerReachabilityMonitor::class.java) monitor.stopForeground() - get(TvPrairieCastReceiver::class.java).stop() + get(TvSiloCastReceiver::class.java).stop() val store = get(PlayerSettingsStore::class.java) lifecycleScope.launch { store.flushPendingDeviceSettings() } } @@ -308,7 +320,7 @@ class MainTvActivity : ComponentActivity() { // onStop()'s stop() already ran, leaving NSD advertising + the cast // socket up while backgrounded. Mirrors the onStart() guard. if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - val receiver = get(TvPrairieCastReceiver::class.java) + val receiver = get(TvSiloCastReceiver::class.java) receiver.start() // Same TOCTOU compensation as onStart(): if the activity // stopped between the check and start(), undo the start — @@ -325,6 +337,9 @@ class MainTvActivity : ComponentActivity() { personalDataRepository = get(PersonalDataRepository::class.java), sectionRepository = get(SectionRepository::class.java), homeCache = get(HomeCachePort::class.java), + identityTransitions = get( + org.prairieserver.prairie.network.IdentityTransitionBarrier::class.java, + ), serverUrl = get(ServerRegistry::class.java).activeEntry.value?.url, artworkPlan = StartupArtworkPlan.tv(), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt index 482d25a8b..0f2a12422 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt @@ -79,13 +79,13 @@ class PrairieTvApplication : Application(), Configuration.Provider, SingletonIma // for cold start. runCatching { org.prairieserver.prairie.common.downloads.installOrphanedServerDataPurge( - context = this@SiloTvApplication, + context = this@PrairieTvApplication, registry = koinApp.koin.get(), database = koinApp.koin.get(), storage = koinApp.koin.get(), ) }.onFailure { - android.util.Log.w("SiloTvApplication", "Orphaned server purge init failed", it) + android.util.Log.w("PrairieTvApplication", "Orphaned server purge init failed", it) } // One-time migration: drain the legacy .record.json download sidecar tree // into Room so pre-cutover downloads keep their metadata. Guarded — runs diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt index 2766ca7e8..1b5373aae 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/cast/RemotePlaybackIdentityManager.kt @@ -74,7 +74,8 @@ class RemotePlaybackIdentityManager( val started = deviceLoginApi.startRemotePlaybackAt( serverUrl = offer.serverURL, deviceName = deviceNameProvider(), - devicePlatform = "android_tv", + // Matches the X-Prairie-Device-Platform header spelling. + devicePlatform = "android-tv", ).successOrThrow() require(started.clientPurpose == "remote_playback" && started.temporary == true) { "The server did not create a temporary remote playback session." @@ -115,7 +116,17 @@ class RemotePlaybackIdentityManager( refreshToken = refreshToken, profileId = profileId, profileToken = profileToken, + // The SESSION deadline — hours past the + // access token it was issued with, so it + // must never be read as a token expiry. expiresAtEpochMs = expiresAtMs, + // The token's own deadline, kept separate. + // Null when the server omits expires_in, + // which keeps this overlay reactive-only + // rather than guessing off the session. + accessTokenExpiresAtEpochMs = poll.expiresIn + ?.let { System.currentTimeMillis() + it * 1000L }, + accessTokenLifetimeMs = poll.expiresIn?.times(1000L), ), ) val active = ActiveIdentity( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt index 5f324eb1c..5bf57dec9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigration.kt @@ -10,8 +10,10 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStoreFile import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.settings.EffectiveSetting import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import kotlinx.coroutines.flow.first @@ -114,21 +116,62 @@ class LegacyTvPrefsMigration( val effective = getEffectiveSettings( listOf( PlaybackSettingsKeys.PreferredQuality, + // Quality is two rows now, and `setQuality` writes both. Asking + // only about the resolution would let a device that has a + // server-side bitrate cap but no resolution override pass the + // guard, and the legacy preset's bitrate — or JSON null, when + // the legacy value is Auto — would overwrite that cap. Both + // axes are queried so both can be guarded. + PlaybackSettingsKeys.MaxBitrateKbps, PlaybackSettingsKeys.AutoPlayNext, + // Both spellings of the intro preference. The legacy boolean is + // migrated into the enum that superseded it, so an override on + // either one means this device has already answered the + // question and the stale local pref must not overwrite it. PlaybackSettingsKeys.AutoSkipIntro, + PlaybackSettingsKeys.IntroSkipMode, PlaybackSettingsKeys.AutoSkipCredits, PlaybackSettingsKeys.SubtitleAppearance, ), ) - if (effective[PlaybackSettingsKeys.PreferredQuality]?.hasDeviceOverride != true) { - playerSettingsStore.setPreferredQuality(legacyQuality) + val qualityOverridden = + effective[PlaybackSettingsKeys.PreferredQuality]?.hasDeviceOverride == true || + effective[PlaybackSettingsKeys.MaxBitrateKbps]?.hasDeviceOverride == true + if (!qualityOverridden) { + // Both axes, never just the resolution. Quality is a + // (resolution, bitrate) pair now, and the legacy enum's bare + // "720p" carries an implied cap — the same one the server's own + // migration assigns it (internal/settingsmigrate/plan.go + // decomposes 720p to {720p, 2000}). Writing the resolution alone + // would leave a pair no preset covers, so the picker would render + // nothing as selected with the cursor parked on Auto, and the + // sentinel is marked on this pass so it could never be re-migrated. + // + // The legacy enum's wire values are exactly the base preset ids, + // so the id lookup lands on the same bitrate the server assigns + // (1080p -> 6000, 720p -> 2000, 480p -> 1500) rather than on + // whichever tier of that resolution happens to sort first. + val resolution = QualityPresets.normalizeResolution(legacyQuality) + val preset = QualityPresets.byId(resolution) + playerSettingsStore.setQuality( + preset?.resolution ?: resolution, + preset?.bitrateKbps, + ) } if (effective[PlaybackSettingsKeys.AutoPlayNext]?.hasDeviceOverride != true) { playerSettingsStore.setAutoPlayNext(legacyAutoPlayNext) } - if (effective[PlaybackSettingsKeys.AutoSkipIntro]?.hasDeviceOverride != true) { - playerSettingsStore.setAutoSkipIntro(legacyAutoSkipIntro) + val introSkipOverridden = + effective[PlaybackSettingsKeys.IntroSkipMode]?.hasDeviceOverride == true || + effective[PlaybackSettingsKeys.AutoSkipIntro]?.hasDeviceOverride == true + if (!introSkipOverridden) { + // true -> always, false -> ask; the same mapping the server's own + // migration uses. "never" is unreachable from a boolean, which is + // exactly why the enum replaced it. + playerSettingsStore.setIntroSkipMode( + IntroSkipMode.fromLegacyBoolean(legacyAutoSkipIntro), + ) } if (effective[PlaybackSettingsKeys.AutoSkipCredits]?.hasDeviceOverride != true) { playerSettingsStore.setAutoSkipCredits(legacyAutoSkipCredits) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt index 8577b2c63..017e4a9c0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt @@ -4,9 +4,11 @@ package org.prairieserver.prairie.tv.di import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.tv.BuildConfig import org.prairieserver.prairie.tv.data.preferences.LegacyTvPrefsMigration import org.prairieserver.prairie.tv.data.preferences.TvLibrarySelectionStore import org.prairieserver.prairie.common.network.AndroidDeviceMetadataProvider +import org.prairieserver.prairie.common.network.PrairieClientBuildIdentity import org.prairieserver.prairie.common.network.CleartextConsentStore import org.prairieserver.prairie.common.network.DataStoreCleartextConsentStore import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache @@ -19,23 +21,26 @@ import org.prairieserver.prairie.network.createSecureSharedPrefs import org.prairieserver.prairie.tv.ui.screens.servers.TvServerListViewModel import org.prairieserver.prairie.common.player.AudioCapabilityManager import org.prairieserver.prairie.common.player.AudioTrackManager +import org.prairieserver.prairie.common.player.AndroidSubtitlePresentation import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsViewModel import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel import org.prairieserver.prairie.common.player.PrairiePlayerFactory import org.prairieserver.prairie.common.player.PlaybackSessionManager +import org.prairieserver.prairie.common.player.audio.PassthroughSuppressionScope +import org.prairieserver.prairie.common.di.AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER +import org.prairieserver.prairie.common.di.AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.cast.PrairieCastNsdAdvertiser import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStarter -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver import org.prairieserver.prairie.tv.cast.RemotePlaybackIdentityManager import org.prairieserver.prairie.tv.ui.screens.player.TvPlayerLaunchArgs import org.prairieserver.prairie.tv.ui.screens.auth.TvLoginViewModel import org.prairieserver.prairie.tv.ui.screens.auth.TvServerSetupViewModel import org.prairieserver.prairie.tv.ui.screens.collections.TvCollectionDetailViewModel -import org.prairieserver.prairie.viewmodel.AdminStatsViewModel import org.prairieserver.prairie.viewmodel.CalendarViewModel import org.prairieserver.prairie.viewmodel.CollectionsViewModel import org.prairieserver.prairie.tv.ui.screens.detail.TvItemDetailViewModel @@ -44,9 +49,12 @@ import org.prairieserver.prairie.viewmodel.RecommendationsViewModel import org.prairieserver.prairie.viewmodel.MyRequestsViewModel import org.prairieserver.prairie.viewmodel.RequestSearchViewModel import org.prairieserver.prairie.viewmodel.RequestsViewModel +import org.prairieserver.prairie.viewmodel.LiveTvViewModel +import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel import org.prairieserver.prairie.tv.ui.screens.libraries.TvLibrariesViewModel import org.prairieserver.prairie.tv.ui.screens.library.TvLibraryCollectionDetailViewModel import org.prairieserver.prairie.tv.ui.screens.library.TvLibraryDetailViewModel +import org.prairieserver.prairie.tv.ui.screens.personal.TvPersonalListControlsViewModel import org.prairieserver.prairie.viewmodel.FavoritesViewModel import org.prairieserver.prairie.viewmodel.HistoryViewModel import org.prairieserver.prairie.viewmodel.WatchlistViewModel @@ -63,8 +71,6 @@ import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module -import org.prairieserver.prairie.viewmodel.LiveTvPlayerViewModel -import org.prairieserver.prairie.viewmodel.LiveTvViewModel /** * TV-specific Koin module. @@ -114,6 +120,7 @@ val androidTvModule = module { org.prairieserver.prairie.common.data.repository.RoomHomeCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -121,6 +128,7 @@ val androidTvModule = module { org.prairieserver.prairie.common.data.repository.RoomCatalogCacheRepository( db = get(), snapshotProvider = { tokenManager.snapshotCurrentScope() }, + identityTransitions = get(), ) } single { @@ -137,11 +145,24 @@ val androidTvModule = module { } single { AndroidServerSettingsCache(androidContext()) } + // The one place the TV app's BuildConfig crosses into android-shared; see + // androidModule for why every identity reporter resolves this instead of + // deriving its own answer. + single { PrairieClientBuildIdentity(BuildConfig.BUILD_NUMBER, BuildConfig.RELEASE_CHANNEL) } single { - AndroidDeviceMetadataProvider(androidContext(), platform = "android-tv") + AndroidDeviceMetadataProvider( + androidContext(), + platform = "android-tv", + buildIdentity = get(), + ) } // Player infrastructure (duplicate-for-now; extract to :android-player later). - single { SubtitleManager(get()) } + single { + SubtitleManager( + libassBridge = get(), + presentation = AndroidSubtitlePresentation.Television, + ) + } single { AudioTrackManager() } single { VideoPlaybackBackendFactory( @@ -151,7 +172,7 @@ val androidTvModule = module { ) } single { AudioCapabilityManager(androidContext()) } - single { PlaybackCapabilityDetector(androidContext(), get(), get()) } + single { PlaybackCapabilityDetector(androidContext(), get(), get(), get()) } single { PrairiePlayerFactory( context = androidContext(), @@ -166,6 +187,14 @@ val androidTvModule = module { ) } single { PlaybackSessionManager(get(), get(), get()) } + single(AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER) { + PlaybackSessionManager( + playbackRepository = get(), + tokenManager = get(), + networkEvidenceProvider = get(), + passthroughSuppression = PassthroughSuppressionScope.None, + ) + } factory(named("tvVideoPlaybackStarter")) { TvVideoPlaybackStarter( catalogRepository = get(), @@ -187,10 +216,17 @@ val androidTvModule = module { // graph has no downloads (streaming-only), so register the resolver inline: // it just always finds no local media and the VM falls back to the server // stream. The stores are local-only JSON under filesDir. + // Registered rather than constructed inline because the orphaned-server + // purge resolves it from the graph at startup. It is streaming-only here, + // so there are no download bytes to delete — but the purge also clears the + // Room rows of removed servers (resume positions, cached home and catalog + // rows, pending outbox ops), and without this definition the whole purge + // fails at startup and none of that is ever reclaimed. + single { org.prairieserver.prairie.common.downloads.DownloadStorage(androidContext()) } single { org.prairieserver.prairie.common.downloads.OfflineMediaResolver( org.prairieserver.prairie.common.downloads.DownloadMetadataStore(get()), - org.prairieserver.prairie.common.downloads.DownloadStorage(androidContext()), + get(), get(), ) } @@ -206,7 +242,8 @@ val androidTvModule = module { viewModel { org.prairieserver.prairie.common.player.AudiobookPlayerViewModel( catalogRepository = get(), - playbackSessionManager = get(), + playbackSessionManager = get(AUDIOBOOK_PLAYBACK_SESSION_MANAGER_QUALIFIER), + playbackSessionLifecycle = get(AUDIOBOOK_PLAYBACK_SESSION_LIFECYCLE_QUALIFIER), capabilityDetector = get(), bookmarksStore = get(), userItemStatePort = get(), @@ -258,7 +295,7 @@ val androidTvModule = module { single { WatchNextSeeder(androidContext(), get()) } // Deep-link bridge between MainTvActivity (producer) and TvAppNavigation - // (consumer). The Activity writes incoming Prairie app-scheme URIs here on + // (consumer). The Activity writes incoming Silo app-scheme URIs here on // cold-launch (read from launching intent in onCreate) and warm-launch // (onNewIntent); the navigation Composable observes the flow and routes // to ItemDetail / Player once the user is past the auth chain. Using a @@ -282,7 +319,7 @@ val androidTvModule = module { }, // Always `setup`: advertising only runs while the server-setup // screen is showing, and Apple is authoritative for the wire — - // prairie-apple's TVPairingAdvertiser hardcodes st=setup and its + // silo-apple's TVPairingAdvertiser hardcodes st=setup and its // companion card FILTERS to state == .setup, so a registry-based // `login` (always true after a sign-out, since the registry keeps // entries) made the TV invisible to phones exactly when the user @@ -296,7 +333,7 @@ val androidTvModule = module { receiver = get(), // Always `setup`: advertising only runs while the server-setup // screen is showing, and Apple is authoritative for the wire — - // prairie-apple's TVPairingAdvertiser hardcodes st=setup and its + // silo-apple's TVPairingAdvertiser hardcodes st=setup and its // companion card FILTERS to state == .setup, so a registry-based // `login` (always true after a sign-out, since the registry keeps // entries) made the TV invisible to phones exactly when the user @@ -313,7 +350,7 @@ val androidTvModule = module { ) } single { - TvPrairieCastReceiver( + TvSiloCastReceiver( advertiser = get(), serverRegistry = get(), identityManager = get(), @@ -339,14 +376,6 @@ val androidTvModule = module { } viewModel { TvServerListViewModel(get(), get(), get()) } - // Admin ViewModels - viewModel { AdminStatsViewModel(get()) } - viewModel { org.prairieserver.prairie.viewmodel.AdminUsersViewModel(get()) } - viewModel { org.prairieserver.prairie.viewmodel.AdminUserEditViewModel(get()) } - viewModel { org.prairieserver.prairie.tv.ui.screens.admin.TvAdminSessionsViewModel(get()) } - viewModel { org.prairieserver.prairie.tv.ui.screens.admin.TvAdminScansViewModel(get(), get()) } - viewModel { org.prairieserver.prairie.tv.ui.screens.admin.TvAdminLogsViewModel(get()) } - viewModel { org.prairieserver.prairie.tv.ui.screens.settings.TvManageSessionsViewModel(get()) } viewModel { params -> org.prairieserver.prairie.viewmodel.RequestDetailViewModel(get(), params.get(), params.get()) } @@ -360,20 +389,13 @@ val androidTvModule = module { } // Content ViewModels - viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull()) } + viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.home.TvUpcomingViewModel(get()) } viewModel { RecommendationsViewModel(get()) } viewModel { RequestsViewModel(get()) } viewModel { RequestSearchViewModel(get()) } viewModel { MyRequestsViewModel(get()) } viewModel { org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsViewModel(get()) } - viewModel { - LiveTvViewModel( - repository = get(), - nowMillisProvider = { System.currentTimeMillis() }, - ) - } - viewModel { LiveTvPlayerViewModel(get()) } // Platform supplies "today" and the IANA timezone; the shared ViewModel's // week math stays deterministic in commonTest (no Clock.System default). viewModel { @@ -407,6 +429,7 @@ val androidTvModule = module { viewModel { params -> TvLibraryCollectionDetailViewModel( sectionRepository = get(), + catalogRepository = get(), libraryId = params.get(), collectionId = params.get(), title = params.get(), @@ -419,11 +442,14 @@ val androidTvModule = module { personalDataRepository = get(), playerSettingsStore = get(), profileRepository = get(), + profileSettings = get(), metadataAiRepository = get(), contentId = params.get(), userItemState = getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, recommendationRepository = getOrNull(), + tokenManager = get(), + identityTransitions = get(), ) } // Watch Together entry (create/join orchestration) — backs the entry + @@ -465,9 +491,16 @@ val androidTvModule = module { } // Personal data grids. - viewModel { FavoritesViewModel(get()) } - viewModel { WatchlistViewModel(get()) } + viewModel { FavoritesViewModel(get(), get()) } + viewModel { WatchlistViewModel(get(), get()) } viewModel { HistoryViewModel(get()) } + // Sort/filter state for the favorites and watchlist grids, keyed by source. + viewModel { params -> + TvPersonalListControlsViewModel( + catalogRepository = get(), + source = params.get(), + ) + } // Collections. viewModel { CollectionsViewModel(get()) } @@ -490,11 +523,20 @@ val androidTvModule = module { libraryPlaybackPrefsStore = get(), overlayPrefsStore = get(), legacyTvPrefsMigration = get(), + profileSettings = get(), tvLibraryScopeStore = getOrNull(), ) } viewModel { TvDiagnosticsViewModel(get()) } + + viewModel { + LiveTvViewModel( + repository = get(), + nowMillisProvider = { System.currentTimeMillis() }, + ) + } + viewModel { LiveTvPlayerViewModel(get()) } } private fun tvDeviceName(): String = - android.os.Build.MODEL?.trim()?.ifBlank { null } ?: "Android TV" + android.os.Build.MODEL?.trim()?.ifBlank { null } ?: "Android TV" \ No newline at end of file diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt index 9d92d8543..f88e282e8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAlphabetRail.kt @@ -77,7 +77,7 @@ fun TvAlphabetRail( * the top menu is to register this fallback: on a letter it moves focus * up the rail; on "All" it swallows the move. */ - onUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, modifier: Modifier = Modifier, ) { val letters = remember { @@ -107,7 +107,7 @@ fun TvAlphabetRail( // old fresh-lambda + null pattern left a stale always-true fallback // registered forever, killing Up-to-menu-bar for the whole library. val railUpFallback = remember { - { + { _: Boolean -> if (!allEntryFocused) { focusManager.moveFocus(FocusDirection.Up) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt index d0c40b5e2..0d5dbfd29 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt @@ -1,42 +1,74 @@ package org.prairieserver.prairie.tv.ui.components +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.MenuDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface // --------------------------------------------------------------------------- -// Anchored selector popover — Compose-for-TV port of the prairie-apple tvOS +// Anchored selector popover — Compose-for-TV port of the silo-apple tvOS // `TVSelectorButton` + `selectorMenuItem` (TVPlaybackSelectorRow.swift). // // Apple renders a SwiftUI `Menu` whose label is a secondary `.compact` squared @@ -54,6 +86,7 @@ import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface * [enabled] = false renders a non-selectable row (Apple's disabled "Unknown" * audio fallback / unavailable subtitle entries). */ data class TvSelectorOption( + val key: String, val title: String, val detail: String, val selected: Boolean, @@ -61,6 +94,74 @@ data class TvSelectorOption( val enabled: Boolean = true, ) +internal fun selectorExpansionAfterInteractivityChange( + expanded: Boolean, + interactive: Boolean, +): Boolean = expanded && interactive + +/** + * The next selectable row [from] the current one, or null at the list boundary. + * + * The menu drives its own d-pad walk rather than leaving it to Compose's focus + * search: the search would leave the popup once the next row was off-screen, + * stranding every option below the fold and — because each row only scrolls + * itself into view when it gains focus — never scrolling the list at all. + * Disabled rows (the "Unknown" audio fallback) are stepped over, not landed on. + */ +internal fun nextSelectorMenuIndex( + options: List, + from: Int, + forward: Boolean, +): Int? { + val step = if (forward) 1 else -1 + var candidate = from + step + while (candidate in options.indices) { + if (options[candidate].enabled) return candidate + candidate += step + } + return null +} + +/** + * Where the menu must scroll to so the row at [rowTop]..[rowTop] + [rowHeight] + * is fully on screen, given the current [scroll] offset and [viewport] height. + * + * The menu scrolls itself rather than leaving it to `bringIntoView`: measured on + * a Google TV Streamer, focus moved onto the below-fold rows correctly while the + * requester scrolled nothing at all, so every row past the tenth stayed off + * screen even though it held focus. Returns [scroll] unchanged when the row is + * already visible, so an ordinary d-pad step does not jitter the list. + */ +internal fun selectorMenuScrollTarget( + scroll: Int, + rowTop: Int, + rowHeight: Int, + viewport: Int, + maxValue: Int, +): Int { + if (viewport <= 0 || rowHeight <= 0) return scroll + val target = when { + rowTop < scroll -> rowTop + rowTop + rowHeight > scroll + viewport -> rowTop + rowHeight - viewport + else -> scroll + } + return target.coerceIn(0, maxOf(0, maxValue)) +} + +/** + * Index the menu should focus when it opens: the selected row, else the first + * selectable one, or -1 when there is nothing selectable at all. + * + * Returning 0 for a list with no enabled rows would aim focus at a disabled + * one, which cannot take it — the request fails silently and the menu opens + * with focus nowhere. + */ +internal fun initialSelectorMenuIndex(options: List): Int { + val selected = options.indexOfFirst { it.selected && it.enabled } + if (selected >= 0) return selected + return options.indexOfFirst { it.enabled } +} + /** * A secondary `.compact` squared pill that opens an anchored dropdown of * [options]. Trigger layout mirrors tvOS `TVSelectorButton` at tvOS÷2 scale @@ -81,10 +182,20 @@ fun TvAnchoredSelectorMenu( triggerFocusRequester: FocusRequester? = null, interactive: Boolean = true, ) { - var expanded by remember { mutableStateOf(false) } + var expansionRequested by remember { mutableStateOf(false) } + // Derived, not deferred: a LaunchedEffect would leave the dropdown drawn + // over a trigger that has already stopped being interactive for the frame + // it takes the effect to run. The effect below still clears the stored bit + // so interactivity returning does not re-open a menu the viewer never + // asked for a second time. + val expanded = selectorExpansionAfterInteractivityChange(expansionRequested, interactive) + LaunchedEffect(interactive) { + expansionRequested = selectorExpansionAfterInteractivityChange(expansionRequested, interactive) + } // Use the caller's requester when provided (Task 4 directs selector-row // focus to a specific trigger); otherwise a private one for focus-restore. val triggerFr = triggerFocusRequester ?: remember { FocusRequester() } + val menuScrollState = rememberScrollState() // Wrapping the trigger and the DropdownMenu in the same Box anchors the // popup at the trigger's layout position (the menu inherits the anchor's @@ -92,9 +203,19 @@ fun TvAnchoredSelectorMenu( Box(modifier = modifier) { SquaredPillSurface( kind = PillKind.Secondary, - onClick = { if (interactive) expanded = true }, + onClick = { if (interactive) expansionRequested = true }, modifier = Modifier, focusRequester = triggerFr, + // Deliberately NOT `enabled = interactive`. A single-choice pill is + // not a disabled control — it is Apple's `TVSelectorValue`, a value + // display that stays focusable and simply does nothing on Select. + // `SquaredPillSurface` routes `enabled` into `Modifier.clickable`, + // and a disabled clickable is also unfocusable, so handing it + // `interactive` would drop the pill out of D-pad traversal. Most + // titles have one version and one audio track, so that would strand + // the row: three pills drawn, none reachable, and Down from the + // action row skipping the whole cluster. The chevron below is + // hidden instead, which is what tells the viewer it will not open. // Secondary .compact pill body padding, tvOS 40×22pt → 20×11dp, // +2/+1 per design review. contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp), @@ -149,69 +270,256 @@ fun TvAnchoredSelectorMenu( } } - // Known limitation: this is the phone Material3 DropdownMenu rather - // than a TV-native popup. Its items are focusable clickables, so d-pad - // up/down + OK work inside the popup, but it lacks the TV focus - // grammar (scale/border) of the rest of the module. A TV-styled - // anchored popup would need a bespoke Popup — deliberate deferral, - // audit 2026-07-20. + // The Material3 DropdownMenu is kept only as the anchored popup host + // (positioning under the trigger, focus capture, dismiss-on-Back); its + // own surface is made transparent and the content draws the same + // Skyline glass panel, dim uppercase header, inverted-capsule rows and + // hint footer as the top-bar cascade / For You selector, so every + // dropdown in the app reads as one component. DropdownMenu( - expanded = interactive && expanded, + expanded = expanded, onDismissRequest = { - expanded = false + expansionRequested = false // Guard: the trigger may have left composition (selector row // reloaded on selection) — requesting focus then throws. runCatching { triggerFr.requestFocus() } }, - containerColor = DarkSurfaceElevated, + offset = DpOffset(0.dp, SelectorMenuGap), + containerColor = Color.Transparent, tonalElevation = 0.dp, - shadowElevation = 18.dp, + shadowElevation = 0.dp, + shape = RectangleShape, ) { - options.forEach { option -> - val labelText = if (option.detail.isBlank()) { - option.title - } else { - "${option.title} — ${option.detail}" + // Own both halves of the walk: which row takes focus, and where the + // list has to scroll for it to be visible. Compose's own focus + // search leaves the popup once the next row is off-screen, and + // BringIntoViewRequester was measured on a Google TV Streamer to + // scroll this menu not at all — so a row could hold focus while + // staying below the fold, which is what stranded every option past + // the tenth for two release candidates. + val rowTops = remember(options) { mutableStateMapOf() } + val rowHeights = remember(options) { mutableStateMapOf() } + val rowFocusRequesters = remember(options) { List(options.size) { FocusRequester() } } + var focusedIndex by remember(options) { mutableStateOf(initialSelectorMenuIndex(options)) } + LaunchedEffect(options) { + if (focusedIndex < 0) return@LaunchedEffect + rowFocusRequesters.getOrNull(focusedIndex)?.let { requester -> + runCatching { requester.requestFocus() } } - DropdownMenuItem( - modifier = Modifier.semantics { this.selected = option.selected }, - enabled = option.enabled, - text = { - androidx.compose.material3.Text( - text = labelText, - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.Medium, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + } + Column( + // focusGroup is load-bearing, not decoration: key modifiers only + // see events on nodes that sit in the focus hierarchy, so without + // it the handler below is never called and the d-pad falls + // straight through to Compose's own focus search. + modifier = Modifier + .widthIn(min = CascadeLibraryColumnWidth, max = TvCascadeSelectorMaxPanelWidth) + .tvSkylinePanelChrome() + .padding(CascadePanelPadding) + .focusGroup() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val forward = when (event.key) { + Key.DirectionDown -> true + Key.DirectionUp -> false + else -> return@onPreviewKeyEvent false + } + val next = nextSelectorMenuIndex(options, focusedIndex, forward) + if (next != null) { + rowFocusRequesters.getOrNull(next)?.let { requester -> + runCatching { requester.requestFocus() } + } + } + // Consume at the boundary too: a d-pad press that runs off + // the end must stay put rather than leak to the screen the + // menu is covering. + true }, - leadingIcon = if (option.selected) { - { - androidx.compose.material3.Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), + ) { + CascadePanelHeader(label.uppercase()) + // The rows scroll inside a capped list while the header and + // footer stay pinned — a long subtitle list would otherwise + // grow the panel past the bottom of the screen. + Box { + Column( + modifier = Modifier + .heightIn(max = SelectorMenuMaxListHeight) + .selectorMenuEdgeFade( + fadeTop = menuScrollState.canScrollBackward, + fadeBottom = menuScrollState.canScrollForward, + ) + .verticalScroll(menuScrollState), + ) { + options.forEachIndexed { index, option -> + val interactionSource = remember(option.key) { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(focused, rowTops[index], rowHeights[index]) { + if (!focused) return@LaunchedEffect + focusedIndex = index + val target = selectorMenuScrollTarget( + scroll = menuScrollState.value, + rowTop = rowTops[index] ?: return@LaunchedEffect, + rowHeight = rowHeights[index] ?: return@LaunchedEffect, + viewport = menuScrollState.viewportSize, + maxValue = menuScrollState.maxValue, ) + if (target != menuScrollState.value) menuScrollState.animateScrollTo(target) } - } else { - null - }, - colors = MenuDefaults.itemColors( - textColor = PrairieOnSurface, - leadingIconColor = PrairieOnSurface, - disabledTextColor = PrairieOnSurface.copy(alpha = 0.38f), - disabledLeadingIconColor = PrairieOnSurface.copy(alpha = 0.38f), + SelectorMenuRow( + option = option, + focused = focused, + interactionSource = interactionSource, + modifier = Modifier + .focusRequester(rowFocusRequesters[index]) + .onGloballyPositioned { coords -> + // positionInParent is content-space: it does not + // move when the menu scrolls, so it is a stable + // scroll target. + rowTops[index] = coords.positionInParent().y.toInt() + rowHeights[index] = coords.size.height + }, + onClick = { + option.onSelect() + expansionRequested = false + runCatching { triggerFr.requestFocus() } + }, + ) + } + } + // Make the overflow obvious: a fade plus chevron on whichever + // edge still has rows beyond it. + SelectorMenuScrollEdge( + visible = menuScrollState.canScrollBackward, + top = true, + modifier = Modifier.align(Alignment.TopCenter), + ) + SelectorMenuScrollEdge( + visible = menuScrollState.canScrollForward, + top = false, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + CascadePanelFooter(caption = "Press selects · Back closes") + } + } + } +} + +private val SelectorMenuGap = 6.dp + +/** Six rows of options; anything longer scrolls within the panel. */ +private val SelectorMenuMaxListHeight = 230.dp +private val SelectorMenuScrollEdgeHeight = 26.dp + +/** + * Fades the rows out toward whichever edge still has more of them, by masking + * the list's own pixels (DstIn) rather than painting a colour over it — a + * painted fade can never quite match the panel's translucent gradient and + * shows up as a band. + */ +private fun Modifier.selectorMenuEdgeFade(fadeTop: Boolean, fadeBottom: Boolean): Modifier { + if (!fadeTop && !fadeBottom) return this + return this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fade = SelectorMenuScrollEdgeHeight.toPx() + if (fadeTop) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black), + startY = 0f, + endY = fade, ), - onClick = { - option.onSelect() - expanded = false - runCatching { triggerFr.requestFocus() } - }, + size = Size(size.width, fade), + blendMode = BlendMode.DstIn, ) } + if (fadeBottom) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = size.height - fade, + endY = size.height, + ), + topLeft = Offset(0f, size.height - fade), + size = Size(size.width, fade), + blendMode = BlendMode.DstIn, + ) + } + } +} + +/** Chevron over the list edge that still has rows beyond it. */ +@Composable +private fun SelectorMenuScrollEdge(visible: Boolean, top: Boolean, modifier: Modifier = Modifier) { + if (!visible) return + Icon( + imageVector = if (top) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = PrairieOnSurface.copy(alpha = 0.7f), + modifier = modifier.size(14.dp), + ) +} + +/** + * One option row, drawn with the cascade's row chrome (see `CascadeRowChrome`): + * a leading check slot (kept even when unselected so titles stay aligned, the + * way the cascade's leading icon does), the title in semibold and the detail + * dimmed, inverting to a solid [PrairieOnSurface] capsule on focus. Disabled rows + * are dimmed and skipped by focus. + */ +@Composable +private fun SelectorMenuRow( + option: TvSelectorOption, + focused: Boolean, + interactionSource: MutableInteractionSource, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val visual = tvSelectorRowVisualState(focused, option.selected, option.enabled) + val shape = RoundedCornerShape(CascadeRowCornerRadius) + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .clip(shape) + .background(visual.container) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = option.enabled, + onClick = onClick, + ) + .semantics { this.selected = option.selected } + .padding(horizontal = CascadeRowPaddingHorizontal, vertical = CascadeRowPaddingVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = if (option.selected) visual.content else Color.Transparent, + modifier = Modifier.size(CascadeRowIconSize), + ) + Text( + text = option.title, + color = visual.content, + fontWeight = FontWeight.SemiBold, + fontSize = CascadeRowTextSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (option.detail.isNotBlank()) { + Text( + text = option.detail, + color = visual.content.copy(alpha = 0.6f), + fontWeight = FontWeight.Medium, + fontSize = CascadeRowTextSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt index 20b6b90b2..aee7a6e7c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt @@ -131,18 +131,19 @@ fun AuroraJourneyProgress( } } -/** Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". */ +/** + * Gold-hairline + mono-caps step label, e.g. "STEP 01 — CONNECT". + * + * The hairline is mirrored on both sides so the label sits on the row's centre + * line. Every eyebrow in the auth flow is laid out in a `CenterHorizontally` + * column under a centered title; with a leading rule only, the row centres but + * the *text* does not, and the eyebrow visibly hangs right of the title beneath + * it. Keep the pair symmetric if you restyle this. + */ @Composable fun AuroraEyebrow(text: String, modifier: Modifier = Modifier) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .width(46.dp) - .height(1.dp) - .background( - Brush.horizontalGradient(listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f))), - ), - ) + AuroraEyebrowRule(fadesTowardText = true) Spacer(Modifier.width(16.dp)) Text( text = text.uppercase(), @@ -152,9 +153,31 @@ fun AuroraEyebrow(text: String, modifier: Modifier = Modifier) { letterSpacing = 3.5.sp, color = AuroraAccent, ) + Spacer(Modifier.width(16.dp)) + AuroraEyebrowRule(fadesTowardText = false) } } +/** + * One 46dp hairline of the eyebrow. [fadesTowardText] runs the gradient solid + * at the outer edge and transparent at the inner one, so a mirrored pair reads + * as a single rule interrupted by the label. + */ +@Composable +private fun AuroraEyebrowRule(fadesTowardText: Boolean) { + val stops = if (fadesTowardText) { + listOf(AuroraAccent, AuroraAccent.copy(alpha = 0f)) + } else { + listOf(AuroraAccent.copy(alpha = 0f), AuroraAccent) + } + Box( + modifier = Modifier + .width(46.dp) + .height(1.dp) + .background(Brush.horizontalGradient(stops)), + ) +} + /** * Liquid-glass panel chrome (translucent plum tint + gradient hairline + top * sheen + soft drop shadow; optional gold halo). Compose has no backdrop blur, @@ -259,8 +282,8 @@ fun AuroraPrimaryButton( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - enabled = true, - onClick = { if (enabled) onClick() }, + enabled = enabled, + onClick = onClick, ) .padding(horizontal = 30.dp, vertical = 18.dp), contentAlignment = Alignment.Center, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt index 24c07ff05..ebd735dc9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.Favorite import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateMapOf @@ -82,10 +83,23 @@ internal val CascadeRowIconSize = 15.dp internal val CascadeRowPaddingHorizontal = 9.dp internal val CascadeRowPaddingVertical = 8.dp internal val CascadeRowCornerRadius = 7.dp + internal val CascadeFlyoutRowTextSize = 14.sp internal val CascadeFlyoutRowIconSize = 9.dp internal val CascadeFlyoutRowPaddingHorizontal = 8.dp internal val CascadeFlyoutRowPaddingVertical = 6.5.dp + +/** Resolves per-row state by stable identity while preserving the current display order. */ +internal fun stableIdentityValues( + ids: List, + valuesById: MutableMap, + create: () -> V, +): Map = buildMap { + ids.forEach { id -> + put(id, valuesById.getOrPut(id, create)) + } +} + internal val CascadeFlyoutRowCornerRadius = 6.dp private val CascadeRowSpacing = 7.dp @@ -118,7 +132,7 @@ fun TvForYouSelector( LaunchedEffect(entersPanel, focusEntryToken) { if (entersPanel && focusEntryToken > 0) { - runCatching { watchlistFocus.requestFocus() } + runCatching { recommendationsFocus.requestFocus() } } } @@ -130,12 +144,14 @@ fun TvForYouSelector( .focusGroup(), ) { CascadePanelHeader("FOR YOU") + // Recommendations first: it is the tab's landing content, so entry + // focus sits on what the viewer is already looking at. CascadeActionRow( - title = "Watchlist", - icon = Icons.Filled.Bookmark, + title = "Recommendations", + icon = Icons.Filled.AutoAwesome, entersPanel = entersPanel, - focusRequester = watchlistFocus, - onSelect = onWatchlist, + focusRequester = recommendationsFocus, + onSelect = onRecommendations, ) CascadeActionRow( title = "Favorites", @@ -145,11 +161,11 @@ fun TvForYouSelector( onSelect = onFavorites, ) CascadeActionRow( - title = "Recommendations", - icon = Icons.Filled.AutoAwesome, + title = "Watchlist", + icon = Icons.Filled.Bookmark, entersPanel = entersPanel, - focusRequester = recommendationsFocus, - onSelect = onRecommendations, + focusRequester = watchlistFocus, + onSelect = onWatchlist, ) CascadePanelFooter(isSingleLibrary = true) } @@ -178,7 +194,6 @@ fun TvForYouSelector( * returns to the anchored library row. * - **Select/Enter** on a library row commits that scope ([onCommitLibrary]); * on a section row commits scope + section ([onCommitSection]). - * - **Back/Escape** closes ([onClose]). */ @Composable fun TvCascadeSelector( @@ -191,7 +206,6 @@ fun TvCascadeSelector( onCommitLibrary: (UserLibrary) -> Unit, onCommitSection: (UserLibrary, TvLibraryPill) -> Unit, onPanelFocusChanged: (Boolean) -> Unit, - onClose: () -> Unit, /** Gates the Collections pill per anchored library (QA 2026-07-08). */ libraryHasCollections: (Int) -> Boolean = { true }, modifier: Modifier = Modifier, @@ -201,6 +215,11 @@ fun TvCascadeSelector( // One stable FocusRequester per library id and per pill, surviving recomposition. val libraryRequesters = remember { mutableStateMapOf() } val pillRequesters = remember { mutableStateMapOf() } + val visibleLibraryRequesters = stableIdentityValues( + ids = libraries.map(UserLibrary::id), + valuesById = libraryRequesters, + create = ::FocusRequester, + ) // Each library row's top edge in the level-1 column's coordinate space; the // flyout offsets down to the anchored row's value to align tops (§5.3). @@ -328,37 +347,39 @@ fun TvCascadeSelector( if (!isSingleLibrary) { val rowsContent: @Composable () -> Unit = { libraries.forEach { library -> - val requester = libraryRequesters.getOrPut(library.id) { FocusRequester() } - CascadeLibraryRow( - library = library, - type = type, - isCurrent = library.id == currentScopeId, - entersPanel = entersPanel, - focusRequester = requester, - onFocusChanged = { focused -> - focusedRowId = if (focused) { - library.id - } else { - focusedRowId.takeUnless { it == library.id } - } - }, - onTopChanged = { top -> rowTops[library.id] = top }, - onMoveRight = { - anchorId = library.id - val firstPill = pills.firstOrNull() - if (firstPill != null) { - flyoutVisible = true - focusFirstPillToken++ + key(library.id) { + val requester = visibleLibraryRequesters.getValue(library.id) + CascadeLibraryRow( + library = library, + type = type, + isCurrent = library.id == currentScopeId, + entersPanel = entersPanel, + focusRequester = requester, + onFocusChanged = { focused -> + focusedRowId = if (focused) { + library.id + } else { + focusedRowId.takeUnless { it == library.id } + } + }, + onTopChanged = { top -> rowTops[library.id] = top }, + onMoveRight = { + anchorId = library.id + val firstPill = pills.firstOrNull() + if (firstPill != null) { + flyoutVisible = true + focusFirstPillToken++ + true + } else { + false + } + }, + onSelect = { + onCommitLibrary(library) true - } else { - false - } - }, - onSelect = { - onCommitLibrary(library) - true - }, - ) + }, + ) + } } } @@ -374,8 +395,8 @@ fun TvCascadeSelector( state = lazyListState, modifier = Modifier.heightIn(max = CascadeMaxListHeight), ) { - items(libraries) { library -> - val requester = libraryRequesters.getOrPut(library.id) { FocusRequester() } + items(libraries, key = { it.id }) { library -> + val requester = visibleLibraryRequesters.getValue(library.id) CascadeLibraryRow( library = library, type = type, @@ -473,7 +494,7 @@ fun TvCascadeSelector( } @Composable -private fun CascadePanelHeader(text: String) { +internal fun CascadePanelHeader(text: String) { Text( text = text, color = PrairieOnSurface.copy(alpha = 0.38f), @@ -508,11 +529,18 @@ private fun CascadeFlyoutHeader(text: String) { @Composable private fun CascadePanelFooter(isSingleLibrary: Boolean) { - val caption = if (isSingleLibrary) { - "Press opens the section · Menu closes" - } else { - "Press opens the library · → jumps to a section · Menu closes" - } + CascadePanelFooter( + caption = if (isSingleLibrary) { + "Press opens the section · Menu closes" + } else { + "Press opens the library · → jumps to a section · Menu closes" + }, + ) +} + +/** Hairline + hint caption closing a Skyline panel; shared with the anchored selector menu. */ +@Composable +internal fun CascadePanelFooter(caption: String) { Column(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt index a85f94905..084c9c5a3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCatalogGrid.kt @@ -23,10 +23,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer @@ -39,14 +41,8 @@ import androidx.tv.material3.Text import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.overlays.OverlayDataExtractor import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec +import org.prairieserver.prairie.tv.ui.theme.rememberTvGridBringIntoViewSpec import org.prairieserver.prairie.tv.ui.util.tvArtworkAspectRatioForMediaType -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.tv.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.Spacer /** * Poster grid with automatic pagination. Fed by a [List] of @@ -80,6 +76,37 @@ fun TvCatalogGrid( verticalSpacing: Dp = 32.dp, firstItemFocusRequester: FocusRequester? = null, firstItemCardModifier: Modifier = Modifier, + /** + * Return-restoration plumbing, for surfaces that restore focus to the card + * a detail page was opened from. + * + * The requester goes on [restoreItemIndex], and that card reports back + * which identity it is bound to — a card can be laid out while its modifier + * still carries the previous binding, so nothing else can say. Callers + * that do not restore leave these alone and keep the first-item behaviour. + */ + restoreItemIndex: Int = -1, + restoreItemFocusRequester: FocusRequester? = null, + // Two caveats this component cannot remove on its own. + // + // The restorer's fallback is chosen during composition while attachment is + // only known after it, so there is a one-recomposition window either way: + // a just-detached requester still named, or a just-attached one not yet. + // Tolerable, because that failure is a call returning false followed by + // normal entry rather than focus landing somewhere wrong. + // + // And restoreItemIndex is a POSITION. If the list reorders after the caller + // resolved it, the requester follows the index onto a different item. + // Callers whose data only appends are unaffected; one that re-sorts in + // place must re-resolve rather than trust a frozen index. + onRestoreRequesterAttached: (String?) -> Unit = {}, + /** + * Both focus edges, not just the gain. A caller confirming a restoration + * has to know what holds focus NOW: reporting only arrivals makes its + * record sticky, so a card passed over on the way somewhere else still + * looks like the current position long after focus has moved on. + */ + onItemFocusedAtIndex: (item: BrowseItem, index: Int, focused: Boolean) -> Unit = { _, _, _ -> }, artworkAspectRatioForItem: (BrowseItem) -> Float? = { item -> tvArtworkAspectRatioForMediaType(item.type) }, @@ -89,6 +116,28 @@ fun TvCatalogGrid( emptyState: (@Composable () -> Unit)? = null, ) { val resolvedGridState = gridState ?: rememberLazyGridState() + // Keyed lazy lists throw on a repeated key, which is fatal. Paging can + // hand the same item back across pages, so the grid guarantees uniqueness + // itself rather than trusting every caller to. + val uniqueItems = remember(items) { items.distinctBy { it.contentId } } + // The caller speaks positions in the list it handed us; we render the + // deduplicated one. Translate on both edges — the incoming restore index + // through contentId into our list, and outgoing focus reports back into + // the caller's list — so a duplicate earlier in the feed cannot shift + // either side's arithmetic. distinctBy keeps first occurrences, so the + // first raw index of a rendered item is the item itself. + val resolvedRestoreItemIndex = remember(items, uniqueItems, restoreItemIndex) { + items.getOrNull(restoreItemIndex)?.contentId + ?.let { id -> uniqueItems.indexOfFirst { it.contentId == id } } + ?: -1 + } + val rawIndexByContentId = remember(items) { + buildMap { + items.forEachIndexed { rawIndex, item -> + putIfAbsent(item.contentId, rawIndex) + } + } + } // Backoff gate against an endless load-more retry storm. When a load-more // completes without growing the list while the server still reports more @@ -103,19 +152,19 @@ fun TvCatalogGrid( // Trigger pagination when the user is within 6 items of the end. The // `loadMoreRequestedSize` guard is read inside the derived state so a failed // page (size unchanged) stays gated until a retry or a successful growth. - val shouldLoadMore by remember(items.size, hasMore, isLoading) { + val shouldLoadMore by remember(uniqueItems.size, hasMore, isLoading) { derivedStateOf { - if (!hasMore || isLoading || items.isEmpty()) return@derivedStateOf false - if (items.size == loadMoreRequestedSize) return@derivedStateOf false + if (!hasMore || isLoading || uniqueItems.isEmpty()) return@derivedStateOf false + if (uniqueItems.size == loadMoreRequestedSize) return@derivedStateOf false val lastVisible = resolvedGridState.layoutInfo.visibleItemsInfo .lastOrNull()?.index ?: return@derivedStateOf false - lastVisible >= items.size - loadMoreThreshold + lastVisible >= uniqueItems.size - loadMoreThreshold } } LaunchedEffect(shouldLoadMore) { if (shouldLoadMore) { - loadMoreRequestedSize = items.size + loadMoreRequestedSize = uniqueItems.size onLoadMore() } } @@ -126,19 +175,40 @@ fun TvCatalogGrid( // list back to page size while keeping the same first item) — so a fresh // list is never mistaken for a stalled page. A failed load-more changes // neither key, so the gate correctly holds until the retry footer is used. - LaunchedEffect(items.firstOrNull()?.contentId, items.size) { + LaunchedEffect(uniqueItems.firstOrNull()?.contentId, uniqueItems.size) { loadMoreRequestedSize = -1 } // A page we requested has settled (not loading) without adding items while // the server still claims more — treat as a stalled/failed load-more and // offer an explicit, focusable retry instead of silently re-firing. + // WHICH card is holding the restore requester, not merely whether one is. + // A Boolean cannot express ownership, and pagination moves the restore + // index while both the old and new cards are briefly composed: if the new + // card attaches before the old one disposes, the old disposal erases a live + // attachment and the fallback silently reverts. + var attachedRestoreItemId by remember { mutableStateOf(null) } + // Tracked the same way for the first item, so the fallback below names a + // requester some card is actually holding. + // + // Worth being accurate about the size of this. An UNATTACHED requester is + // benign: it returns false and Compose carries on with normal entry, which + // is where Default would have arrived anyway. What is not benign is a + // requester attached to the WRONG card, and that is what the identity + // ownership above prevents. Gating the fallback on attachment only avoids + // a pointless failed call. + var attachedFirstItemId by remember { mutableStateOf(null) } + val loadMoreStalled = hasMore && !isLoading && - items.isNotEmpty() && - loadMoreRequestedSize == items.size + uniqueItems.isNotEmpty() && + loadMoreRequestedSize == uniqueItems.size - CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberTvGridBringIntoViewSpec( + contentPadding.calculateTopPadding(), + ), + ) { LazyVerticalGrid( state = resolvedGridState, columns = fixedColumnCount?.let { GridCells.Fixed(it) } @@ -152,7 +222,17 @@ fun TvCatalogGrid( // explicit first-item requester (or Compose's default first-focusable // search) the very first time, before anything has been remembered. modifier = modifier.focusRestorer( - firstItemFocusRequester ?: FocusRequester.Default, + // The restore requester only once a card is genuinely holding it. + // It displaces the first-item requester on its card, so naming that + // as the fallback would point the restorer at a requester nothing + // is attached to — and an index alone does not prove attachment, + // since a deep target sits outside lazy composition until scrolled + // to. Compose calls this fallback directly when restoration fails, + // and an unattached requester returns false, dropping the whole + // thing into an ordinary focus search. + restoreItemFocusRequester?.takeIf { attachedRestoreItemId != null } + ?: firstItemFocusRequester?.takeIf { attachedFirstItemId != null } + ?: FocusRequester.Default, ), ) { if (header != null) { @@ -161,7 +241,7 @@ fun TvCatalogGrid( } } - if (items.isEmpty() && !isLoading && emptyState != null) { + if (uniqueItems.isEmpty() && !isLoading && emptyState != null) { item(span = { GridItemSpan(maxLineSpan) }) { Box( modifier = Modifier @@ -174,11 +254,42 @@ fun TvCatalogGrid( } } else { itemsIndexed( - items = items, + // See TvMediaRow: a repeated contentId is fatal to a keyed + // lazy list, and paging can hand the same item back twice. + items = uniqueItems, key = { _, item -> item.contentId }, contentType = { _, item -> item.type }, ) { index, item -> val (actions, userState) = rememberTvBrowseItemCardActions(item) + val isRestoreTarget = + restoreItemFocusRequester != null && index == resolvedRestoreItemIndex + if (isRestoreTarget) { + // Keyed on the callback as well as the item: an owner + // change while the same card survives has to re-announce + // the existing attachment, or the new owner only ever hears + // about it when it goes away. + DisposableEffect(item.contentId, onRestoreRequesterAttached) { + attachedRestoreItemId = item.contentId + onRestoreRequesterAttached(item.contentId) + onDispose { + // Only if this effect still owns the attachment. A + // successor that attached first must not be undone + // by its predecessor's teardown. + if (attachedRestoreItemId == item.contentId) { + attachedRestoreItemId = null + onRestoreRequesterAttached(null) + } + } + } + } + if (firstItemFocusRequester != null && index == 0 && !isRestoreTarget) { + DisposableEffect(item.contentId) { + attachedFirstItemId = item.contentId + onDispose { + if (attachedFirstItemId == item.contentId) attachedFirstItemId = null + } + } + } TvMediaCard( title = item.title, posterUrl = item.posterUrl, @@ -189,9 +300,25 @@ fun TvCatalogGrid( onClick = { onBrowseItemClick?.invoke(item) ?: onItemClick(item.contentId) }, fillWidth = true, artworkAspectRatio = artworkAspectRatioForItem(item), - focusRequester = firstItemFocusRequester.takeIf { index == 0 }, + focusRequester = if (isRestoreTarget) { + restoreItemFocusRequester + } else { + firstItemFocusRequester.takeIf { index == 0 } + }, cardModifier = if (index == 0) firstItemCardModifier else Modifier, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + // hasFocus, not isFocused: this modifier lands on the + // card's outer Column while the Material Card inside it + // owns focus, so isFocused is never true here and the + // helper would never see a card take focus at all. + .onFocusChanged { + onItemFocusedAtIndex( + item, + rawIndexByContentId[item.contentId] ?: index, + it.hasFocus, + ) + }, overlay = OverlayDataExtractor.fromBrowseItem(item), actions = actions, ) @@ -257,12 +384,6 @@ private fun TvLoadMoreRetryFooter(onRetry: () -> Unit) { onClick = onRetry, contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Retry", style = MaterialTheme.typography.labelLarge) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt index eb2cfaf79..f8d76cdab 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt @@ -7,31 +7,74 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager import kotlinx.coroutines.delay +import org.prairieserver.prairie.tv.ui.focus.TvFocusAcquisitionBudgetMillis +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved + +private const val TvDialogInitialFocusRetryDelayMillis = 60L + +internal const val TvDialogInitialFocusMaxAttempts = + (TvFocusAcquisitionBudgetMillis / TvDialogInitialFocusRetryDelayMillis).toInt() + +internal suspend fun requestTvDialogInitialFocus( + awaitAttempt: suspend () -> Unit, + isOverlayFocused: () -> Boolean, + requestFocus: () -> Boolean, +): TvObservedFocusResult = requestFocusUntilObserved( + maxAttempts = TvDialogInitialFocusMaxAttempts, + awaitAttempt = awaitAttempt, + requestFocus = requestFocus, + isFocused = isOverlayFocused, +) /** - * Retry-until-focused initial focus for popup overlays. + * Bounded retry-until-observed initial focus for popup overlays. + * + * Attach the returned modifier to the overlay content root. Focus on any child + * completes acquisition; the retry cadence divides + * [TvFocusAcquisitionBudgetMillis] into fixed attempts. Leaving composition + * cancels the effect through structured concurrency. + * + * Exhausting the budget must not end in a dead D-pad, which is the failure the + * whole policy exists to prevent — so a last resort asks the focus system to + * enter the overlay by traversal. That works even when [target] never became + * focusable (an all-disabled option list, a control that left the graph while + * the request was in flight), which is exactly when the retries run out. * - * A Popup window's focus lags composition on TV (Shield-class devices), so a - * single delayed `requestFocus()` often fires before the window is focusable - * and silently no-ops — the overlay opens with NOTHING focused and the D-pad - * is dead (issue #64's root cause, originally fixed only in the PIN keypad). - * This keeps requesting [target] until anything inside the overlay holds - * focus, then stops so it never fights the user's navigation (including a - * user who reached a different control before the first grab landed). + * [reacquireKey] re-runs acquisition when an overlay swaps its own body — a + * dialog that replaces its form with a progress view renders zero focusables + * for a while, and the form coming back needs focus again or the overlay is + * dead to the D-pad. Overlays with one fixed body leave it alone. Re-acquisition + * checks focus first, so a viewer already inside the overlay is not dragged back + * to the first row by an unrelated key change. * - * Attach the returned [Modifier] to the overlay's content root: - * `Column(modifier = rememberTvDialogInitialFocus(firstRowFocus)) { ... }`. + * [enabled] suppresses acquisition for a body that has nothing to focus at all + * (an in-flight "Submitting…" message). Without it those phases spend the whole + * budget requesting a target that is not in the tree and then ask the focus + * system to enter an overlay with nothing in it. */ @Composable -internal fun rememberTvDialogInitialFocus(target: FocusRequester): Modifier { +internal fun rememberTvDialogInitialFocus( + target: FocusRequester, + reacquireKey: Any? = Unit, + enabled: Boolean = true, +): Modifier { var overlayHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - while (!overlayHasFocus) { - runCatching { target.requestFocus() } - delay(60) + val focusManager = LocalFocusManager.current + LaunchedEffect(target, reacquireKey, enabled) { + if (!enabled || overlayHasFocus) return@LaunchedEffect + val result = requestTvDialogInitialFocus( + awaitAttempt = { delay(TvDialogInitialFocusRetryDelayMillis) }, + isOverlayFocused = { overlayHasFocus }, + requestFocus = target::requestFocus, + ) + if (result == TvObservedFocusResult.Exhausted && !overlayHasFocus) { + runCatching { focusManager.moveFocus(FocusDirection.Enter) } } } return Modifier.onFocusChanged { overlayHasFocus = it.hasFocus } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt index 72bcc7de6..368bca48c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvEpisodeCard.kt @@ -44,7 +44,7 @@ import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.tv.ui.theme.ProgressFill import org.prairieserver.prairie.tv.ui.theme.ProgressTrack import org.prairieserver.prairie.tv.ui.theme.RowDimens -import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults +import org.prairieserver.prairie.tv.ui.theme.siloCardDefaults /** * 16:9 thumbnail card for "Continue Watching", "Next Up", and episode list rows. @@ -78,8 +78,8 @@ fun TvEpisodeCard( val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val cardShape = RoundedCornerShape(8.dp) - val cardFocus = prairieCardDefaults(shape = cardShape, focusedScale = 1.04f) + val cardShape = TvEpisodeCardShape + val cardFocus = siloCardDefaults(shape = cardShape, focusedScale = 1.04f) val episodeBadge = formatEpisodeTag(seasonNumber, episodeNumber) var menuExpanded by remember { mutableStateOf(false) } @@ -228,3 +228,6 @@ private fun formatEpisodeTag(season: Int?, episode: Int?): String? { * that to Android TV as 180×100dp. */ val TvEpisodeCardWidth: Dp = RowDimens.BackdropWidth + +/** Hoisted so every card shares one instance instead of allocating a shape per composition. */ +private val TvEpisodeCardShape = RoundedCornerShape(8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFilterSheet.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFilterSheet.kt index 5c9d5dcca..12810a09e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFilterSheet.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFilterSheet.kt @@ -29,13 +29,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.theme.Spacing /** * Bottom-anchored slide-up filter sheet for the library detail screen. * Mirrors the tvOS TVLibraryFilterSheet pattern: a 60%-height surface - * with Genre / Year / Sort / Alphabet sections, focus-trapped, Back to - * dismiss. + * with Genre / Year / Sort / Alphabet sections, Back to dismiss. + * + * The sheet is a modal focus owner: D-pad movement cannot leave it for the + * page still composed behind the scrim. Callers are responsible for handing + * focus back to the control that opened it, via TvRestoreFocusOnModalDismiss — + * the sheet cannot do that itself because its exit animation outlives its own + * dismissal. * * Sections are slotted by the caller via [content] so this component * stays generic; the library detail screen composes the actual filter @@ -82,11 +89,13 @@ fun TvFilterSheet( .align(Alignment.BottomStart), ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(visible) { - if (visible) { - runCatching { focusRequester.requestFocus() } - } - } + // The sheet slides in, so the first request lands before its + // controls are placed. Retry until focus is actually observed + // inside the sheet rather than firing once and hoping. + val sheetFocus = rememberTvContentInitialFocus( + target = focusRequester, + contentKey = visible.takeIf { it }, + ) BackHandler(enabled = visible, onBack = onDismiss) @@ -99,7 +108,8 @@ fun TvFilterSheet( horizontal = Spacing.safeArea, vertical = Spacing.xl, ) - .focusGroup() + .then(sheetFocus) + .tvModalFocusBoundary() .focusRequester(focusRequester), verticalArrangement = Arrangement.spacedBy(Spacing.lg), ) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt index 67a04f827..0f48d2eec 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarquee.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding @@ -29,6 +30,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -123,9 +125,12 @@ private fun TvMarqueeBlock( animationSpec = tween(TvMarqueeCrossfadeMs, easing = TvMarqueeEasing), label = "marqueeLogoAlpha", ) + // 6dp rows and an 84dp logo slot: the marquee viewport (screen minus the + // top-bar zone minus the row band) fits five text rows only if the block + // stays under ~206dp, and the format spec line is the fifth row. Column( modifier = Modifier.widthIn(max = MarqueeContentWidth), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { // Keep the semantic text title visible until transparent logo artwork // has actually decoded. A bad/slow URL therefore never creates a blank @@ -155,11 +160,16 @@ private fun TvMarqueeBlock( thumbhash = null, contentDescription = content.title, contentScale = ContentScale.Fit, + // Flush with the editorial text below it; the default + // centre alignment floated wide logos toward the middle + // of the block, away from the meta/synopsis left edge. + alignment = Alignment.CenterStart, transparent = true, crossfadeMillis = 0, onSuccess = { logoLoaded = true }, modifier = Modifier - .fillMaxSize() + .fillMaxHeight() + .widthIn(max = MarqueeLogoMaxWidth) .alpha(logoAlpha), ) } @@ -240,22 +250,40 @@ private fun TvMarqueeBlock( } } } + + // Format spec line: the resolution / dynamic-range / audio trio, kept + // as quiet text under the credits so the rating stays the only chip. + content.specLine?.let { spec -> + Text( + text = spec, + color = PrairieOnSurface.copy(alpha = 0.55f), + fontSize = MarqueeSpecSize, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + letterSpacing = MarqueeSpecSize * 0.04f, + lineHeight = MarqueeSpecSize * 1.2f, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.offset(y = (-6).dp), + ) + } } } @Composable private fun MarqueeBadge(label: String) { val shape = RoundedCornerShape(5.dp) + // Solid, not outlined: the content rating is the one chip on the hero + // and has to read before the meta text next to it. Box( modifier = Modifier .clip(shape) - .background(Color.White.copy(alpha = 0.14f)) - .border(1.dp, Color.White.copy(alpha = 0.24f), shape) - .padding(horizontal = 8.dp, vertical = 3.dp), + .background(Color.White.copy(alpha = 0.92f)) + .padding(horizontal = 9.dp, vertical = 3.dp), ) { Text( text = label, - color = Color.White.copy(alpha = 0.92f), + color = Color.Black.copy(alpha = 0.92f), fontSize = MarqueeBadgeSize, lineHeight = MarqueeBadgeSize * 1.25f, letterSpacing = MarqueeBadgeSize * 0.08f, @@ -270,10 +298,11 @@ private fun MarqueeBadge(label: String) { private val MarqueeContentWidth = 440.dp private val MarqueeSynopsisMaxWidth = 390.dp private val MarqueeLogoMaxWidth = 440.dp -private val MarqueeLogoMaxHeight = 95.dp +private val MarqueeLogoMaxHeight = 84.dp private val MarqueeDetailLineHeight = 20.dp private val MarqueeTitleSize = 44.sp private val MarqueeMetaSize = 14.sp private val MarqueeDetailSize = 14.sp private val MarqueeSynopsisSize = 16.sp private val MarqueeBadgeSize = 14.sp +private val MarqueeSpecSize = 13.sp diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt index 2c643706c..8873557be 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.OverlaySummary import org.prairieserver.prairie.model.section.SectionItem import kotlinx.coroutines.delay import java.text.SimpleDateFormat @@ -27,14 +28,20 @@ data class TvMarqueeContent( val id: String, val title: String, val logoUrl: String?, - /** Codec/HDR + content-rating chips (`4K`, `DOLBY VISION`, `ATMOS`). */ + /** Optional uppercase content-classification chip. */ val badges: List, - /** Dot-joined meta tokens after the badges: year · genre · runtime, or - * `S2 E7 · episode title · 45 min · 23m left` for episodes. */ + /** Dot-joined editorial metadata after the badge. */ val metaParts: List, val synopsis: String?, /** A quieter detail line: cast / air-date when carried by the payload. */ val detailLine: String?, + /** + * Playback format, `4K · Dolby Vision · EAC3 5.1`, from the section + * payload's overlay summary. Rendered as a muted spec line under the + * detail line rather than as chips, so the content rating stays the only + * badge on the hero and the format is there when the viewer looks for it. + */ + val specLine: String?, val backdropUrl: String?, val backdropThumbhash: String?, val posterUrl: String?, @@ -75,38 +82,45 @@ data class TvMarqueeContent( } companion object { - fun from(item: SectionItem, rowTitle: String): TvMarqueeContent { + fun from( + item: SectionItem, + rowTitle: String, + rowIdentity: String = rowTitle, + ): TvMarqueeContent { val isEpisode = item.type.equals("episode", ignoreCase = true) val meta = mutableListOf() if (isEpisode) { episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) if (item.title.isNotBlank()) meta.add(item.title) - lengthText(item.durationSeconds)?.let(meta::add) + lengthText(item.runtime, item.durationSeconds)?.let(meta::add) timeLeftText(item.positionSeconds, item.durationSeconds)?.let(meta::add) + ratingToken(item.ratingImdb)?.let(meta::add) } else { if (item.year > 0) meta.add(item.year.toString()) + lengthText(item.runtime, item.durationSeconds)?.let(meta::add) + ratingToken(item.ratingImdb)?.let(meta::add) item.genres.firstOrNull { it.isNotBlank() }?.let(meta::add) - lengthText(item.durationSeconds)?.let(meta::add) - item.ratingImdb?.let { meta.add(formatRating(it)) } + timeLeftText(item.positionSeconds, item.durationSeconds)?.let(meta::add) } - // Codec/HDR + content-rating chips (`4K · DOLBY VISION · ATMOS · - // TV-MA`) derived from the section payload's overlay summary, then - // the content rating — mirrors tvOS `TVFocusMarquee.badges(from:)`. - val badges = qualityBadges(item.overlaySummary).toMutableList() - item.contentRating?.takeIf { it.isNotBlank() }?.let { badges.add(it.uppercase()) } + val badges = item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let(::listOf) + .orEmpty() val sectionBackdropUrl = item.backdropUrl?.takeIf { it.isNotBlank() } val sectionPosterUrl = item.posterUrl?.takeIf { it.isNotBlank() } return TvMarqueeContent( - id = "$rowTitle#${item.contentId}", + id = "$rowIdentity#${item.contentId}", title = if (isEpisode) (item.seriesTitle ?: item.title) else item.title, logoUrl = item.logoUrl?.takeIf { it.isNotBlank() }, badges = badges, metaParts = meta, synopsis = item.overview?.takeIf { it.isNotBlank() }, detailLine = null, + specLine = specLine(item.overlaySummary), // Match tvOS: a section backdrop (or poster fallback) is always // available for the first rested frame. Episode enrichment may // replace it with series art later. @@ -123,77 +137,67 @@ data class TvMarqueeContent( ) } - /** - * Headline quality trio — resolution, dynamic range, audio — uppercased - * to the Skyline badge style, from the section payload's overlay summary. - * Mirrors tvOS `TVFocusMarquee.badges(from:)`. - */ - internal fun qualityBadges(summary: org.prairieserver.prairie.model.catalog.OverlaySummary?): List { - if (summary == null) return emptyList() - val badges = mutableListOf() - prettyResolution(summary.resolution)?.let(badges::add) - summary.hdr?.takeIf { it.isNotBlank() }?.let { hdr -> - badges.add(dynamicRangeBadge(hdr)) - } - summary.audio?.takeIf { it.isNotBlank() }?.let { audio -> - badges.add(audioBadge(audio)) - } - return badges.distinct() + private fun episodeToken(season: Int?, episode: Int?): String? = when { + season != null && episode != null -> "S$season E$episode" + season != null -> "Season $season" + episode != null -> "Episode $episode" + else -> null } - private fun dynamicRangeBadge(value: String): String { - val normalized = value.trim().lowercase(Locale.US) - return when { - normalized.contains("dolby vision") || - normalized.contains("dovi") || - Regex("(^|[^a-z])dv([^a-z]|$)").containsMatchIn(normalized) -> "DOLBY VISION" - normalized.contains("hdr10+") || normalized.contains("hdr10 plus") -> "HDR10+" - normalized.contains("hdr10") -> "HDR10" - normalized.contains("hlg") -> "HLG" - else -> value.trim().uppercase(Locale.US) - } + private fun timeLeftText(position: Double?, duration: Double?): String? { + if (position == null || duration == null || duration <= 0) return null + if (position <= 60 || position / duration >= 0.95) return null + val remaining = (((duration - position) / 60.0)).let { kotlin.math.ceil(it).toInt() }.coerceAtLeast(1) + return "$remaining min left" } - private fun audioBadge(value: String): String { - val normalized = value.trim().lowercase(Locale.US) - return when { - normalized.contains("atmos") || - Regex("(^|[^a-z])joc([^a-z]|$)").containsMatchIn(normalized) -> "ATMOS" - normalized.contains("dts-hd") || normalized.contains("dts hd") -> "DTS-HD" - normalized.contains("truehd") || normalized.contains("true hd") -> "TRUEHD" - normalized.contains("e-ac-3") || normalized.contains("eac3") -> "EAC3" - normalized.contains("ac-3") || normalized == "ac3" -> "AC3" - else -> value.trim().uppercase(Locale.US) + /** + * `4K · Dolby Vision · EAC3 5.1` from the payload's overlay summary — + * the same resolution / dynamic-range / audio trio the tvOS marquee + * shows, spelled out rather than uppercased since it is a text line + * here, not a chip row. + */ + internal fun specLine(summary: OverlaySummary?): String? { + if (summary == null) return null + val parts = mutableListOf() + prettyResolution(summary.resolution)?.let(parts::add) + summary.hdr?.trim()?.takeIf { it.isNotEmpty() }?.let { hdr -> + parts.add( + if (hdr.contains("dv", ignoreCase = true) || hdr.contains("dolby", ignoreCase = true)) { + "Dolby Vision" + } else { + hdr.uppercase(Locale.US) + }, + ) + } + summary.audio?.trim()?.takeIf { it.isNotEmpty() }?.let { audio -> + val codec = if (audio.contains("atmos", ignoreCase = true)) "Atmos" else audio.uppercase(Locale.US) + val channels = summary.audioChannels?.trim()?.takeIf { it.isNotEmpty() } + parts.add(if (channels != null) "$codec $channels" else codec) } + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } private fun prettyResolution(value: String?): String? { - val v = value?.takeIf { it.isNotBlank() } ?: return null - return when (v.lowercase()) { + val v = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return when (v.lowercase(Locale.US)) { "2160p", "4k", "uhd" -> "4K" "4320p", "8k" -> "8K" - else -> v.uppercase() + else -> v.uppercase(Locale.US) } } - private fun episodeToken(season: Int?, episode: Int?): String? = when { - season != null && episode != null -> "S$season E$episode" - season != null -> "Season $season" - episode != null -> "Episode $episode" - else -> null + /** Episode/movie length: the metadata runtime when present, else + * derived from the file duration the payload already carries. */ + private fun lengthText(runtimeMinutes: Int?, durationSeconds: Double?): String? { + runtimeText(runtimeMinutes)?.let { return it } + val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null + return runtimeText((duration / 60.0).roundToInt()) } - private fun timeLeftText(position: Double?, duration: Double?): String? { - if (position == null || duration == null || duration <= 0) return null - if (position <= 60 || position / duration >= 0.95) return null - val remaining = (((duration - position) / 60.0)).let { kotlin.math.ceil(it).toInt() }.coerceAtLeast(1) - return "${remaining}m left" - } - - private fun lengthText(durationSeconds: Double?): String? { - if (durationSeconds == null || durationSeconds <= 0) return null - val minutes = (durationSeconds / 60.0).roundToInt() - if (minutes <= 0) return null + private fun runtimeText(minutes: Int?): String? { + if (minutes == null || minutes <= 0) return null return if (minutes >= 60) { val hours = minutes / 60 val rest = minutes % 60 @@ -203,6 +207,12 @@ data class TvMarqueeContent( } } + private fun ratingToken(rating: Double?): String? = + validImdbRating(rating)?.let(::formatRating) + + private fun validImdbRating(rating: Double?): Double? = + rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } + private fun formatRating(rating: Double): String { val rounded = (rating * 10).roundToInt() / 10.0 return rounded.toString() @@ -287,14 +297,21 @@ class TvFocusMarqueeState internal constructor() { internal var candidate: TvMarqueeContent? by mutableStateOf(null) + internal var focusedMarqueeId: String? by mutableStateOf(null) + private set + + internal val hasSettledRealFocus: Boolean + get() = focusedMarqueeId != null && focusedMarqueeId == content?.id + /** Per-contentId enrichment cache (tvOS `enrichmentCache`) so scrubbing * back over a row never refetches item detail. Persists for the page. */ private val enrichmentCache = mutableMapOf() private val enrichmentRequests = mutableSetOf() /** Report card focus. The displayed content swaps on the next composition turn. */ - fun preview(item: SectionItem, rowTitle: String) { - val next = TvMarqueeContent.from(item, rowTitle) + fun preview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + focusedMarqueeId = next.id // Focus is back on the already-displayed card: cancel any pending swap // so a brief A→B→A scrub within the debounce window can't commit a // stale B after focus has returned to A. @@ -310,9 +327,11 @@ class TvFocusMarqueeState internal constructor() { * is only for page entry: once focus has produced displayed or pending * content, the seed is ignored so it never fights real navigation. */ - fun seedInitialPreview(item: SectionItem, rowTitle: String) { - if (content != null || candidate != null) return - candidate = TvMarqueeContent.from(item, rowTitle) + fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + if (focusedMarqueeId != null) return + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + if (candidate?.id == next.id || content?.id == next.id) return + candidate = next } internal fun commit(value: TvMarqueeContent?) { @@ -377,8 +396,9 @@ fun rememberTvFocusMarqueeState( // Populate the cache and enrich the active hero when identity still // matches. Near-viewport proactive prefetch usually wins this request; the // shared claim prevents duplicates when it is already in flight. - LaunchedEffect(state.content?.contentId, fetchDetail) { + LaunchedEffect(state.content?.id, state.focusedMarqueeId, fetchDetail) { val fetch = fetchDetail ?: return@LaunchedEffect + if (!state.hasSettledRealFocus) return@LaunchedEffect val contentId = state.content?.contentId ?: return@LaunchedEffect if (!state.beginEnrichmentRequest(contentId)) return@LaunchedEffect try { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvHomeHeroCarousel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvHomeHeroCarousel.kt index ff92e59c0..7a2337e2b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvHomeHeroCarousel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvHomeHeroCarousel.kt @@ -92,19 +92,23 @@ fun TvHomeHeroCarousel( onFocusEntered: () -> Unit = {}, onActiveItemChanged: (SectionItem) -> Unit = {}, ) { - if (items.isEmpty()) return + // See TvCatalogGrid: a repeated key is fatal to a keyed lazy list. Every + // index below addresses this list, not the caller's, so the card the + // carousel reports as active is the card it actually drew. + val uniqueItems = remember(items) { items.distinctBy { it.contentId } } + if (uniqueItems.isEmpty()) return val listState = rememberLazyListState() val scope = rememberCoroutineScope() val internalInitialFocusRequester = remember { FocusRequester() } val targetInitialFocusRequester = initialFocusRequester ?: internalInitialFocusRequester - var activeIndex by remember(items.map { it.contentId }) { mutableIntStateOf(0) } + var activeIndex by remember(uniqueItems.map { it.contentId }) { mutableIntStateOf(0) } var heroHasFocus by remember { androidx.compose.runtime.mutableStateOf(false) } - LaunchedEffect(items.map { it.contentId }) { - activeIndex = activeIndex.coerceIn(0, items.lastIndex) + LaunchedEffect(uniqueItems.map { it.contentId }) { + activeIndex = activeIndex.coerceIn(0, uniqueItems.lastIndex) listState.scrollToItem(activeIndex) - onActiveItemChanged(items[activeIndex]) + onActiveItemChanged(uniqueItems[activeIndex]) } LaunchedEffect(autoFocus) { @@ -122,14 +126,14 @@ fun TvHomeHeroCarousel( } LaunchedEffect(activeIndex) { - onActiveItemChanged(items[activeIndex]) + onActiveItemChanged(uniqueItems[activeIndex]) scope.launch { listState.animateScrollToItem(activeIndex) } } - LaunchedEffect(activeIndex, heroHasFocus, items.size) { - if (heroHasFocus || items.size <= 1) return@LaunchedEffect + LaunchedEffect(activeIndex, heroHasFocus, uniqueItems.size) { + if (heroHasFocus || uniqueItems.size <= 1) return@LaunchedEffect delay(HOME_HERO_AUTO_ADVANCE_MS) - activeIndex = (activeIndex + 1) % items.size + activeIndex = (activeIndex + 1) % uniqueItems.size } BoxWithConstraints( @@ -160,7 +164,9 @@ fun TvHomeHeroCarousel( .height(heroHeight), ) { itemsIndexed( - items, + // See TvMediaRow: a repeated contentId is fatal to a keyed + // lazy list. + uniqueItems, key = { _, item -> item.contentId }, contentType = { _, _ -> "hero-card" }, ) { index, item -> @@ -187,7 +193,7 @@ fun TvHomeHeroCarousel( } HeroPageIndicator( - total = items.size, + total = uniqueItems.size, activeIndex = activeIndex, modifier = Modifier .align(Alignment.BottomCenter) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareForm.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareForm.kt new file mode 100644 index 000000000..24a1e0fd3 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareForm.kt @@ -0,0 +1,486 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.PlatformTextInputInterceptor +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp + +internal data class TvImeRelocationKey( + val imeBottomPx: Int, + val fieldWidthPx: Int, + val fieldHeightPx: Int, +) + +internal fun tvImeRelocationKey( + hasFocus: Boolean, + imeBottomPx: Int, + fieldWidthPx: Int, + fieldHeightPx: Int, +): TvImeRelocationKey? = + if (hasFocus && imeBottomPx > 0 && fieldWidthPx > 0 && fieldHeightPx > 0) { + TvImeRelocationKey( + imeBottomPx = imeBottomPx, + fieldWidthPx = fieldWidthPx, + fieldHeightPx = fieldHeightPx, + ) + } else { + null + } + +internal fun shouldRestoreTvImeFormScroll( + previousImeBottomPx: Int, + currentImeBottomPx: Int, +): Boolean = previousImeBottomPx > 0 && currentImeBottomPx == 0 + +/** + * Keeps a focused TV text-field context clear of the stock platform IME. + * + * Apply this to the smallest container that includes the field's visible + * label. [focusGroup] lets the container observe focus held by its child text + * field without becoming an extra D-pad destination. + */ +@Composable +internal fun Modifier.tvImeAwareFieldContext( + bottomClearance: Dp = TvImeFieldBottomClearance, +): Modifier { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val density = LocalDensity.current + val imeBottomPx = WindowInsets.ime.getBottom(density) + val bottomClearancePx = with(density) { bottomClearance.toPx() } + var hasFocus by remember { mutableStateOf(false) } + var fieldSize by remember { mutableStateOf(IntSize.Zero) } + val relocationKey = tvImeRelocationKey( + hasFocus = hasFocus, + imeBottomPx = imeBottomPx, + fieldWidthPx = fieldSize.width, + fieldHeightPx = fieldSize.height, + ) + + LaunchedEffect(relocationKey, bottomClearancePx) { + val key = relocationKey ?: return@LaunchedEffect + withFrameNanos { } + runCatching { + bringIntoViewRequester.bringIntoView( + Rect( + left = 0f, + top = 0f, + right = key.fieldWidthPx.toFloat(), + bottom = key.fieldHeightPx + bottomClearancePx, + ), + ) + } + } + + return this + .bringIntoViewRequester(bringIntoViewRequester) + .onSizeChanged { fieldSize = it } + .onFocusEvent { hasFocus = it.hasFocus } + .focusGroup() +} + +/** + * The scrollable form the fields under one [TvSelectToShowImeHost] belong to. + * + * A registration slot rather than a parameter: the fields sit inside private + * card composables that would otherwise have to thread the scroll state down, + * and each auth route hosts exactly one form, so the slot is unambiguous. + * [Modifier.tvShowImeOnSelect] reads it to put the form back at the top when + * the D-pad tries to leave the topmost field upward. + */ +@Stable +internal class TvImeAwareFormScroll { + var scrollState by mutableStateOf(null) +} + +/** Absent for fields composed outside a [TvSelectToShowImeHost]. */ +internal val LocalTvImeAwareFormScroll = staticCompositionLocalOf { null } + +/** + * Owns scrolling for a TV form and returns it to its normal top position when + * the stock IME closes. Initial composition with a hidden IME is a no-op. + * + * Also registers the form with the host so its fields can reach it. + */ +@Composable +internal fun rememberTvImeAwareFormScrollState(): ScrollState { + val scrollState = rememberScrollState() + val density = LocalDensity.current + val imeBottomPx = WindowInsets.ime.getBottom(density) + var previousImeBottomPx by remember { mutableIntStateOf(imeBottomPx) } + + LaunchedEffect(imeBottomPx) { + if (shouldRestoreTvImeFormScroll(previousImeBottomPx, imeBottomPx)) { + scrollState.scrollTo(0) + } + previousImeBottomPx = imeBottomPx + } + + val formScroll = LocalTvImeAwareFormScroll.current + DisposableEffect(formScroll, scrollState) { + formScroll?.scrollState = scrollState + onDispose { + if (formScroll?.scrollState === scrollState) formScroll.scrollState = null + } + } + + return scrollState +} + +private val TvImeFieldBottomClearance = 32.dp + +/** + * Permission to raise the stock IME, shared by every field under one + * [TvSelectToShowImeHost]. + * + * Held by a token rather than a bare flag so a field can only close the gate it + * opened: focus moving between two fields interleaves their events, and an + * unconditional close from the field being left would revoke a permission the + * arriving field had already been granted. + */ +@Stable +internal class TvSelectToShowImeGate { + private var holder by mutableStateOf(null) + + /** True while some field has earned the keyboard with a completed SELECT. */ + val isOpen: Boolean + get() = holder != null + + fun open(token: Any) { + holder = token + } + + fun close(token: Any) { + if (holder === token || (holder as? Parked)?.token === token) holder = null + } + + /** + * Leaves [token]'s permission standing but unowned, so the field focus is + * moving *to* can [open] it for itself. + * + * The IME's own Next action moves focus while the keyboard is up; revoking + * on the way out would tear down the session the viewer is typing into and + * cost them a SELECT per field. The two orderings of a focus transfer are + * both covered: an arriving field that observes its focus first adopts a + * still-live holder, one that observes it second adopts the park. + */ + fun park(token: Any) { + if (holder === token) holder = Parked(token) + } + + /** Carries the parking field's token so only that field can drop it. */ + private class Parked(val token: Any) +} + +/** + * Absent by default so [Modifier.tvShowImeOnSelect] degrades to its reactive + * behavior when used outside a [TvSelectToShowImeHost] rather than crashing. + */ +internal val LocalTvSelectToShowImeGate = staticCompositionLocalOf { null } + +/** + * Refuses the platform text-input session for the fields inside it until a + * SELECT asks for one, so the stock IME is never raised in the first place. + * + * [Modifier.tvShowImeOnSelect] can only hide the keyboard *after* the field has + * asked for it — the `value: String` `BasicTextField` under every Material + * `OutlinedTextField` requests the IME on focus unconditionally. Hiding after + * the fact is a race, and losing it is visible: device logcat on a Shield + * caught the keyboard on screen for 120–270ms before the hide landed, which is + * the flash this fixes (`PrairieTvFocus`, 2026-08-15). Suppression therefore + * has to happen upstream of the request, which is what this does: block + * `startInputMethod` and the IMM is never told to start input at all, so there + * is nothing to flash. + * + * **The interceptor instance is the restart signal.** Once + * `interceptStartInputMethod` suspends in `awaitCancellation()`, flipping state + * the suspended body already read changes nothing — Compose re-reads the + * interceptor, not the body, and restarts the upstream session only when a + * *different* interceptor object is provided + * (`ChainedPlatformTextInputInterceptor` collects `snapshotFlow { interceptor }` + * with `collectLatest`). Hence `remember(allowIme)`, and hence the lambda + * capturing `allowIme` rather than reading the gate itself: both are needed for + * the gate flip to produce a new object, and without a new object SELECT would + * silently stop summoning the keyboard at all. + * + * Wrap the auth screens (see `TvAppNavigation`), not the whole app: fields that + * legitimately want the keyboard on focus — search, the text-entry dialogs — + * must stay outside. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun TvSelectToShowImeHost(content: @Composable () -> Unit) { + val gate = remember { TvSelectToShowImeGate() } + val formScroll = remember { TvImeAwareFormScroll() } + // Pointer users are exempt: a tap on a field is an explicit request to + // type, so the session is never withheld from them. Same product call the + // reactive half of the policy makes in tvShowImeOnSelect. + val touchMode = LocalInputModeManager.current.inputMode == InputMode.Touch + val allowIme = gate.isOpen || touchMode + + val interceptor = remember(allowIme) { + PlatformTextInputInterceptor { request, nextHandler -> + if (allowIme) { + TvFocusLog.d { "ime: platform session allowed (select or touch)" } + nextHandler.startInputMethod(request) + } else { + TvFocusLog.d { "ime: platform session blocked (focus without select)" } + // Never delegating is what blocks the request. The session stays + // suspended here until the gate flips and this instance is + // replaced, at which point the branch above runs instead. + awaitCancellation() + } + } + } + + CompositionLocalProvider( + LocalTvSelectToShowImeGate provides gate, + LocalTvImeAwareFormScroll provides formScroll, + ) { + InterceptPlatformTextInput(interceptor = interceptor, content = content) + } +} + +/** + * Summons the stock IME on SELECT/ENTER instead of on focus, and routes + * vertical D-pad out of the field so focus is never trapped in it. + * + * **Every** text field in the TV auth flow needs this, not just the one a + * screen focuses first: without it the field owns the vertical D-pad and the + * remote cannot leave it (verified on the emulator 2026-08-14 — the first-run + * admin form could not be completed at all). + * + * `KeyboardOptions(showKeyboardOnFocus = false)` does **not** deliver the + * focus half on its own. Compose foundation 1.8.0 documents the option as + * unsupported on the `value: String` overload of `BasicTextField` + * (`BasicTextField.kt:639` and `:796`), which is what every Material + * `OutlinedTextField` in this flow is built on — so the field still pops the + * IME the moment D-pad focus lands. Suppression therefore lives here, where + * this modifier can tell a focus arrival apart from a deliberate SELECT. + * Set the option anyway at the call sites: it is free, and it starts working + * on its own the day the fields move to the `TextFieldState` overload. + * + * The actual suppression is [TvSelectToShowImeHost]'s, which refuses the + * platform input session outright; this modifier only tells it which field has + * earned the keyboard. The reactive `hide()` below stays as a safety net for + * fields composed outside a host, where the gate is null — after the host + * landed it should never again see `visible=true` in the log. + * + * Pointer users are exempt from the suppression — a click on a field is an + * explicit request to type. Auth-flow field idiom (product call 2026-08-14). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun Modifier.tvShowImeOnSelect(): Modifier { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val inputMode = LocalInputModeManager.current.inputMode + // The visibility flag, not the inset height: Gboard TV is a floating panel + // whose IME inset source reports visible=true with a zero-height frame + // (Google TV emulator, 2026-08-16 — `dumpsys window` showed + // `type=ime frame=[0,1080][1920,1080] visible=true` with the keyboard on + // screen), so `ime.getBottom() > 0` reads false while the keyboard is up + // and the Next-keeps-the-keyboard handoff below never engages. + val imeVisible = WindowInsets.isImeVisible + // This modifier raises the IME, so it owns taking it back down when the + // field leaves composition — otherwise the keyboard floats over the next + // screen and keeps eating the D-pad. TvStockKeyboardPolicyTest pins the + // pairing; putting it here means every field using this gets it. + TvHideStockImeOnDispose() + + // Null when this field is composed outside a TvSelectToShowImeHost; the + // reactive suppression below then carries the policy on its own. + val imeGate = LocalTvSelectToShowImeGate.current + // Identifies this field to the shared gate for the lifetime of the node. + val gateToken = remember { Any() } + val formScrollState = LocalTvImeAwareFormScroll.current?.scrollState + val scope = rememberCoroutineScope() + + var hasFocus by remember { mutableStateOf(false) } + // True once SELECT summoned the keyboard on purpose, so the arrival + // suppression below leaves it alone until focus moves on. + var imeRequested by remember { mutableStateOf(false) } + + // Leaving composition with the gate still open would hand the next screen's + // fields a keyboard they never asked for. + DisposableEffect(imeGate, gateToken) { + onDispose { imeGate?.close(gateToken) } + } + + // Take back down whatever the field raised on a focus arrival this + // modifier did not ask for. Keyed on the IME insets as well as on focus so + // it is self-correcting: the field's own show request is asynchronous and + // can land after ours, and re-running the moment the keyboard actually + // surfaces closes that race without guessing a frame count. + LaunchedEffect(hasFocus, imeRequested, inputMode, imeVisible) { + if (!hasFocus || imeRequested || inputMode == InputMode.Touch) return@LaunchedEffect + withFrameNanos { } + keyboardController?.hide() + TvFocusLog.d { "field: focus arrived without select -> IME hidden (visible=$imeVisible)" } + } + + // A park exists only to survive the focus transfer that created it. One + // frame on, either the arriving sibling has claimed it or nothing will — + // leaving it standing would let the host allow a session no field asked + // for. Harmless on the initial composition: closing a gate we never held + // is a no-op. + LaunchedEffect(hasFocus) { + if (hasFocus) return@LaunchedEffect + withFrameNanos { } + imeGate?.close(gateToken) + } + + // A select must start AND end on this field to summon the IME. Acting on + // KeyUp alone leaks: activating a button whose click moves focus into the + // field (e.g. "Sign in with a password") delivers the tail KeyUp of that + // same press here and pops the keyboard uninvited — after which the D-pad + // drives the keyboard instead of the form. + val sawKeyDown = remember { java.util.concurrent.atomic.AtomicBoolean(false) } + return this + .onFocusEvent { focusState -> + // isFocused, not hasFocus: the field's own decoration hosts the + // password visibility button, and that button holding focus must + // not read as the editable field holding it. + val focused = focusState.isFocused + if (focused) { + // Focus arriving while the keyboard is already up and this + // form's permission is still live is the IME's own Next + // action moving between fields — the viewer is mid-entry, so + // adopt the session instead of tearing it down. A programmatic + // claim on a fresh screen cannot reach this: each auth route + // has its own host, so its gate starts closed. + if (imeVisible && imeGate?.isOpen == true) { + imeGate.open(gateToken) + imeRequested = true + } + } else { + imeRequested = false + // A SELECT that started here but ends elsewhere is not a + // select on this field; forgetting the KeyDown keeps a later + // stray KeyUp from summoning the keyboard on its own. + sawKeyDown.set(false) + if (imeVisible) imeGate?.park(gateToken) else imeGate?.close(gateToken) + } + hasFocus = focused + } + .onPreviewKeyEvent { event -> + val selectKey = event.key == Key.DirectionCenter || + event.key == Key.Enter || + event.key == Key.NumPadEnter + when { + // SELECT belongs to whatever is focused. When that is the + // trailing visibility button rather than the editable field, + // this handler is still its ancestor, and swallowing the press + // here is what made the button untoggleable from a remote. + selectKey && !hasFocus -> false + selectKey && event.type == KeyEventType.KeyDown -> { + sawKeyDown.set(true) + // DPAD_CENTER means nothing to the field itself, and left + // unconsumed the root key handler reads it as + // FocusDirection.Enter — on the password field that walks + // focus into the trailing visibility button before the + // KeyUp that raises the keyboard ever arrives (Google TV + // emulator 2026-08-16; foundation's own D-pad interceptor + // only swallows it for physical D-pad devices). Enter stays + // with the field so a hardware keyboard's Enter still + // performs the IME action. + event.key == Key.DirectionCenter + } + selectKey && event.type == KeyEventType.KeyUp -> { + if (sawKeyDown.compareAndSet(true, false)) { + TvFocusLog.d { "field: select completed on field -> showing IME" } + imeRequested = true + // Opening the gate is what actually raises the keyboard + // under a host: it swaps the interceptor, which restarts + // the field's pending session and lets it through — the + // delegated startInput shows the IME by itself. The + // show() below still matters for the re-press case (the + // gate is already open, so nothing recomposes) and for + // fields composed outside a host. + imeGate?.open(gateToken) + keyboardController?.show() + } else { + TvFocusLog.d { "field: stray select KeyUp suppressed (no matching KeyDown)" } + } + // Consumed either way. Forwarding the stray tail KeyUp is + // what the suppression exists to prevent — handing it to + // the field pops the very keyboard we declined to show. + true + } + // The legacy text field consumes vertical D-pad for cursor moves a + // single-line box cannot make, trapping focus in the field forever. + // Route vertical D-pad to focus search instead. Only reachable with + // the IME closed — an open IME owns the keys before the app sees + // them. Left/right stay with the field for in-text cursor movement. + event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown -> { + // Call outside the log lambda: TvFocusLog.d only runs its + // body in debug builds, so a move made in there would not + // happen in release. + val moved = focusManager.moveFocus(FocusDirection.Down) + TvFocusLog.d { "field: dpad DOWN -> moveFocus moved=$moved" } + // Consumed even when the move fails. Handing an unusable + // vertical key back to a single-line field is what trapped + // focus in the first place. + true + } + event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp -> { + val moved = focusManager.moveFocus(FocusDirection.Up) + if (!moved) { + // Nothing focusable above, but the form can still be + // scrolled: focus search only ever scrolls a control + // far enough to be visible, so the brand mark and title + // above the first field stay off-screen with no + // focusable way back. Spend the key on the scroll + // instead of on nothing. + formScrollState?.let { state -> + scope.launch { state.animateScrollTo(0) } + } + } + TvFocusLog.d { "field: dpad UP -> moveFocus moved=$moved" } + true + } + else -> false + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt index f3bc52703..e91a353a6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCard.kt @@ -48,7 +48,7 @@ import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.tv.ui.theme.ProgressTrack import org.prairieserver.prairie.tv.ui.theme.ProgressFill import org.prairieserver.prairie.tv.ui.theme.RowDimens -import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults +import org.prairieserver.prairie.tv.ui.theme.siloCardDefaults import org.prairieserver.prairie.tv.ui.util.tvArtworkAspectRatioForMediaType /** @@ -96,8 +96,8 @@ fun TvMediaCard( val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - val cardShape = RoundedCornerShape(8.dp) - val cardFocus = prairieCardDefaults(shape = cardShape) + val cardShape = TvMediaCardShape + val cardFocus = siloCardDefaults(shape = cardShape) var menuExpanded by remember { mutableStateOf(false) } @@ -152,7 +152,6 @@ fun TvMediaCard( data = overlay, prefs = overlayState.prefs, variant = CardOverlayVariant.Poster, - scale = TvCardOverlayScale, forceOpaqueBackground = false, modifier = Modifier.fillMaxSize(), ) @@ -236,5 +235,8 @@ fun TvMediaCard( */ val TvCardWidth: Dp = RowDimens.PosterWidth -/** Optical TV scale: compact like tvOS badges, still readable at sofa distance. */ +/** Optical scale for wide TV thumbnails; poster cards scale from their actual width. */ const val TvCardOverlayScale: Float = 0.7f + +/** Hoisted so every card shares one instance instead of allocating a shape per composition. */ +private val TvMediaCardShape = RoundedCornerShape(8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt index 13c4c9de9..523b3863a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaCardActions.kt @@ -94,7 +94,10 @@ fun TvMediaCardContextMenu( isFavorite: Boolean, isInWatchlist: Boolean, ) { - if (actions.isEmpty) return + // Nothing to compose while closed — this is called for every card in + // every rail, so the requester/position-provider allocations below must + // not run for the (almost always) collapsed case. + if (actions.isEmpty || !expanded) return // A TV Card reports its long-click while DPAD_CENTER is still held. The // popup immediately focuses its first row, so the matching key-up would @@ -113,8 +116,6 @@ fun TvMediaCardContextMenu( else -> TvMenuAction.RemoveFromContinueWatching } - if (!expanded) return - Box( modifier = Modifier .fillMaxWidth() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt index d6f42cb34..48ff31928 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -22,11 +23,14 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import androidx.compose.ui.ExperimentalComposeUiApi import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.overlays.OverlayData import org.prairieserver.prairie.overlays.OverlayDataExtractor +import org.prairieserver.prairie.tv.ui.theme.TvRailScrollBehavior +import org.prairieserver.prairie.tv.ui.theme.tvRailPinOnFocus import org.prairieserver.prairie.tv.ui.theme.Spacing /** Visual style of cards inside a [TvMediaRow]. */ @@ -98,18 +102,31 @@ fun TvMediaRow( * of card 0. Callers should only pass it while a restore is pending. */ restoreFocusIndex: Int = -1, restoreFocusRequester: FocusRequester? = null, + /** Nonzero only while an exact-card return is pending. The row uses its + * private horizontal state to compose [restoreFocusIndex] before focus is + * requested; ordinary row rendering never changes horizontal position. */ + restoreFocusRequest: Int = 0, + onRestoreFocusTargetPlaced: ((Int, Int) -> Unit)? = null, + onRestoreFocusTargetDisposed: ((Int, Int) -> Unit)? = null, /** Fired (on focus GAIN only) with whichever card the user focuses, so the * Skyline marquee + backdrop can preview the focused item. */ onItemFocused: ((SectionItem) -> Unit)? = null, /** Indexed focus callback for callers that maintain a rolling prefetch * window around the currently focused card. */ onItemFocusedAtIndex: ((SectionItem, Int) -> Unit)? = null, + /** Reports whether this row or any descendant card currently owns focus. */ + onRowFocusChanged: ((Boolean) -> Unit)? = null, cardActions: (SectionItem) -> TvMediaCardActions = { TvMediaCardActions() }, ) { if (items.isEmpty()) return val rowState = rememberLazyListState() val rowItems = remember(items, showProgress, style, cardLayout) { - items.map { item -> + // Deduplicate before keying. A repeated contentId inside one row makes + // the lazy list throw ("Key ... was already used"), which is fatal — + // and a row has no reason to show the same title twice anyway. Feeds + // can legitimately overlap, so this is a property of the row, not a + // bug to fix upstream of it. + items.distinctBy { it.contentId }.map { item -> TvMediaRowItemModel( item = item, progress = if (showProgress) item.progressFraction() else null, @@ -123,6 +140,33 @@ fun TvMediaRow( ) } } + // The caller counts positions in the list it handed us; we render a + // deduplicated one, which can be shorter. Translate through contentId so a + // duplicate earlier in the row cannot shift the restored card. + val restoreFocusContentId = items.getOrNull(restoreFocusIndex)?.contentId + // Outbound focus reports also speak the caller's list. distinctBy keeps + // first occurrences, so a rendered item's first raw index is itself. + val rawIndexByContentId = remember(items) { + buildMap { + items.forEachIndexed { rawIndex, item -> + putIfAbsent(item.contentId, rawIndex) + } + } + } + val resolvedRestoreFocusIndex = remember(rowItems, restoreFocusContentId) { + restoreFocusContentId + ?.let { contentId -> rowItems.indexOfFirst { it.item.contentId == contentId } } + ?: -1 + } + + LaunchedEffect(restoreFocusRequest, resolvedRestoreFocusIndex, restoreFocusContentId) { + prepareTvMediaRowFocusRestore( + requestId = restoreFocusRequest, + restoreFocusIndex = resolvedRestoreFocusIndex, + itemCount = rowItems.size, + scrollToItem = rowState::scrollToItem, + ) + } LaunchedEffect(firstItemFocusRequest) { if (firstItemFocusRequest > 0 && firstItemFocusRequester != null) { @@ -144,6 +188,7 @@ fun TvMediaRow( modifier = Modifier.padding(start = startPadding, end = endPadding), ) } + TvRailScrollBehavior { LazyRow( state = rowState, // focusRestorer remembers the last-focused card inside this row. @@ -161,6 +206,15 @@ fun TvMediaRow( Modifier }, ) + .then( + if (onRowFocusChanged != null) { + Modifier.onFocusChanged { state -> + onRowFocusChanged(state.hasFocus) + } + } else { + Modifier + }, + ) .focusRestorer( restoreFocusRequester ?: firstItemFocusRequester ?: FocusRequester.Default, ), @@ -178,6 +232,18 @@ fun TvMediaRow( contentType = { _, rowItem -> rowItem.contentType }, ) { index, rowItem -> val item = rowItem.item + val isRestoreFocusTarget = + restoreFocusRequest > 0 && index == resolvedRestoreFocusIndex + if (isRestoreFocusTarget && onRestoreFocusTargetDisposed != null) { + // Report the position the caller asked about, not ours. It + // compares this against the index it passed in, and after + // deduplication the two coordinate spaces can differ. + DisposableEffect(restoreFocusRequest, restoreFocusIndex) { + onDispose { + onRestoreFocusTargetDisposed(restoreFocusRequest, restoreFocusIndex) + } + } + } // Always anchor firstItemFocusRequester to index 0 so it can // serve as a stable fallback target for focusRestorer and for // imperative requestFocus() calls from parent screens. @@ -185,11 +251,21 @@ fun TvMediaRow( val appliedCardModifier = itemCardModifier.then( if (index == 0) firstItemCardModifier else Modifier, ).then( - if (restoreFocusRequester != null && index == restoreFocusIndex) { + if (restoreFocusRequester != null && index == resolvedRestoreFocusIndex) { Modifier.focusRequester(restoreFocusRequester) } else { Modifier }, + ).then( + if (isRestoreFocusTarget && onRestoreFocusTargetPlaced != null) { + // Caller's coordinates, matching Disposed below: the + // consumer compares this against the index it passed in. + Modifier.onGloballyPositioned { + onRestoreFocusTargetPlaced(restoreFocusRequest, restoreFocusIndex) + } + } else { + Modifier + }, ).then( if (onDirectionUp != null) { Modifier.onPreviewKeyEvent { event -> @@ -208,19 +284,36 @@ fun TvMediaRow( } else { Modifier }, - ).then( + ).tvRailPinOnFocus(rowState, index, startPadding) + .then( if (onItemFocused != null || onItemFocusedAtIndex != null) { Modifier.onFocusChanged { st -> if (st.isFocused) { onItemFocused?.invoke(item) - onItemFocusedAtIndex?.invoke(item, index) + onItemFocusedAtIndex?.invoke( + item, + rawIndexByContentId[item.contentId] ?: index, + ) } } } else { Modifier }, ) - val itemActions = cardActions(item) + // Memoised per item: the producer builds a fresh action bundle + // (four fresh lambdas) on every call, and TvMediaCardActions is + // a data class comparing those lambdas by identity — so without + // this no visible card could ever skip recomposition once its + // row recomposed (which the feed does on every focus move). + // + // Keyed on the PRODUCER as well as the item: what the bundle + // contains depends on what the producer closes over, not only on + // the item — Home decides whether to expose "remove from continue + // watching" from the section it is building actions for. An + // item-only key would keep a stale bundle (and stale callback + // owners) after a refresh that reclassifies the section while + // leaving the item equal (Codex). + val itemActions = remember(item, cardActions) { cardActions(item) } when (cardLayout) { TvRowCardLayout.ReferenceShelf -> TvReferenceShelfCard( title = rowItem.shelfTitle, @@ -271,9 +364,21 @@ fun TvMediaRow( } } } + } } } +internal suspend fun prepareTvMediaRowFocusRestore( + requestId: Int, + restoreFocusIndex: Int, + itemCount: Int, + scrollToItem: suspend (Int) -> Unit, +): Boolean { + if (requestId <= 0 || restoreFocusIndex !in 0 until itemCount) return false + scrollToItem(restoreFocusIndex) + return true +} + /** Fraction [0..1] of item consumed for "continue watching" progress bars. */ private fun SectionItem.progressFraction(): Float? { val pos = positionSeconds ?: return null diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt index 5026530b6..50dd7b16e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt @@ -36,10 +36,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.window.PopupPositionProvider import androidx.tv.material3.Border import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -73,6 +78,7 @@ fun TvOptionDialog( ?: options.firstOrNull { it.enabled }?.key val focusedIndex = options.indexOfFirst { it.key == focusedKey } val listState: LazyListState = rememberLazyListState() + val popupPositionProvider = remember { TvOptionDialogWindowPositionProvider() } // Re-target focus when the dialog is reused for a new menu (title) or the // selected option changes; the shared helper below covers the initial grab. @@ -86,13 +92,13 @@ fun TvOptionDialog( } Popup( - alignment = Alignment.Center, + popupPositionProvider = popupPositionProvider, onDismissRequest = onDismiss, properties = PopupProperties( focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true, - clippingEnabled = false, + clippingEnabled = true, ), ) { Box( @@ -157,6 +163,18 @@ fun TvOptionDialog( } } +internal class TvOptionDialogWindowPositionProvider : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset( + x = ((windowSize.width - popupContentSize.width) / 2).coerceAtLeast(0), + y = ((windowSize.height - popupContentSize.height) / 2).coerceAtLeast(0), + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun TvOptionDialogRow( @@ -173,9 +191,8 @@ private fun TvOptionDialogRow( val restContent = if (enabled) Color.White else Color.White.copy(alpha = 0.42f) Surface( - onClick = { - if (enabled) onClick() - }, + onClick = onClick, + enabled = enabled, interactionSource = interactionSource, shape = ClickableSurfaceDefaults.shape(shape = shape), colors = ClickableSurfaceDefaults.colors( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt index e36e042cb..6ad44ad31 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt @@ -49,10 +49,11 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface @@ -64,7 +65,7 @@ private const val PIN_LENGTH = 4 @Composable fun TvPinEntryDialog( profileName: String, - profileAvatar: String? = null, + profileAvatar: ProfileAvatarRef = ProfileAvatarRef.None, onPinEntered: (String) -> Unit, onDismiss: () -> Unit, errorMessage: String? = null, @@ -188,11 +189,8 @@ fun TvPinEntryDialog( } @Composable -private fun ProfilePinAvatar(profileName: String, profileAvatar: String?) { - val serverUrl = rememberProfileServerUrl() - val avatarUrl = profileAvatar - ?.takeIf(::isImageAvatar) - ?.let { resolveAvatarUrl(serverUrl, it) } +private fun ProfilePinAvatar(profileName: String, profileAvatar: ProfileAvatarRef) { + val avatarImage = rememberProfileAvatarImage(profileAvatar) Box( modifier = Modifier .size(42.dp) @@ -200,13 +198,15 @@ private fun ProfilePinAvatar(profileName: String, profileAvatar: String?) { .background(Color.White.copy(alpha = 0.10f)), contentAlignment = Alignment.Center, ) { - if (avatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = avatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) } else { Text( @@ -246,6 +246,11 @@ private fun PinKeypad( onDigitPressed: (Char) -> Unit, onBackspacePressed: () -> Unit, ) { + // Verification is in flight, not a structural dead end: the keys stay + // focusable so the ring survives the round trip. Dropping the whole keypad + // out of the focus graph would strand a rejected PIN with a dead D-pad — + // the initial-focus policy is one-shot and never re-fires. + val keyState = TvControlState.transient(enabled) Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -254,16 +259,22 @@ private fun PinKeypad( row.forEach { digit -> PinKey( label = digit.toString(), + controlState = keyState, modifier = if (digit == '5') Modifier.focusRequester(fiveFocusRequester) else Modifier, - onClick = { if (enabled) onDigitPressed(digit) }, + onClick = { onDigitPressed(digit) }, ) } } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Spacer(modifier = Modifier.size(48.dp)) - PinKey(label = "0", onClick = { if (enabled) onDigitPressed('0') }) - PinKey(label = null, icon = Icons.AutoMirrored.Filled.Backspace, onClick = { if (enabled) onBackspacePressed() }) + PinKey(label = "0", controlState = keyState, onClick = { onDigitPressed('0') }) + PinKey( + label = null, + controlState = keyState, + icon = Icons.AutoMirrored.Filled.Backspace, + onClick = onBackspacePressed, + ) } } } @@ -272,6 +283,7 @@ private fun PinKeypad( @Composable private fun PinKey( label: String?, + controlState: TvControlState, onClick: () -> Unit, modifier: Modifier = Modifier, icon: androidx.compose.ui.graphics.vector.ImageVector? = null, @@ -280,9 +292,13 @@ private fun PinKey( val isFocused by interactionSource.collectIsFocusedAsState() val keyShape = RoundedCornerShape(9.dp) Surface( - onClick = onClick, + onClick = { controlState.perform(onClick) }, + enabled = controlState.focusable, interactionSource = interactionSource, shape = ClickableSurfaceDefaults.shape(shape = keyShape), + // The keypad deliberately carries no dimmed treatment while verifying + // (the panel shows its own progress), so the resting and disabled + // slots are the same colours either way. colors = ClickableSurfaceDefaults.colors( containerColor = Color.White.copy(alpha = 0.10f), contentColor = PrairieOnSurface, @@ -290,6 +306,8 @@ private fun PinKey( focusedContentColor = FocusedContent, pressedContainerColor = FocusedContainer, pressedContentColor = FocusedContent, + disabledContainerColor = Color.White.copy(alpha = 0.10f), + disabledContentColor = PrairieOnSurface, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f), border = ClickableSurfaceDefaults.border( @@ -302,7 +320,9 @@ private fun PinKey( shape = keyShape, ), ), - modifier = modifier.size(48.dp), + modifier = modifier + .size(48.dp) + .tvControlSemantics(controlState), ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (label != null) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt index f0f03580c..25c4df17a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvRootHeroBackdrop.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.BlendMode @@ -74,7 +74,10 @@ fun TvRootHeroBackdrop( } val targetAccent = ambientAccent ?: emptyWashColor ?: MaterialTheme.colorScheme.background - val animatedTint by animateColorAsState( + // Kept as State and read only inside the Canvas draw lambda below: reading + // the animating colour here would recompose this whole backdrop (and its + // Crossfade subtree) on every frame of the 500ms tint tween. + val animatedTint = animateColorAsState( targetValue = targetAccent, animationSpec = tween( durationMillis = if (snapInitialTint) 0 else TvMarqueeCrossfadeMs, @@ -82,7 +85,6 @@ fun TvRootHeroBackdrop( ), label = "tvRootHeroBackdropTint", ) - val displayedTint = if (animateTransition) animatedTint else targetAccent val isVisible = content != null val hasTintOnlyWash = !isVisible && ambientAccent != null @@ -114,6 +116,7 @@ fun TvRootHeroBackdrop( // Diagonal sampled-tint wash: richest in the top-right behind the art, // carried dimmed to the bottom-left (tvOS stops 1.0 / 0.5 / 0.18). Canvas(modifier = Modifier.fillMaxSize()) { + val displayedTint = if (animateTransition) animatedTint.value else targetAccent drawRect( brush = Brush.linearGradient( colorStops = smoothedWashStops( @@ -200,31 +203,33 @@ private fun CornerAnchoredArt( modifier = Modifier .size(artWidth, artHeight) .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .drawWithContent { - drawContent() + // drawWithCache: the two mask brushes are built once per + // size, not on every frame of a crossfade. + .drawWithCache { // Horizontal ramp: opaque at trailing (right) edge, // clear toward the leading (left) edge. - drawRect( - brush = Brush.horizontalGradient( - colorStops = arrayOf( - 0.0f to Color.Transparent, - 0.68f to Color.Black, - 1.0f to Color.Black, - ), + val horizontalMask = Brush.horizontalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.68f to Color.Black, + 1.0f to Color.Black, ), - blendMode = BlendMode.DstIn, + endX = size.width, ) // Vertical ramp: opaque at the top, clear toward bottom. - drawRect( - brush = Brush.verticalGradient( - colorStops = arrayOf( - 0.0f to Color.Black, - 0.58f to Color.Black, - 1.0f to Color.Transparent, - ), + val verticalMask = Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Black, + 0.58f to Color.Black, + 1.0f to Color.Transparent, ), - blendMode = BlendMode.DstIn, + endY = size.height, ) + onDrawWithContent { + drawContent() + drawRect(brush = horizontalMask, blendMode = BlendMode.DstIn) + drawRect(brush = verticalMask, blendMode = BlendMode.DstIn) + } }, ) { ThumbhashImage( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt new file mode 100644 index 000000000..4b50f301c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.ui.graphics.Color +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent +import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface + +internal data class TvSelectorRowVisualState( + val container: Color, + val content: Color, + val border: Color, +) + +internal fun tvSelectorRowVisualState( + focused: Boolean, + selected: Boolean, + enabled: Boolean, +): TvSelectorRowVisualState = when { + // Rows sit on the Skyline glass panel (tvSkylinePanelChrome), so idle and + // disabled rows are transparent like the cascade's; only focus (inverted + // capsule) and the current selection (soft tint) paint a fill. + !enabled -> TvSelectorRowVisualState( + Color.Transparent, + PrairieOnSurface.copy(alpha = 0.38f), + Color.Transparent, + ) + focused -> TvSelectorRowVisualState( + FocusedContainer, + FocusedContent, + FocusedContent.copy(alpha = 0.22f), + ) + selected -> TvSelectorRowVisualState( + PrairieOnSurface.copy(alpha = 0.14f), + PrairieOnSurface, + PrairieOnSurface.copy(alpha = 0.28f), + ) + else -> TvSelectorRowVisualState(Color.Transparent, PrairieOnSurface, Color.Transparent) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt new file mode 100644 index 000000000..32cb8db10 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.section.SectionItem + +internal data class TvSkylineSettledFocus( + val rowIndex: Int, + val contentId: String, +) + +internal fun settledFocusIdentity( + rawRowIndex: Int, + rawFocusedContentId: String?, + rawFocusedMarqueeId: String?, + settledMarqueeId: String?, +): TvSkylineSettledFocus? { + if ( + rawRowIndex < 0 || + rawFocusedContentId == null || + rawFocusedMarqueeId == null || + rawFocusedMarqueeId != settledMarqueeId + ) { + return null + } + return TvSkylineSettledFocus(rawRowIndex, rawFocusedContentId) +} + +internal fun settledPrefetchItems( + items: List, + rawFocusedContentId: String?, + settledContentId: String?, + radius: Int = 2, +): List { + if (rawFocusedContentId == null || rawFocusedContentId != settledContentId || radius <= 0) { + return emptyList() + } + val focusedIndex = items.indexOfFirst { it.contentId == settledContentId } + if (focusedIndex < 0) return emptyList() + + return ((focusedIndex - radius)..(focusedIndex + radius)) + .filter { it in items.indices && it != focusedIndex } + .map(items::get) +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt index bee76fea9..c9ac10137 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt @@ -47,6 +47,13 @@ import coil3.SingletonImageLoader import coil3.request.ImageRequest import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.tv.ui.focus.TvReturnResolution +import org.prairieserver.prairie.tv.ui.focus.TvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.keyedTvReturnTargetSaver +import org.prairieserver.prairie.tv.ui.focus.keyedBooleanSaver +import org.prairieserver.prairie.tv.ui.focus.keyedIntSaver +import org.prairieserver.prairie.tv.ui.focus.resolveTvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.toTvReturnSections import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.CatalogRepository @@ -54,6 +61,7 @@ import org.prairieserver.prairie.tv.ui.theme.RowDimens import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.TvSkyline import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.async @@ -72,7 +80,32 @@ import org.koin.compose.koinInject fun TvSkylineSectionFeed( sections: List, onItemClick: (String) -> Unit, + /** + * Identifies the surface this feed instance belongs to. + * + * `rememberSaveable` slots are POSITIONAL. Two feeds composed at the same + * position in different surfaces — Home and a library detail — otherwise + * share one slot, so a return target armed on one could be restored into + * the other, sending focus to a card that surface never showed. + * + * Keying the slot alone is not enough, because rememberSaveable does not + * validate a value RESTORED after process death against its inputs. This + * key is therefore written into the savers too, and a payload belonging to + * another surface is discarded on the way back. + */ + surfaceKey: String, modifier: Modifier = Modifier, + /** + * False while rows are still being hydrated. + * + * Without it a launch row that has not arrived yet is indistinguishable + * from one that is gone, and resolution settles on the nearest survivor — + * driving focus to a card the viewer never opened and retiring the real + * target on the way. The rows list cannot answer this itself: it is + * filtered to sections that already HAVE items, so an unhydrated + * placeholder is dropped before the adapter could mark it incomplete. + */ + sectionsComplete: Boolean = true, focusRequest: Int = 0, detailReturnFocusRequest: Int = 0, /** Shell-owned requester for the card a detail page was launched from. @@ -90,7 +123,7 @@ fun TvSkylineSectionFeed( if (it.isTvProgressRow()) TvRowStyle.Backdrop else TvRowStyle.Poster }, cardActions: (ResolvedSection, SectionItem) -> TvMediaCardActions = { _, _ -> TvMediaCardActions() }, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, ) { val rows = remember(sections) { sections.filter { it.items.isNotEmpty() } } val tintState = rememberAmbientBackdropTintState() @@ -99,109 +132,29 @@ fun TvSkylineSectionFeed( val catalogRepository: CatalogRepository = koinInject() val fetchDetail: suspend (String) -> ItemDetail? = remember(catalogRepository) { { contentId -> - (catalogRepository.getItemDetail(contentId) as? ApiResult.Success)?.data + (catalogRepository.getItemDetailForPrefetch(contentId) as? ApiResult.Success)?.data } } val marquee = rememberTvFocusMarqueeState(fetchDetail = fetchDetail) val initialMarqueeSeed = remember(rows) { rows.firstOrNull()?.let { section -> section.items.firstOrNull()?.let { item -> - TvSkylineMarqueeSeed(item = item, rowTitle = section.title) + TvSkylineMarqueeSeed( + item = item, + rowTitle = section.title, + rowIdentity = section.id, + ) } } } - LaunchedEffect(initialMarqueeSeed?.item?.contentId, initialMarqueeSeed?.rowTitle) { + LaunchedEffect( + initialMarqueeSeed?.item?.contentId, + initialMarqueeSeed?.rowTitle, + initialMarqueeSeed?.rowIdentity, + ) { val seed = initialMarqueeSeed ?: return@LaunchedEffect - if (marquee.content == null) { - marquee.seedInitialPreview(seed.item, seed.rowTitle) - } - } - - // Warm the hero-sized backdrop/logo variants for the cards the user can - // reach first. This is intentionally opportunistic: focus transitions - // never wait on the network, but the shared Crossfade usually receives a - // memory-cached image instead of a late ThumbHash replacement. - LaunchedEffect(rows) { - val requests = rows - .take(HeroPreloadRowCount) - .flatMap { it.items.take(HeroPreloadItemsPerRow) } - .flatMap { item -> - buildList { - item.backdropUrl?.takeIf { it.isNotBlank() }?.let { url -> - add( - ImageRequest.Builder(context) - .data(url) - .size(HeroBackdropPreloadWidthPx, HeroBackdropPreloadHeightPx) - .build(), - ) - } - item.logoUrl?.takeIf { it.isNotBlank() }?.let { url -> - add( - ImageRequest.Builder(context) - .data(url) - .size(HeroLogoPreloadWidthPx, HeroLogoPreloadHeightPx) - .build(), - ) - } - } - } - .distinctBy { it.data.toString() } - - val loader = SingletonImageLoader.get(context) - coroutineScope { - requests.map { request -> - async { runCatching { loader.execute(request) } } - }.awaitAll() - } - } - - // Section payloads intentionally stay lightweight and omit the aired/cast - // line. Warm detail for the same near-viewport cards whose hero artwork is - // preloaded so normal D-pad navigation presents a complete marquee on its - // first rested frame. The shared request guard prevents this from racing or - // duplicating the focus-driven fetch for the currently displayed card. - LaunchedEffect(rows, fetchDetail) { - val loader = SingletonImageLoader.get(context) - rows - .take(HeroPreloadRowCount) - .forEach { row -> - coroutineScope { - row.items - .take(HeroPreloadItemsPerRow) - .map { item -> - async { - val contentId = item.contentId - if (!marquee.beginEnrichmentRequest(contentId)) return@async - try { - val detail = runCatching { fetchDetail(contentId) }.getOrNull() - ?: return@async - val enrichment = TvMarqueeEnrichment.from(detail) - marquee.applyEnrichment(contentId, enrichment) - - // Warm a possible episode-series art upgrade - // at the exact hero decode size as well. - enrichment.backdropUrl?.takeIf { it.isNotBlank() }?.let { url -> - runCatching { - loader.execute( - ImageRequest.Builder(context) - .data(url) - .size( - HeroBackdropPreloadWidthPx, - HeroBackdropPreloadHeightPx, - ) - .build(), - ) - } - } - } finally { - marquee.finishEnrichmentRequest(contentId) - } - } - } - .awaitAll() - } - } + marquee.seedInitialPreview(seed.item, seed.rowTitle, seed.rowIdentity) } val rowBandState = rememberLazyListState() @@ -215,18 +168,64 @@ fun TvSkylineSectionFeed( var focusedItemIndex by remember { mutableIntStateOf(-1) } var focusedContentId by remember { mutableStateOf(null) } var removalFocusRequest by remember { mutableIntStateOf(0) } - // The (row, item) to restore focus to when this feed is recreated after - // being removed from composition — saveable so it survives both the outer - // Main → ItemDetail → Main round trip and inner-nav trips (Settings, - // Search). Disposal drops the shell restorer's saved child NODE, so its - // default enter can land on the wrong card; these indices let the - // recreation ladder re-target it exactly. Updated continuously from card - // focus (and on detail launch, where the clicked card is the focused one). - var returnRowIndex by rememberSaveable { mutableIntStateOf(-1) } - var returnItemIndex by rememberSaveable { mutableIntStateOf(-1) } + // Disposal drops the shell restorer's saved child NODE, so its default + // enter can land on the wrong card; this target is what lets the recreation + // ladder re-target the launch card exactly. Updated continuously from card + // focus (and on detail launch, where the clicked card is the focused one), + // except while a restoration is running. + // The card a detail page was launched from, by identity. Two saved indices + // used to stand in for this, and they only describe the same card while the + // data is unchanged: a refresh on resume, Continue Watching reordering + // after playback, a finished item leaving its row, or a recreated process + // rebuilding from the server all leave the numbers valid and pointing + // somewhere else. Saveable so it survives both the outer round trip and + // process death, which is exactly when the live focus state below is gone + // and indices would be all that was left. + // Keyed SAVER, not just a keyed slot: rememberSaveable does not validate a + // value restored after process death against its inputs, so a process + // coming back on a different feed first would otherwise adopt this one's + // target. Same guard TvFlatReturnRestoration already applies. + var returnTarget by rememberSaveable( + surfaceKey, + stateSaver = keyedTvReturnTargetSaver(surfaceKey), + ) { + mutableStateOf(null) + } // True while a restore target is armed. Gates the restore requester // attachments (and the row restorer's enter-fallback redirect they imply). - var detailReturnPending by rememberSaveable { mutableStateOf(false) } + var detailReturnPending by rememberSaveable( + surfaceKey, + stateSaver = keyedBooleanSaver(surfaceKey, slot = "detailReturnPending"), + ) { mutableStateOf(false) } + // True while a ladder is actively driving focus back to the launch card. + // + // Focus lands on the wrong card first often enough that these ladders exist + // for it, and every focus gain re-arms the return target. Without this the + // intermediate card's focus callback overwrites the armed identity, the + // resolution recomputes around it, and the ladder then declares success + // against content the viewer never launched — the identity contract + // defeating itself. + // Counted, not a flag: the recreation ladder and the shell-request ladder + // can both be live at once, and a boolean would let whichever finished + // first re-open the window while the other was still driving focus. + var restorationsInFlight by remember { mutableIntStateOf(0) } + // Bumped every time a new return target is armed. A ladder captures it on + // entry and stops the moment it no longer matches, because the driver + // deliberately follows the current resolution: without this an older ladder + // would pivot onto a newly clicked card, see it already focused, and clear + // the NEW trip's pending state — losing restoration for the trip that had + // only just started. + var returnGeneration by rememberSaveable( + surfaceKey, + stateSaver = keyedIntSaver(surfaceKey, slot = "returnGeneration"), + ) { mutableIntStateOf(0) } + // Bumped when a ladder starts, so the row scrolls its own LazyRow to the + // resolved card. Without it the card can sit outside the composed + // horizontal window after a reorder, the requester never attaches, and + // every retry fails — the contract's obligation to scroll the destination + // into composition, unmet. + var returnRestoreRequest by remember { mutableIntStateOf(0) } + LaunchedEffect(rows) { val previousContentId = focusedContentId val focusedItemWasRemoved = previousContentId != null && @@ -237,11 +236,20 @@ fun TvSkylineSectionFeed( if (focusedRowIndex in rows.indices && focusedItemIndex >= rows[focusedRowIndex].items.size) { focusedItemIndex = (rows[focusedRowIndex].items.size - 1).coerceAtLeast(-1) } - if (focusedItemWasRemoved && focusedRowIndex in rows.indices) { + // Same guard as browse re-arming: a refresh that removes whatever + // incidental focus happened to land on must not redefine what the + // viewer launched from. + if (focusedItemWasRemoved && focusedRowIndex in rows.indices && restorationsInFlight == 0) { val targetRow = rows[focusedRowIndex] if (targetRow.items.isNotEmpty()) { - returnRowIndex = focusedRowIndex - returnItemIndex = focusedItemIndex.coerceIn(0, targetRow.items.lastIndex) + val itemIndex = focusedItemIndex.coerceIn(0, targetRow.items.lastIndex) + returnTarget = TvReturnTarget( + sectionId = targetRow.id, + itemId = targetRow.items[itemIndex].contentId, + sectionIndex = focusedRowIndex, + itemIndex = itemIndex, + ) + returnGeneration++ detailReturnPending = true removalFocusRequest += 1 } @@ -251,8 +259,9 @@ fun TvSkylineSectionFeed( val rowBandScope = rememberCoroutineScope() // Skyline matches tvOS' view-aligned row stack: vertical motion is owned by // this feed, while each row's LazyRow still handles horizontal card scroll. - val onItemFocused: (SectionItem, String, Int, Int) -> Unit = { item, rowTitle, rowIndex, itemIndex -> - marquee.preview(item, rowTitle) + val onItemFocused: (SectionItem, String, String, Int, Int) -> Unit = + { item, rowTitle, rowIdentity, rowIndex, itemIndex -> + marquee.preview(item, rowTitle, rowIdentity) focusedRowIndex = rowIndex focusedItemIndex = itemIndex focusedContentId = item.contentId @@ -264,22 +273,40 @@ fun TvSkylineSectionFeed( // the band is still scrolled rows down. Re-arming on every focus event // is safe: the ladder's first check sees the card already focused and // breaks immediately whenever nothing was actually lost. - returnRowIndex = rowIndex - returnItemIndex = itemIndex - detailReturnPending = true + // Browse movement re-arms; a restoration in progress must not. See + // restorationInFlight above. + if (restorationsInFlight == 0) { + returnTarget = TvReturnTarget( + sectionId = rowIdentity, + itemId = item.contentId, + sectionIndex = rowIndex, + itemIndex = itemIndex, + ) + returnGeneration++ + detailReturnPending = true + } } - // Keep the two cards immediately before and after focus hot. Because this - // window is established while the current card is focused, the next two - // D-pad moves in either direction already have logo/backdrop bytes and - // aired/cast enrichment in cache before their focus events arrive. - LaunchedEffect(rows, focusedRowIndex, focusedItemIndex, fetchDetail) { - val row = rows.getOrNull(focusedRowIndex) ?: return@LaunchedEffect - if (focusedItemIndex !in row.items.indices) return@LaunchedEffect - val window = ((focusedItemIndex - HeroFocusPrefetchRadius).. - (focusedItemIndex + HeroFocusPrefetchRadius)) - .filter { it in row.items.indices && it != focusedItemIndex } - .map(row.items::get) + // Keep a small window around RESTED focus hot. A raw D-pad move cancels the + // previous job immediately, but the new identity starts no speculative + // work until the marquee's focus-rest transaction commits it. + val settledFocus = settledFocusIdentity( + rawRowIndex = focusedRowIndex, + rawFocusedContentId = focusedContentId, + rawFocusedMarqueeId = rows.getOrNull(focusedRowIndex) + ?.let { row -> focusedContentId?.let { contentId -> "${row.id}#$contentId" } }, + settledMarqueeId = marquee.content?.id, + ) + LaunchedEffect(rows, settledFocus, fetchDetail) { + val focus = settledFocus ?: return@LaunchedEffect + val row = rows.getOrNull(focus.rowIndex) ?: return@LaunchedEffect + val window = settledPrefetchItems( + items = row.items, + rawFocusedContentId = focus.contentId, + settledContentId = focus.contentId, + radius = HeroFocusPrefetchRadius, + ) + if (window.isEmpty()) return@LaunchedEffect val loader = SingletonImageLoader.get(context) coroutineScope { @@ -346,26 +373,52 @@ fun TvSkylineSectionFeed( } } - val currentContentUpFallback = rememberUpdatedState<() -> Boolean> { - val currentRow = focusedRowIndex - when { - currentRow <= 0 || currentRow !in rows.indices -> - // Top row (or unfocused): report not-handled so the shell hands - // focus to the menu bar. - false - // Previous row is already laid out: move immediately so the returned - // value is HONEST — the old code launched the move asynchronously and - // returned `true` before it ran, so a failed move stranded focus - // (neither moved up nor escalated to the menu). - focusManager.moveFocus(FocusDirection.Up) -> true - else -> { + var rowRelocationInFlight by remember { mutableStateOf(false) } + val currentContentUpFallback = rememberUpdatedState<(Boolean) -> Boolean> { isRepeat -> + val bandTopRow = rowBandState.firstVisibleItemIndex + // See tvSkylineEffectiveRow: the reported focused row can lag or be + // clamped; the band's top row is the ground truth it is checked against. + val currentRow = tvSkylineEffectiveRow(focusedRowIndex, bandTopRow, rows.size) + when ( + tvSkylineUpAction( + currentRow = focusedRowIndex, + rowCount = rows.size, + isRepeat = isRepeat, + relocationInFlight = rowRelocationInFlight, + bandTopRow = bandTopRow, + ) + ) { + TvSkylineUpAction.EnterMenu -> false + TvSkylineUpAction.StayInContent -> true + TvSkylineUpAction.TryPreviousRow -> { + if (focusManager.moveFocus(FocusDirection.Up)) { + return@rememberUpdatedState true + } // Previous row is scrolled off; bring it on-screen first, then - // move once the scroll settles (animateScrollToItem suspends until - // it does, so the row is laid out before moveFocus). + // move once the scroll settles. While this job owns relocation, + // key repeats are consumed instead of launching competing jobs. + rowRelocationInFlight = true rowBandScope.launch { - rowBandState.animateScrollToItem(currentRow - 1) - withFrameNanos { } - focusManager.moveFocus(FocusDirection.Up) + try { + val targetRow = (currentRow - 1).coerceAtLeast(0) + rowBandState.animateScrollToItem(targetRow) + // On a slow device the row's cards can take more than one + // frame to lay out after the scroll settles; moving before + // they exist finds nothing and strands focus. Wait for the + // target row to be present (bounded), then move. + var frames = 0 + while ( + frames < RelocationLayoutFrameBudget && + rowBandState.layoutInfo.visibleItemsInfo.none { it.index == targetRow } + ) { + withFrameNanos { } + frames++ + } + withFrameNanos { } + focusManager.moveFocus(FocusDirection.Up) + } finally { + rowRelocationInFlight = false + } } true } @@ -374,8 +427,8 @@ fun TvSkylineSectionFeed( // Stable per-screen registration so the shell can identify THIS feed's // ownership of the shared up-fallback slot across sibling (tab) swaps. - val contentUpFallbackRegistration: () -> Boolean = - remember { { currentContentUpFallback.value() } } + val contentUpFallbackRegistration: (Boolean) -> Boolean = + remember { { isRepeat -> currentContentUpFallback.value(isRepeat) } } DisposableEffect(onContentUpFallbackChanged, contentUpFallbackRegistration) { onContentUpFallbackChanged?.invoke(contentUpFallbackRegistration) @@ -408,6 +461,49 @@ fun TvSkylineSectionFeed( // previously entered card. var initialFocusRequested by rememberSaveable { mutableStateOf(false) } var firstRowFocusRequest by remember { mutableIntStateOf(0) } + // Where the launch card is NOW. Resolved against the rows this feed + // actually renders — Skyline drops empty rows before layout, so a + // projection taken from further upstream would put these indices in a + // different coordinate space from the rows they address. + // + // Rows are treated as a complete snapshot: an aggregate feed response + // arrives whole, and a row trimmed to its item limit is capped rather than + // paged, so nothing further will load into it. SameSectionOnly because + // Home rows overlap — a title can sit in Continue Watching and Recently + // Added at once, and following an id across rows would jump focus to a + // copy the viewer never touched. + // The section map depends on the rows alone; the return target is re-armed + // on every focus move, so building the map inside the resolution remember + // copied every content id in the feed per keypress. + val returnSections = remember(rows) { rows.toTvReturnSections() } + val returnResolution: TvReturnResolution = + remember(returnSections, returnTarget, detailReturnPending) { + if (detailReturnPending) { + resolveTvReturnTarget( + target = returnTarget, + sections = returnSections, + sectionsComplete = sectionsComplete, + ) + } else { + TvReturnResolution.Empty + } + } + // Read fresh inside the retry ladders. The resolution is a plain remembered + // value, so a coroutine that captured it keeps working from the rows of the + // composition it launched in; a quiet refresh would leave it steering by a + // map of a feed that is no longer on screen. + val currentResolution by rememberUpdatedState(returnResolution) + val locatedReturn = returnResolution as? TvReturnResolution.Located + + + // Landing is confirmed by identity, not by coordinates. The card at a + // given index is not necessarily the card that was resolved, and an + // index-only check reports success for whatever now occupies the slot. + fun hasLandedOnReturnTarget(): Boolean { + val located = currentResolution as? TvReturnResolution.Located ?: return false + return focusedRowIndex == located.sectionIndex && focusedContentId == located.itemId + } + val detailReturnRowContainerFocusRequester = remember { FocusRequester() } val detailReturnItemFocusRequester = detailReturnCardFocusRequester ?: remember { FocusRequester() } @@ -423,27 +519,75 @@ fun TvSkylineSectionFeed( val lifecycleOwner = LocalLifecycleOwner.current val firstRowId = rows.firstOrNull()?.id + /** + * Walk focus back to the resolved launch card, re-reading where that is on + * every attempt. + * + * Steering has to be as fresh as the success check. A refresh that keeps + * the same first row does not restart these effects, so a ladder that + * captured its destination up front would keep driving toward a row the + * feed has since moved, while the predicate looks for the new one — it + * cannot succeed, and for the shell ladder the request token has already + * been marked applied, so nothing retries. + * + * Hops one focus-restorer scope per frame pair — row group, then card — + * because a request that crosses a restorer toward a descendant is + * cancelled and rolled back. The vertical band is scrolled whenever the + * resolved row changes, not once at the start, for the same reason. + */ + suspend fun driveFocusToReturnTarget(generation: Int, attempts: Int, scrollBand: Boolean) { + var scrolledToSection = -1 + repeat(attempts) { + withFrameNanos { } + // Someone armed a newer target; that trip owns restoration now. + if (generation != returnGeneration) return + // The real success signal is the card's own focus callback — + // requestFocus() can report success yet silently roll back when + // the request crosses a restorer scope. + if (hasLandedOnReturnTarget()) return + val located = currentResolution as? TvReturnResolution.Located ?: return + if (scrollBand && located.sectionIndex != scrolledToSection) { + val scrolled = runCatching { rowBandState.scrollToItem(located.sectionIndex) } + scrolled.exceptionOrNull()?.let { if (it is CancellationException) throw it } + // Only remember a scroll that actually happened, or a failure + // would be recorded as done and never retried. + if (scrolled.isSuccess) scrolledToSection = located.sectionIndex + } + // Classified from the fresh index rather than a captured + // firstRowId: the removal ladder is not keyed on the row list, so + // a reorder mid-run would otherwise keep it addressing the row the + // target used to be in. + val rowRequester = if (located.sectionIndex == 0) { + firstRowContainerFocusRequester + } else { + detailReturnRowContainerFocusRequester + } + runCatching { rowRequester.requestFocus() } + withFrameNanos { } + runCatching { detailReturnItemFocusRequester.requestFocus() } + } + } + LaunchedEffect(removalFocusRequest) { if (removalFocusRequest == 0 || !detailReturnPending) return@LaunchedEffect - val rowIndex = returnRowIndex - val itemIndex = returnItemIndex - if (rowIndex !in rows.indices || itemIndex !in rows[rowIndex].items.indices) { + // Pending means the answer is not knowable yet; keep the target and + // wait for the data rather than spending it on a stand-in. + if (returnResolution is TvReturnResolution.Pending) return@LaunchedEffect + if (locatedReturn == null) { detailReturnPending = false return@LaunchedEffect } - withFrameNanos { } - val rowRequester = if (rows[rowIndex].id == firstRowId) { - firstRowContainerFocusRequester - } else { - detailReturnRowContainerFocusRequester - } - runCatching { rowRequester.requestFocus() } - for (attempt in 0 until 8) { + val generation = returnGeneration + restorationsInFlight++ + returnRestoreRequest++ + try { withFrameNanos { } - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) break - runCatching { detailReturnItemFocusRequester.requestFocus() } + driveFocusToReturnTarget(generation, attempts = 8, scrollBand = false) + } finally { + restorationsInFlight-- } - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) { + // Only the trip that owns the target may retire it. + if (generation == returnGeneration && hasLandedOnReturnTarget()) { detailReturnPending = false } } @@ -467,30 +611,15 @@ fun TvSkylineSectionFeed( // click that sets pending while this feed is still composed and focused. LaunchedEffect(firstRowId) { if (!detailReturnPending || firstRowId == null) return@LaunchedEffect - val rowIndex = returnRowIndex - val itemIndex = returnItemIndex - if (rowIndex !in rows.indices || itemIndex !in rows[rowIndex].items.indices) { - return@LaunchedEffect - } - runCatching { rowBandState.scrollToItem(rowIndex) } - val rowRequester = if (rows[rowIndex].id == firstRowId) { - firstRowContainerFocusRequester - } else { - detailReturnRowContainerFocusRequester - } - for (attempt in 0 until 40) { - withFrameNanos { } - // The real success signal is the card's own focus callback — - // requestFocus() can report success yet silently roll back when - // the request crosses a restorer scope. - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) break - // Hop one restorer scope per frame pair: row group, then card — - // once default focus is anywhere inside the content group these - // are honored, and the row restorer's enter fallback is the - // launch card itself, so the hop lands directly on it. - runCatching { rowRequester.requestFocus() } - withFrameNanos { } - runCatching { detailReturnItemFocusRequester.requestFocus() } + if (returnResolution is TvReturnResolution.Pending) return@LaunchedEffect + if (locatedReturn == null) return@LaunchedEffect + val generation = returnGeneration + restorationsInFlight++ + returnRestoreRequest++ + try { + driveFocusToReturnTarget(generation, attempts = 40, scrollBand = true) + } finally { + restorationsInFlight-- } } @@ -508,35 +637,30 @@ fun TvSkylineSectionFeed( // loading so the firstRowId key re-runs it once data lands. if (detailReturnFocusRequest == lastAppliedDetailReturnRequest) return@LaunchedEffect if (!detailReturnPending) return@LaunchedEffect - val rowIndex = returnRowIndex - val itemIndex = returnItemIndex - if (rowIndex !in rows.indices || itemIndex !in rows[rowIndex].items.indices) { + // Skip WITHOUT consuming the request while the answer is still + // unknowable, so the firstRowId key re-runs this once data lands. + if (returnResolution is TvReturnResolution.Pending) return@LaunchedEffect + if (locatedReturn == null) { detailReturnPending = false return@LaunchedEffect } lastAppliedDetailReturnRequest = detailReturnFocusRequest - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) { + if (hasLandedOnReturnTarget()) { // The early recreation-time ladder already landed the launch // card; re-running the hops would only jiggle focus. detailReturnPending = false return@LaunchedEffect } - runCatching { rowBandState.scrollToItem(rowIndex) } - withFrameNanos { } - val rowRequester = if (rows[rowIndex].id == firstRowId) { - firstRowContainerFocusRequester - } else { - detailReturnRowContainerFocusRequester - } - runCatching { rowRequester.requestFocus() } - // The card requester attaches once the row's restored LazyRow - // window composes the launch card; retry across a few frames. - for (attempt in 0 until 8) { + val generation = returnGeneration + restorationsInFlight++ + returnRestoreRequest++ + try { withFrameNanos { } - runCatching { detailReturnItemFocusRequester.requestFocus() } - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) break + driveFocusToReturnTarget(generation, attempts = 8, scrollBand = true) + } finally { + restorationsInFlight-- } - if (focusedRowIndex == rowIndex && focusedItemIndex == itemIndex) { + if (generation == returnGeneration && hasLandedOnReturnTarget()) { detailReturnPending = false } return@LaunchedEffect @@ -662,14 +786,19 @@ fun TvSkylineSectionFeed( ) { rowIndex, section -> val isFirstRow = section.id == firstRowId val showProgress = showProgressForSection(section) - val isReturnRow = detailReturnPending && rowIndex == returnRowIndex + val isReturnRow = locatedReturn?.sectionIndex == rowIndex TvMediaRow( title = section.title, items = section.items, onItemClick = { contentId -> - returnRowIndex = rowIndex - returnItemIndex = - section.items.indexOfFirst { it.contentId == contentId } + returnTarget = TvReturnTarget( + sectionId = section.id, + itemId = contentId, + sectionIndex = rowIndex, + itemIndex = section.items + .indexOfFirst { it.contentId == contentId }, + ) + returnGeneration++ detailReturnPending = true onItemClick(contentId) }, @@ -691,11 +820,21 @@ fun TvSkylineSectionFeed( else -> null }, firstItemFocusRequest = if (isFirstRow) firstRowFocusRequest else 0, - restoreFocusIndex = if (isReturnRow) returnItemIndex else -1, + restoreFocusIndex = if (isReturnRow) { + locatedReturn?.itemIndex ?: -1 + } else { + -1 + }, + // Bumped when a ladder starts, so the row scrolls + // its own LazyRow to the resolved card. A card that + // moved horizontally can otherwise sit outside the + // composed window, leaving the requester unattached + // and every retry doomed. + restoreFocusRequest = if (isReturnRow) returnRestoreRequest else 0, restoreFocusRequester = detailReturnItemFocusRequester .takeIf { isReturnRow }, onItemFocusedAtIndex = { item, itemIndex -> - onItemFocused(item, section.title, rowIndex, itemIndex) + onItemFocused(item, section.title, section.id, rowIndex, itemIndex) }, cardActions = { item -> cardActions(section, item) }, ) @@ -718,6 +857,7 @@ fun ResolvedSection.isTvProgressRow(): Boolean { private data class TvSkylineMarqueeSeed( val item: SectionItem, val rowTitle: String, + val rowIdentity: String, ) // 0.64 × 1920 by 0.70 × 1080, and the 440×100dp logo cap at 2× density. @@ -725,8 +865,6 @@ private const val HeroBackdropPreloadWidthPx = 1229 private const val HeroBackdropPreloadHeightPx = 756 private const val HeroLogoPreloadWidthPx = 880 private const val HeroLogoPreloadHeightPx = 200 -private const val HeroPreloadRowCount = 2 -private const val HeroPreloadItemsPerRow = 8 private const val HeroFocusPrefetchRadius = 2 /** tvOS MediaRow cardSpacing 40pt maps to 20dp. */ @@ -770,3 +908,6 @@ private val TvSkylineBringIntoViewSpec: BringIntoViewSpec = object : BringIntoVi } } } + +/** Frames to wait for a relocated row to lay out before moving focus into it. */ +private const val RelocationLayoutFrameBudget = 12 diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt new file mode 100644 index 000000000..10658fe56 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.tv.ui.components + +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +/** + * The row an Up press should be measured from. [focusedRow] is what the last + * card focus callback reported; it can lag or be clamped (a row-list refresh + * mid-browse, a focus event not yet delivered on a slow device). The band's + * scroll position always tracks the focused row, so when the two disagree + * and the band is scrolled below the top, trust the band: focus cannot be on + * row 0 while the band shows a lower row at its top. + */ +internal fun tvSkylineEffectiveRow(focusedRow: Int, bandTopRow: Int, rowCount: Int): Int { + val focusedValid = focusedRow in 0 until rowCount + return when { + !focusedValid -> bandTopRow.coerceIn(-1, rowCount - 1) + focusedRow == 0 && bandTopRow > 0 -> bandTopRow.coerceAtMost(rowCount - 1) + else -> focusedRow + } +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, + bandTopRow: Int = 0, +): TvSkylineUpAction { + val effectiveRow = tvSkylineEffectiveRow(currentRow, bandTopRow, rowCount) + return when { + relocationInFlight -> TvSkylineUpAction.StayInContent + effectiveRow !in 0 until rowCount -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + // Only leave content when the band really is at its top row: a stale + // "row 0" while the band is scrolled down is a fast double-Up on a slow + // device, and must step to the previous row instead of jumping to the + // menu. + effectiveRow == 0 && bandTopRow <= 0 -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + else -> TvSkylineUpAction.TryPreviousRow + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSquaredButtons.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSquaredButtons.kt index df42b6143..c38021077 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSquaredButtons.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSquaredButtons.kt @@ -225,6 +225,7 @@ internal fun SquaredPillSurface( onClick: () -> Unit, modifier: Modifier, focusRequester: FocusRequester?, + enabled: Boolean = true, contentPadding: PaddingValues, content: @Composable (foreground: Color) -> Unit, ) { @@ -339,6 +340,7 @@ internal fun SquaredPillSurface( .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .onFocusChanged { isFocused = it.isFocused } .clickable( + enabled = enabled, interactionSource = interactionSource, indication = null, onClick = onClick, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockImeLifecycle.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockImeLifecycle.kt new file mode 100644 index 000000000..23c8f14c3 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockImeLifecycle.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalSoftwareKeyboardController + +/** + * Dismiss the stock TV keyboard when the caller leaves composition. + * + * Android TV does not take the IME down on its own when the surface that + * raised it goes away, so without this the keyboard floats over whatever screen + * comes next — with the D-pad still feeding it rather than the content behind. + * + * Every surface that calls `show()` needs this, which is why it is a shared + * composable rather than a comment: the two implementations that copied the + * focus-and-show half of the policy both omitted the disposal half. + * `TvStockKeyboardPolicyTest` pins that pairing. + * + * Callers that can be covered by the IME also need `Modifier.imePadding()` on + * their container; that cannot be enforced from here. + */ +@Composable +internal fun TvHideStockImeOnDispose() { + val keyboardController = LocalSoftwareKeyboardController.current + DisposableEffect(keyboardController) { + onDispose { runCatching { keyboardController?.hide() } } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextFieldDefaults.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextFieldDefaults.kt index cdc5a23db..04397fbde 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextFieldDefaults.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextFieldDefaults.kt @@ -4,8 +4,19 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme +/** + * Canonical control metrics for the TV auth/onboarding forms (server setup, + * sign-in, sign-up, first-run setup). Every text field and primary action in + * that flow sizes from here so the steps read as one surface. + */ +object TvAuthFormDefaults { + val FieldHeight = 56.dp + val PrimaryButtonHeight = 60.dp +} + @Composable fun tvOutlinedTextFieldColors( focusedContainerColor: Color = Color.White.copy(alpha = 0.04f), diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextInputDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextInputDialog.kt index dcaf951a1..13f3ee118 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextInputDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextInputDialog.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -81,17 +82,13 @@ fun TvTextInputDialog( runCatching { fieldFocusRequester.requestFocus() } keyboardController?.show() } - // Dismiss the IME when the dialog leaves composition so the system keyboard - // doesn't float over whatever screen follows (Android TV leaves it up - // otherwise). Mirrors the fix in TvSearchScreen. - DisposableEffect(Unit) { - onDispose { runCatching { keyboardController?.hide() } } - } + TvHideStockImeOnDispose() Dialog(onDismissRequest = onDismiss) { Box( modifier = Modifier .fillMaxSize() + .imePadding() .background(Color.Black.copy(alpha = 0.85f)), contentAlignment = Alignment.Center, ) { @@ -135,6 +132,7 @@ fun TvTextInputDialog( modifier = Modifier .fillMaxWidth() .height(56.dp) + .tvImeAwareFieldContext() .focusRequester(fieldFocusRequester), colors = tvOutlinedTextFieldColors(), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocus.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocus.kt new file mode 100644 index 000000000..10b38ad93 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocus.kt @@ -0,0 +1,111 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import kotlinx.coroutines.delay + +private const val TvContentInitialFocusRetryDelayMillis = 60L + +internal const val TvContentInitialFocusMaxAttempts = + (TvFocusAcquisitionBudgetMillis / TvContentInitialFocusRetryDelayMillis).toInt() + +/** + * Bounded retry-until-observed initial focus for a screen whose content arrives + * asynchronously. + * + * Separate from the dialog adapter because the failure it prevents is + * different. A dialog is on screen the instant its effect runs; a content + * screen composes its first row *during* lazy placement, so the first request + * is routinely rejected. Screens that treated "attempted" as "acquired" + * latched that rejection permanently and never focused anything. + */ +internal suspend fun requestTvContentInitialFocus( + awaitAttempt: suspend () -> Unit, + isContentFocused: () -> Boolean, + requestFocus: () -> Boolean, +): TvObservedFocusResult = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = awaitAttempt, + requestFocus = requestFocus, + isFocused = isContentFocused, +) + +/** + * Whether an anchoring pass should run at all. + * + * Nothing to anchor to yet when there is no content. Nothing to do either when + * the content root already holds focus — the viewer is in there and a refresh + * must not yank them back to the first item. Checked before the first attempt + * rather than inside the retry loop, which delays a frame before looking. + */ +internal fun shouldRequestTvContentInitialFocus( + contentKey: Any?, + contentHasFocus: Boolean, +): Boolean = contentKey != null && !contentHasFocus + +/** + * Attach the returned modifier to the content root, and [target] to the first + * item. Anchoring re-runs whenever [contentKey] — the stable identity of the + * first item — changes, which is what allows a request rejected during lazy + * placement to be retried instead of latched as failure. + * + * "Acquired" here means the content root owns focus, not that [target] + * specifically does: the observation is `hasFocus` on an ancestor, so focus + * landing on any descendant satisfies it. That is the property worth having — + * the failure being prevented is a dead D-pad, and any focused item inside the + * content prevents it. + * + * Refresh does not steal focus, subject to one bound: focus is checked before + * the first request, so a viewer already inside the content is left alone. If + * they leave the content *during* an anchoring pass the request can still land + * and pull them back. + * + * [onAcquired] fires only on observed acquisition. Shells use it to hand over + * content focus, and telling a shell that focus landed when it did not is how + * a screen ends up with no focus owner at all. + */ +@Composable +internal fun rememberTvContentInitialFocus( + target: FocusRequester, + contentKey: Any?, + onAcquired: () -> Unit = {}, +): Modifier { + var contentHasFocus by remember { mutableStateOf(false) } + + // Deliberately no "already acquired" latch, which is a real trade rather + // than a free win. With one, A -> null -> A and A -> B-exhausted -> A + // suppress the request while nothing holds focus — the permanent no-focus + // state this adapter exists to remove. Without one, those same re-entries + // will anchor even when focus legitimately sits outside the content root, + // e.g. in a confirmation dialog, and pull it back. + // + // Dead D-pad is the worse failure, so it loses. Resolving it properly needs + // the modal focus-ownership contract (Series C): a modal that owns focus + // should suppress content anchoring underneath it, which is knowledge this + // adapter cannot have on its own. + LaunchedEffect(target, contentKey) { + if (!shouldRequestTvContentInitialFocus(contentKey, contentHasFocus)) { + TvFocusLog.d { + "contentInitialFocus: skipped (key=$contentKey, alreadyFocused=$contentHasFocus)" + } + return@LaunchedEffect + } + TvFocusLog.d { "contentInitialFocus: claiming (key=$contentKey)" } + val result = requestTvContentInitialFocus( + awaitAttempt = { delay(TvContentInitialFocusRetryDelayMillis) }, + isContentFocused = { contentHasFocus }, + requestFocus = target::requestFocus, + ) + TvFocusLog.d { "contentInitialFocus: result=$result (key=$contentKey)" } + if (result == TvObservedFocusResult.Focused) onAcquired() + } + + return Modifier.onFocusChanged { contentHasFocus = it.hasFocus } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablement.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablement.kt new file mode 100644 index 000000000..f5e72b47e --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablement.kt @@ -0,0 +1,70 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics + +/** + * Disabled TV controls come in two kinds, and they must not be wired alike. + * + * **Structural** unavailability — the action can never apply in this context + * (a selector with one choice, "Debug logging" under consent NEVER, an option + * row for a track the file does not carry). Those belong out of the focus + * graph: leaving them focusable makes the D-pad walk through dead stops. + * + * **Transient** gating — a request is in flight, or a form is still being + * filled. Those must STAY in the focus graph. Android TV does not re-home + * focus when the focused node stops being focusable: the ring simply + * disappears and the D-pad goes dead until something requests focus again. + * Nothing does, because the initial-focus policies are one-shot. In a modal + * whose controls are all busy-gated (the PIN keypad, the join-code grid) that + * strands the viewer with Back as the only working key — which is the very + * failure this focus work exists to remove. + */ +internal data class TvControlState( + /** Whether the control takes part in D-pad focus traversal. */ + val focusable: Boolean, + /** Whether activating the control runs its action. */ + val actionable: Boolean, +) { + companion object { + /** The action is unavailable here at all. Drops out of the focus graph. */ + fun structural(isEnabled: Boolean) = + TvControlState(focusable = isEnabled, actionable = isEnabled) + + /** In-flight work or an evolving form. Stays focusable, action suppressed. */ + fun transient(isEnabled: Boolean) = + TvControlState(focusable = true, actionable = isEnabled) + } + + fun perform(action: () -> Unit) { + if (actionable) action() + } +} + +/** + * Wire a [TvControlState] to a control: focus participation and truthful + * accessibility state. + * + * Passing `focusable` to a TV component's `enabled` parameter does NOT achieve + * the focus half, which is the trap this exists to close. `tvClickable` — the + * shared basis of TV Material's clickable `Surface` and therefore of `Button` + * — calls `focusable()` with its *default* `enabled = true` and never forwards + * the component's own `enabled`; that flag reaches only the D-pad-enter handler + * and the semantics block. A disabled TV button is consequently still a focus + * stop: it just refuses to activate. (Verified against the tv-material 1.0.1 + * bytecode, not inferred from the API shape.) + * + * So structural exclusion has to be asked for explicitly, and on the control's + * own modifier chain ahead of the component's internal focusable — which is + * where a `modifier` parameter lands. + * + * Every call site predating this used [TvControlState.transient], where + * `focusable` is always true, so the promise was never tested. It is kept here + * rather than at each call site so it cannot be forgotten again. + */ +internal fun Modifier.tvControlSemantics(controlState: TvControlState): Modifier = + tvFocusSuppressed(!controlState.focusable) + .semantics { + if (!controlState.actionable) disabled() + } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFlatReturnRestoration.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFlatReturnRestoration.kt new file mode 100644 index 000000000..feecc36d1 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFlatReturnRestoration.kt @@ -0,0 +1,603 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Restores focus to the item a flat, paginated surface was on when it opened a + * detail page. + * + * The orchestration is identical everywhere it is needed — library grids, + * personal lists, people, Collections, Requests — and it is not the kind of + * thing to write twice. Its difficulty is not the happy path but the order in + * which four independent things have to agree: pages arriving, a resolution + * that changes as they do, a requester that binds only once its card composes, + * and focus that can be requested successfully and still roll back. Each of + * those was got wrong at least once while this was being derived on a single + * screen; the point of gathering it here is that the next surface inherits the + * conclusions rather than the derivation. + * + * Not for Search, which is not flat: it mixes library results with + * request-provider results in separate containers with unrelated identifier + * spaces, and needs real section ids and namespaced item ids. + */ +internal class TvFlatReturnRestoration internal constructor( + /** + * Held by the caller's [rememberSaveable] rather than mirrored into it. + * + * Mirroring meant writing to saveable state from composition and restoring + * through a `remember` side effect — two sources of truth for the one value + * that has to survive process death, which is exactly when identity is all + * there is and the live focus state is gone. + */ + private val targetState: MutableState, + /** + * Whether a target was already saved when this holder was created — the + * only honest way to tell "the viewer came back" from "the viewer just + * arrived". + * + * Asking whether a target exists cannot answer it: browsing arms one on + * every card focus, so on a fresh entry the answer flips the moment the + * grid happens to take focus, and it races whatever else the screen wanted + * to focus instead. Captured once, at creation, it cannot race anything. + */ + val isReturning: Boolean, +) { + internal var target: TvReturnTarget? + get() = targetState.value + set(value) { targetState.value = value } + + internal var focusedItemId by mutableStateOf(null) + internal var attachedItemId by mutableStateOf(null) + internal var destination by mutableStateOf(null) + /** + * Deliberately a plain field, not snapshot state: it is derived entirely + * from the current inputs, so composition already re-runs when it can + * change. Making it observable only invited an extra recomposition on + * every change, for a value that is recomputed above the read anyway. + */ + internal var provisionalItemIndex: Int = 0 + internal var inFlight by mutableStateOf(false) + internal var completed by mutableStateOf(false) + + /** + * The item index the restore requester belongs on. + * + * The published destination once there is one, so composition and the + * watcher below cannot disagree about where focus is going; a provisional + * position before that, which is what keeps a requester attached for + * ordinary first entry. + */ + val requesterItemIndex: Int get() = destination?.itemIndex ?: provisionalItemIndex + + + + /** + * Report ordinary browse movement. + * + * Suppressed while a restoration runs: focus lands on other cards on the + * way, and letting one of those redefine the launch item makes the + * restoration confirm itself against something the viewer never opened. + */ + fun onItemFocused(itemId: String, index: Int) { + focusedItemId = itemId + if (!inFlight) { + target = TvReturnTarget(TvFlatSectionId, itemId, sectionIndex = 0, itemIndex = index) + } + } + + /** + * Report a card LOSING focus. Callers must send this — without it + * [focusedItemId] records what was focused once rather than what is focused + * now, and the acquisition below reads it as the latter. + * + * That distinction is the whole game here: acquisition checks the identity + * BEFORE issuing any request, so a stale value lets a restoration report + * success without ever asking for focus, while focus sits on a control or + * another container entirely. + * + * Guarded on identity because a gain elsewhere arrives before this loss: + * clearing unconditionally would erase the position that just replaced it. + */ + fun onItemFocusLost(itemId: String) { + if (focusedItemId == itemId) focusedItemId = null + } + + /** + * Report a deliberate opening. + * + * Always arms, unlike [onItemFocused]. A card focused during a restoration + * did not arm and its focus callback will not fire again, so opening it + * would otherwise navigate carrying the previous trip's target. + */ + fun onItemClicked(itemId: String, index: Int) { + target = TvReturnTarget( + TvFlatSectionId, + itemId, + sectionIndex = 0, + // Only a fallback coordinate; keep the last one rather than + // inventing the top of the list if the item is not in view. + itemIndex = index.takeIf { it >= 0 } ?: target?.itemIndex ?: 0, + ) + } + + /** + * Report which item the restore requester is currently bound to. + * + * The card has to say this, because nothing else can. A card can be laid + * out while its modifier still carries the previous binding, so "the slot + * is visible" is not evidence that a request will reach the right node. + */ + fun onRequesterAttached(itemId: String?) { + attachedItemId = itemId + } +} + +/** + * Drives one restoration for a flat, paginated surface. + * + * [itemIds], [hasMore], [isLoadingMore] and [errorMessage] are read live + * throughout — a captured snapshot would freeze the hunt, and `snapshotFlow` + * would observe nothing at all. + * + * [surfaceKey] identifies the surface. Changing it starts over completely — + * target, landing state and the completion latch — because a different tab or + * library is a different surface, not the same one with different contents. + * + * It is a String, and must be a stable injective one — an enum's `name`, an id, + * a canonically encoded composite. It was `Any?` with `toString()`, which is + * neither: `1` and `"1"` collide, `null` and `""` collide, and a default + * `toString()` can change across process recreation and silently discard a + * valid target. A key that quietly means two things is worse than no key. + * The screen this was extracted from reset only the target and kept the latch, + * which is unsound on its own terms: had its tab branches not disposed and + * recreated the whole subtree, a second tab would have found the latch already + * set and never restored focus at all. + * + * A caller changing [surfaceKey] MUST also recreate its item content — + * separate composition branches, or `key(surfaceKey) { … }` around the list. + * The acknowledgements this depends on come from the cards themselves, and a + * card that neither disposes nor moves has no reason to report its attachment + * again; the fresh holder then waits for something nobody will send and gives + * up, losing restoration on a surface that looks fine. + * + * That is a precondition, not something this can rescue: see the acquisition + * below for why a blind request is unsafe. Existing callers satisfy it by + * construction — a new one must check. + * + * [scrollToItem] receives an ITEM index; a surface with headers ahead of its + * items converts. [onRestored] fires only on a confirmed landing, so a caller + * never tells its shell that content took focus when it did not. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +internal fun rememberTvFlatReturnRestoration( + itemIds: List, + hasMore: Boolean, + isLoadingMore: Boolean, + /** + * True while the surface is REPLACING its list rather than extending it — + * a resume refresh that reloads from offset zero. + * + * This is not the same signal as [isLoadingMore] and cannot be folded into + * it. [isLoadingMore] is only consulted once resolution has already come + * back Pending, and a stale multi-page list still CONTAINS the target, so + * it resolves Exact and that check is never reached. Restoration would then + * scroll and acquire against a list the refresh is about to throw away: + * either the card vanishes mid-acquisition and the one-shot attempt fails, + * or focus lands on a card that is removed a frame later. + * + * So a replacement gates the FIRST resolution, not the page hunt. Once the + * refresh has settled and the truncated list is authoritative, a target + * beyond it is genuinely Pending and paging back toward it is the right + * answer rather than a race. + */ + isReplacingContent: Boolean = false, + errorMessage: String?, + surfaceKey: String, + onLoadMore: () -> Unit, + scrollToItem: suspend (itemIndex: Int) -> Unit, + requestFocus: () -> Boolean, + onRestored: () -> Unit, + /** + * What to do on a surface with nothing recorded yet — ordinary first entry. + * + * True focuses the first item, which is what a surface whose entry point IS + * its first card wants, and makes restoration subsume its plain initial + * focus. False stands down and leaves entry to the screen: a person page + * deliberately opens on its filter chips so the identity header stays + * visible, and a restoration that grabbed the first poster instead would + * break that on every visit while only being wanted on returns. + * + * "Fresh arrival" here means no target was saved before this composition — + * see [TvFlatReturnRestoration.isReturning]. + */ + focusFirstItemWithoutTarget: Boolean = true, +): TvFlatReturnRestoration { + // The key is saved WITH the target and checked on the way back. + // rememberSaveable does not validate restored values against its inputs, so + // a process that comes back composing a different surface first would + // otherwise adopt the previous surface's target as its own. + val savedTarget = rememberSaveable( + surfaceKey, + stateSaver = keyedTvReturnTargetSaver(surfaceKey), + ) { + mutableStateOf(null) + } + val restoration = remember(surfaceKey, savedTarget) { + TvFlatReturnRestoration(savedTarget, isReturning = savedTarget.value != null) + } + + val currentItemIds by rememberUpdatedState(itemIds) + val currentHasMore by rememberUpdatedState(hasMore) + val currentIsLoadingMore by rememberUpdatedState(isLoadingMore) + val currentIsReplacing by rememberUpdatedState(isReplacingContent) + val currentError by rememberUpdatedState(errorMessage) + val currentLoadMore by rememberUpdatedState(onLoadMore) + val currentScrollToItem by rememberUpdatedState(scrollToItem) + val currentRequestFocus by rememberUpdatedState(requestFocus) + val currentOnRestored by rememberUpdatedState(onRestored) + + fun resolve(final: Boolean): TvReturnResolution = resolveTvReturnTarget( + target = restoration.target, + sections = flatTvReturnSections(itemIds = currentItemIds, hasMore = currentHasMore), + treatAbsenceAsFinal = final, + ) + + restoration.provisionalItemIndex = remember(itemIds, hasMore, restoration.target) { + (resolve(final = true) as? TvReturnResolution.Located)?.itemIndex ?: 0 + } + + // One hunt, driven as a loop rather than by re-keying this effect. + // + // Re-keying cannot work: Pending is a singleton, so a resolution that stays + // Pending is an unchanged key and the effect never re-runs. Every way a + // page can fail to move the list — a page of hidden or duplicate items, a + // failed fetch that leaves hasMore set, a request the surface rejects + // because one is already in flight — would stall restoration forever. + LaunchedEffect(surfaceKey, itemIds.isNotEmpty()) { + if (restoration.completed || currentItemIds.isEmpty()) return@LaunchedEffect + // A fresh arrival on a surface that owns its own entry focus. Decided + // from state saved before this composition began, so it cannot depend + // on whether a card has taken focus yet. + if (!restoration.isReturning && !focusFirstItemWithoutTarget) return@LaunchedEffect + // Claimed before the settle delay, not after: default or restored focus + // can land during it, and its callback would redefine the launch item. + restoration.inFlight = true + try { + delay(TvFlatReturnSettleDelayMillis) + + // Ordered after the settle delay on purpose: a resume refresh is + // usually dispatched a frame or two after the screen recomposes, so + // checking on arrival would sail straight past one that has not + // raised its flag yet. + // + // Bounded, because a surface wedged in refresh must not hold + // restoration open forever. Timing out ABANDONS — see below; an + // earlier version proceeded against whatever list existed, and + // that was the defect, not the fallback. + // + // Quiet has to be PROVEN, not assumed. Checking the flag on + // arrival was the first mistake: a false reading is exactly what a + // refresh dispatched one frame later also looks like. Watching for + // a restart with a SECOND subscription was the next one — a + // complete true→false pulse between the two subscriptions slips + // past unobserved. + // + // One subscription, then. collectLatest restarts the quiet timer on + // every change, so the window only elapses if nothing happened + // during it, which is what "settled" has to mean. + val settled = withTimeoutOrNull(TvFlatReturnReplaceWaitMillis) { + snapshotFlow { currentIsReplacing } + .transformLatest { replacing -> + if (!replacing) { + delay(TvFlatReturnSettleDelayMillis) + emit(Unit) + } + } + .first() + } != null + + // Abandon rather than proceed. Falling through was the previous + // answer, on the reasoning that a replacement landing mid-flight + // would fail the identity check and simply not restore — but that + // is not guaranteed. A target from the OUTGOING list can resolve + // Exact, skip the hunt, take focus and report a landing in the + // moment before the replacement removes the card underneath it, + // and then focus is somewhere nobody chose and the shell has been + // told content owns it. + // + // The target is deliberately left intact: this restoration is + // giving up, not deciding the target was wrong, so a later entry + // can still honour it. + if (!settled) return@LaunchedEffect + + // An even earlier version retargeted to the first item on timeout, + // reasoning that a reload produces page one so index zero cannot be + // invalidated. That was wrong twice over: the id came from the + // OUTGOING list, which a replacement can reorder, empty or drop + // that item from entirely, and it overwrote the real target in + // saved state where no later entry could retry it. + + withTimeoutOrNull(TvFlatReturnHuntBudgetMillis) { + var requests = 0 + while (resolve(final = false) is TvReturnResolution.Pending) { + val loadedBefore = currentItemIds.size + // A refresh in flight is a load too — asking for the next + // page on top of it appends at an offset the refresh is + // about to invalidate. + if (!currentIsLoadingMore && !currentIsReplacing) { + // The ceiling stops NEW requests. It must not stop us + // waiting for one already in flight, or the last page + // is abandoned with budget to spare. + if (requests >= TvFlatReturnPageRequests) break + currentLoadMore() + requests++ + } + val waited = awaitFlatPageSettled( + loadedBefore = loadedBefore, + itemCount = { currentItemIds.size }, + isLoadingMore = { currentIsLoadingMore || currentIsReplacing }, + ) + + // Judge the OUTCOME, and only when a load actually reached + // a terminal state. Growth is progress whatever an older + // error still says; a load that finished with nothing to + // show and left an error behind is a real failure — + // including when its message matches the previous one, + // which comparing error values would miss. + // + // NeverActive is why that distinction is needed: the + // request may still be queued, and the idle flag and any + // error then describe the world BEFORE it. + val grew = currentItemIds.size != loadedBefore + if (waited == TvFlatPageWait.Settled && + !grew && + !currentIsLoadingMore && + currentError != null + ) { + break + } + } + } + + val located = resolve(final = true) as? TvReturnResolution.Located + // Published as one value so the requester's position and the + // identity being watched for cannot drift apart. They did: a + // provisional index tracking live data while the identity stayed + // frozen moved the requester onto the real item and left the + // watcher waiting for the fallback — focus arriving exactly where + // it should and being recorded as a failure. + restoration.destination = located + val targetItemId = located?.itemId ?: currentItemIds.firstOrNull() + + currentScrollToItem(located?.itemIndex ?: 0) + // Readiness is part of the acquisition rather than a wait beside + // it. Waiting separately and requesting anyway on timeout only + // delays a wrong-node request; reporting NotReady holds the request + // until the requester is genuinely bound, and gives up honestly if + // it never is. + val landed = requestFocusUntilObserved( + maxAttempts = TvFlatReturnFocusAttempts, + awaitAttempt = { delay(TvFlatReturnFocusRetryMillis) }, + requestFocus = currentRequestFocus, + isFocused = { + targetItemId != null && restoration.focusedItemId == targetItemId + }, + targetState = { + if (targetItemId != null && restoration.attachedItemId == targetItemId) { + TvFocusTargetState.Ready + } else { + TvFocusTargetState.NotReady + } + }, + ) + // Latch either way — nothing re-keys this effect, so not latching + // means never trying again rather than trying later. But only + // report success when focus actually landed on the intended item. + // + // No blind rescue here. One was tried: if the identity-gated + // acquisition failed and no card had reported focus, request + // anyway and take whatever lands. It is unsound, because "no card + // reported focus" is not "nothing has focus" — these surfaces also + // hold sort and filter controls, genre chips and an A–Z rail, and + // focus can be sitting legitimately on any of them. The rescue + // would then steal it, seconds after the viewer arrived, which is + // worse than the restoration it was trying to salvage. Making it + // safe needs an authoritative "nothing on this screen has focus" + // signal that only the caller can provide. + if (landed == TvObservedFocusResult.Focused) currentOnRestored() + } finally { + restoration.inFlight = false + } + restoration.completed = true + } + + return restoration +} + +/** Lets the surface settle before restoration competes with default focus. */ +private const val TvFlatReturnSettleDelayMillis: Long = 120L + +/** + * How long to wait for a requested page to mark itself active. + * + * Short, because this only observes a flag set by an already-launched + * coroutine. Sizing it like a network round trip made a missed pulse — the load + * completing between two snapshot evaluations — cost seconds of frozen focus + * for nothing. + */ +private const val TvFlatReturnPageActiveTimeoutMillis: Long = 300L + +/** How long to wait for an active page to settle. */ +private const val TvFlatReturnPageSettleTimeoutMillis: Long = 2_000L + +/** + * The whole budget for hunting a target through unloaded pages. + * + * A wall clock, paired with — not replaced by — [TvFlatReturnPageRequests]. The + * two bound different things: this is what the viewer experiences, that is how + * hard a struggling endpoint gets pushed. Counting requests alone let one slow + * fetch spend the entire allowance on a single page and gave no ceiling at all. + */ +/** + * How long restoration will wait for a content REPLACEMENT to settle before it + * resolves anyway. + * + * Generous, because waiting costs nothing visible — the surface is mid-refresh + * and has no stable content to focus regardless — while giving up early costs a + * restoration against a list that is about to be discarded. It exists only so a + * refresh that never completes cannot wedge the surface. + */ +private const val TvFlatReturnReplaceWaitMillis: Long = 3_000L + +private const val TvFlatReturnHuntBudgetMillis: Long = 6_000L + +/** How many pages one restoration may ask for. */ +private const val TvFlatReturnPageRequests: Int = 4 + +private const val TvFlatReturnFocusRetryMillis: Long = 60L + +private val TvFlatReturnFocusAttempts: Int = + (TvFocusAcquisitionBudgetMillis / TvFlatReturnFocusRetryMillis).toInt() + +/** + * What a wait for a requested page actually observed. + * + * The distinction matters because the caller decides whether a page load + * failed, and it can only do that honestly for a load it saw reach a terminal + * state. [NeverActive] carries no information about any load — the flags it + * would otherwise read describe the world before the request. + */ +private enum class TvFlatPageWait { + /** + * A load reached a terminal state, or the list changed. Not necessarily + * the load this caller requested — an already-active one can be what + * settles — which is fine, because the caller reads this only as "a result + * exists to judge". + */ + Settled, + + /** Nothing marked itself active in time — the request may still be queued. */ + NeverActive, + + /** Observed running, but not finished when the wait expired. */ + StillActive, +} + +/** + * Wait for a requested page, reporting what was observed. + * + * A surface typically launches its load, so the loading flag is not reliably + * set by the time the request returns. Waiting only for "not loading" can + * therefore succeed instantly against the state from before the request, and a + * caller in a loop fires every request it has before the first fetch marks + * itself active — which the surface then accepts, because it also still sees + * idle. So this waits for a load to become active first, and only then for it + * to settle. + * + * Settling means the list changed OR loading cleared: a page can legitimately + * arrive without changing the visible list, and a failed fetch leaves `hasMore` + * set. Both phases time out, so this always returns promptly. + */ +private suspend fun awaitFlatPageSettled( + loadedBefore: Int, + itemCount: () -> Int, + isLoadingMore: () -> Boolean, +): TvFlatPageWait { + val became = withTimeoutOrNull(TvFlatReturnPageActiveTimeoutMillis) { + snapshotFlow { itemCount() to isLoadingMore() } + .first { (count, loading) -> count != loadedBefore || loading } + } ?: return TvFlatPageWait.NeverActive + if (became.first != loadedBefore) return TvFlatPageWait.Settled + val settled = withTimeoutOrNull(TvFlatReturnPageSettleTimeoutMillis) { + snapshotFlow { itemCount() to isLoadingMore() } + .first { (count, loading) -> count != loadedBefore || !loading } + } + return if (settled == null) TvFlatPageWait.StillActive else TvFlatPageWait.Settled +} + +/** + * Saves [value] alongside the surface that owns it, and refuses a restored + * payload belonging to a different one. + * + * `rememberSaveable(key)` resets when a running composition observes a changed + * input, but it does NOT validate a value RESTORED after process death against + * that input — so a process coming back on a different surface first would + * adopt the previous surface's state as its own. Same reasoning as + * [keyedTvReturnTargetSaver]; this is the scalar case. + */ +internal fun keyedBooleanSaver(resetToken: String, slot: String): Saver = + listSaver( + save = { value -> listOf(resetToken, slot, value) }, + restore = { saved -> + val values = saved as? List<*> ?: return@listSaver false + val owned = values.size == 3 && values[0] == resetToken && values[1] == slot + // Typed, not cast. An erased `as T` validates against Any, so a + // payload with the right owner and the wrong type passes here and + // fails later where Compose reads it — a crash during restoration + // rather than a value we can reject. The slot name is carried too: + // the owner token alone cannot tell one scalar slot from another. + if (owned) values[2] as? Boolean ?: false else false + }, + ) + +internal fun keyedIntSaver(resetToken: String, slot: String): Saver = + listSaver( + save = { value -> listOf(resetToken, slot, value) }, + restore = { saved -> + val values = saved as? List<*> ?: return@listSaver 0 + val owned = values.size == 3 && values[0] == resetToken && values[1] == slot + if (owned) values[2] as? Int ?: 0 else 0 + }, + ) + +internal fun keyedTvReturnTargetSaver(resetToken: String): Saver = + listSaver( + save = { target -> + target?.let { + listOf(resetToken, it.sectionId, it.itemId, it.sectionIndex, it.itemIndex) + } ?: listOf(resetToken) + }, + restore = { saved -> + val values = saved as? List<*> + if (values == null || values.size != 5 || values[0] != resetToken) { + null + } else { + // Safe casts throughout: a right-owner, wrong-shape payload + // should restore as "nothing to return to", not throw and take + // the screen down during restoration. + val sectionId = values[1] as? String + val itemId = values[2] as? String + val sectionIndex = values[3] as? Int + val itemIndex = values[4] as? Int + if (sectionId == null || itemId == null || + sectionIndex == null || itemIndex == null + ) { + null + } else { + TvReturnTarget( + sectionId = sectionId, + itemId = itemId, + sectionIndex = sectionIndex, + itemIndex = itemIndex, + ) + } + } + }, + ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFocusLog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFocusLog.kt new file mode 100644 index 000000000..0d486766e --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvFocusLog.kt @@ -0,0 +1,22 @@ +package org.prairieserver.prairie.tv.ui.focus + +import android.util.Log +import org.prairieserver.prairie.tv.BuildConfig + +/** + * Debug-build tracing for TV focus and IME behavior — `adb logcat -s PrairieTvFocus`. + * + * The failures this exists to tell apart look identical on screen ("keys do + * nothing" / "focus disappeared"): + * - the WINDOW lost focus (launcher stole input — no app log lines at all + * except the window-focus loss from MainTvActivity), + * - a focus claim was skipped (touch mode) or exhausted its retries, + * - the IME opened and is swallowing the D-pad. + */ +internal object TvFocusLog { + const val TAG = "PrairieTvFocus" + + inline fun d(message: () -> String) { + if (BuildConfig.DEBUG) Log.d(TAG, message()) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvModalFocusOwnership.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvModalFocusOwnership.kt new file mode 100644 index 000000000..1f883aa99 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvModalFocusOwnership.kt @@ -0,0 +1,116 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.foundation.focusGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import kotlinx.coroutines.delay + +/** + * A modal's focus boundary. + * + * `focusGroup()` alone is not one. It prioritises traversal inside the group, + * but it does not cancel a focus search at the group's edges — so D-pad + * movement from a boundary control walks straight out of a visually modal + * surface and into the still-composed page behind it. Cancelling the search on + * exit is what actually keeps focus inside. + * + * Apply to the modal's content root, alongside whatever acquires focus within + * it. Callers still need [TvRestoreFocusOnModalDismiss] to hand focus back. + */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +internal fun Modifier.tvModalFocusBoundary(): Modifier = this + .focusGroup() + // `exit` cancels a focus search that is leaving the group, which is exactly + // and only what a modal wants. Cancelling up/down/left/right on this node + // instead would also govern movement between the modal's own controls — + // trading an escape for a dead D-pad inside the modal. Same idiom as the + // shell, the audiobook overlay and the player HUD. + .focusProperties { exit = { FocusRequester.Cancel } } + +/** + * Take a control out of D-pad traversal while a modal owns focus. + * + * The containment in [tvModalFocusBoundary] only holds focus that is already + * inside the modal. It does nothing about focus that never got in — and an + * in-window overlay leaves the surface underneath fully composed and fully + * focusable, so a covered transport button keeps taking Select while a panel is + * open in front of it. + * + * Apply on the same modifier chain as the control's own focusable, ahead of it. + * An ancestor works too, but only conditionally: a focus target resolves its + * properties by walking up and applying each `FocusPropertiesModifierNode` it + * finds, stopping at the first ancestor that is itself a focus target. Anything + * introducing one in between — a `focusGroup()`, a TV `Surface`, another + * `focusable()` — swallows the suppression before it arrives, silently. Placing + * it on the control's own chain has no such dependency on what sits above. + * + * (`focusGroup()` is not that mechanism, despite looking like it: it deactivates + * its own target through `Focusability.Never` rather than through an inherited + * focus property, which is exactly why a focus group's children stay focusable.) + * + * Suppression alone can strand the viewer. Android TV does not re-home focus + * when the focused node stops being focusable: the ring disappears and the + * D-pad goes dead until something requests focus. So every surface that + * suppresses must also give focus somewhere to land — the modal on the way in + * (see the dialog initial-focus adapter) and [TvRestoreFocusOnModalDismiss] on + * the way out. + */ +internal fun Modifier.tvFocusSuppressed(suppressed: Boolean): Modifier = + if (suppressed) focusProperties { canFocus = false } else this + +private const val TvModalRestoreRetryDelayMillis = 60L + +internal const val TvModalRestoreMaxAttempts = + (TvFocusAcquisitionBudgetMillis / TvModalRestoreRetryDelayMillis).toInt() + +/** + * Hand focus back to whatever opened a modal once it closes. + * + * Without this the modal's nodes simply disappear and the focus system picks a + * geometric successor — which is rarely the control the viewer used to open it, + * and on a dimmed page is often something they cannot even see. + * + * Restoration is deliberately driven from the *caller's* scope rather than the + * modal's: an exit animation keeps the modal's nodes alive after `visible` goes + * false, so anything hosted inside it cannot reliably outlive its own dismissal. + * + * Nothing happens on first composition — a modal that has never been open has + * nothing to restore, and stealing focus on screen entry is its own bug. + * + * [isOpenerFocused] must genuinely observe the opener. A request that merely + * returns true has not acquired anything, and without observation the retry + * cannot tell success from an accepted-but-unfocused request — it would keep + * re-requesting for the whole budget after focus had already landed. + */ +@Composable +internal fun TvRestoreFocusOnModalDismiss( + visible: Boolean, + opener: FocusRequester?, + isOpenerFocused: () -> Boolean, +) { + var hasBeenVisible by remember { mutableStateOf(false) } + + LaunchedEffect(visible) { + if (visible) { + hasBeenVisible = true + return@LaunchedEffect + } + if (!hasBeenVisible || opener == null) return@LaunchedEffect + hasBeenVisible = false + requestFocusUntilObserved( + maxAttempts = TvModalRestoreMaxAttempts, + awaitAttempt = { delay(TvModalRestoreRetryDelayMillis) }, + requestFocus = opener::requestFocus, + // The opener sits behind an exit animation that is still tearing + // the modal down, so early attempts land before it is focusable. + isFocused = isOpenerFocused, + ) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt new file mode 100644 index 000000000..d56a0ec0c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt @@ -0,0 +1,76 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Two budgets, because the two situations fail differently. + * + * [TvFocusAcquisitionBudgetMillis] covers "nothing is focused yet" — a popup + * that has just opened. Exhausting it leaves a dead D-pad, so it is generous. + * + * [TvFocusRelocationBudgetMillis] covers "focus is already somewhere usable and + * we are trying to move it somewhere better" — a detail return, a settings page + * preferring the selected row. Exhausting it degrades to a working fallback, so + * it is short: a long relocation budget just means seconds of focus thrash. + */ +internal const val TvFocusAcquisitionBudgetMillis = 2_400L +internal const val TvFocusRelocationBudgetMillis = 480L + +/** `withFrameNanos` cadence on a 60 Hz panel, used to size frame-based budgets. */ +internal const val TvApproximateFrameMillis = 16L + +/** Frame-paced attempts that fit inside [TvFocusRelocationBudgetMillis]. */ +internal const val TvFrameRelocationMaxAttempts = + (TvFocusRelocationBudgetMillis / TvApproximateFrameMillis).toInt() + +internal enum class TvFocusTargetState { NotReady, Ready, Disposed } + +internal enum class TvFocusRequestOutcome { Rejected, AcceptedUnobserved, Focused } + +internal enum class TvObservedFocusResult { Focused, Exhausted, Disposed } + +internal fun observeTvFocusRequest( + requestAccepted: Boolean, + isFocused: Boolean, +): TvFocusRequestOutcome = when { + isFocused -> TvFocusRequestOutcome.Focused + requestAccepted -> TvFocusRequestOutcome.AcceptedUnobserved + else -> TvFocusRequestOutcome.Rejected +} + +internal suspend fun requestFocusUntilObserved( + maxAttempts: Int, + awaitAttempt: suspend () -> Unit, + requestFocus: () -> Boolean, + isFocused: () -> Boolean, + // Callers with no attach/detach signal to offer (a popup's own content root + // is composed for as long as the effect runs) leave this at Ready. + targetState: () -> TvFocusTargetState = { TvFocusTargetState.Ready }, +): TvObservedFocusResult { + require(maxAttempts > 0) { "maxAttempts must be positive" } + + repeat(maxAttempts) { + awaitAttempt() + if (isFocused()) return TvObservedFocusResult.Focused + + when (targetState()) { + TvFocusTargetState.Disposed -> return TvObservedFocusResult.Disposed + TvFocusTargetState.NotReady -> Unit + TvFocusTargetState.Ready -> { + val accepted = runCatching(requestFocus).getOrElse { exception -> + if (exception is CancellationException) throw exception + false + } + if (observeTvFocusRequest(accepted, isFocused()) == TvFocusRequestOutcome.Focused) { + return TvObservedFocusResult.Focused + } + } + } + } + + return when { + isFocused() -> TvObservedFocusResult.Focused + targetState() == TvFocusTargetState.Disposed -> TvObservedFocusResult.Disposed + else -> TvObservedFocusResult.Exhausted + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTarget.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTarget.kt new file mode 100644 index 000000000..e2ebea122 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTarget.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.tv.ui.focus + +/** The actions the pair-device panel can offer, in the order it offers them. */ +internal enum class TvPairDeviceAction { EnterCode, Check, Approve, Done } + +/** + * The action that should hold focus right now. + * + * This answers "which control deserves focus", not "which control is + * focusable", and the return is not nullable because every incomplete state + * renders Check and every completed one renders Done. + * + * An earlier version returned null while a token lookup was running, on the + * theory that the disabled Check had left the focus graph and nothing was + * focusable. It had not: TV Material keeps disabled buttons focusable. So the + * null was never a real state — it just meant focus was left whereever + * traversal put it, including on controls that could not be used. + * + * A resolved lookup outranks Check, because approving is what the viewer came + * to do. It is keyed on the lookup rather than on whether approving is + * *currently* actionable, so submitting a decision — which briefly makes it + * un-actionable — does not move the target out from under the viewer. + * + * Deny is deliberately never a target: it sits beside Approve and is one D-pad + * press away, and defaulting focus to the destructive choice is wrong. + */ +internal fun tvPairDeviceFocusTarget( + hasCompleted: Boolean, + hasResolvedLookup: Boolean, + canEnterCode: Boolean, +): TvPairDeviceAction = when { + hasCompleted -> TvPairDeviceAction.Done + hasResolvedLookup -> TvPairDeviceAction.Approve + canEnterCode -> TvPairDeviceAction.EnterCode + // Check is rendered in every incomplete state and gated transiently, so it + // is always both present and focusable — which is what makes this total. + else -> TvPairDeviceAction.Check +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTarget.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTarget.kt new file mode 100644 index 000000000..cadf7bb5c --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTarget.kt @@ -0,0 +1,43 @@ +package org.prairieserver.prairie.tv.ui.focus + +/** + * Which profile should own focus after the profile list changes. + * + * The screen reloads on every resume, so "the list changed" is the normal case, + * not an exceptional one — returning from an edit, deleting a tile in manage + * mode, or simply coming back to the screen all produce a new list. Anchoring + * unconditionally on the first tile therefore overrode the viewer's position + * every time. + * + * [previousIds] and [currentIds] are profile IDs in display order. + * [focusedId] is the profile that owned focus before the change, if any. + * + * Returns the profile ID to focus, or `null` to leave focus alone — which is + * what an already-correct focus position or an empty list both call for. + */ +internal fun tvProfileFocusTarget( + previousIds: List, + currentIds: List, + focusedId: String?, + hasMaterialized: Boolean, +): String? { + if (currentIds.isEmpty()) return null + + // First time the list has ever arrived: anchor so the D-pad has a home. + if (!hasMaterialized) return currentIds.first() + + // Nothing was focused here — a refresh must not seize focus from wherever + // the viewer actually is, which may be another part of the screen entirely. + val focused = focusedId ?: return null + + // Still present, possibly at a new index: it keeps focus, and the caller + // re-requests it so a reordered tile carries focus with it. + if (focused in currentIds) return focused + + // Deleted. Fall to the tile that took its place, or the last one if it was + // the tail. Landing on the first tile after deleting the fourth is the + // jump this whole function exists to avoid. + val removedIndex = previousIds.indexOf(focused) + if (removedIndex < 0) return null + return currentIds[removedIndex.coerceAtMost(currentIds.lastIndex)] +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdapters.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdapters.kt new file mode 100644 index 000000000..fca665711 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdapters.kt @@ -0,0 +1,62 @@ +package org.prairieserver.prairie.tv.ui.focus + +import org.prairieserver.prairie.model.section.ResolvedSection + +/** + * Projects a Skyline row feed into the shape [resolveTvReturnTarget] reads. + * + * Completeness asks one question: can more items still arrive in this row? Not + * whether more exist somewhere. The two look alike and pull in opposite + * directions, and a Skyline feed contains both cases: + * + * - A row with cards is **complete**, even when `totalCount` runs to hundreds. + * Such a row is *capped* — the server trims it to `itemLimit` — and nothing + * will ever fetch the remainder. Calling it incomplete would park every + * unresolved return in [TvReturnResolution.Pending] forever, waiting on a + * page no one requests. + * - A row with no cards but a non-zero `totalCount` is **incomplete**. That is + * not an empty row, it is a placeholder: `hydrateHomeSections` fetches those + * separately and fills them in. Calling it complete would read "not fetched + * yet" as "nothing here" and spend the return target on a fallback moments + * before the real row appeared. + * - A row with no cards and no total is genuinely empty, and complete. + * + * The caller supplies the matching container-level answer by passing the + * hydration's `fullyResolved` as `sectionsComplete`, which covers rows that had + * not arrived at all. + * + * Project the list the feed actually RENDERS, not an upstream one. Skyline + * drops empty rows before laying them out, so a projection taken from further + * up carries sections the feed never shows and puts every resolved index in a + * different coordinate space from the rows they are meant to address. + */ +internal fun List.toTvReturnSections(): List = + map { section -> + TvReturnSection( + id = section.id, + itemIds = section.items.map { it.contentId }, + isComplete = section.items.isNotEmpty() || section.totalCount == 0, + ) + } + +/** + * Projects a flat, paginated surface — a library grid, personal list, people or + * collection — into the shape [resolveTvReturnTarget] reads. + * + * One implicit section, and [hasMore] is the honest answer to the question + * completeness asks. These surfaces load a page at a time, so an item further + * in is absent from what is loaded in exactly the way a deleted item is; + * reporting complete while pages remain spends the return target on whichever + * card happens to be nearby. + * + * The caller that receives [TvReturnResolution.Pending] owes two things: ask + * for the next page, and bound the hunt — by a clock, a request ceiling, or + * both — then re-ask with `treatAbsenceAsFinal`. A surface that only waits + * waits forever. + */ +internal fun flatTvReturnSections( + itemIds: List, + hasMore: Boolean, +): List = listOf( + TvReturnSection(id = TvFlatSectionId, itemIds = itemIds, isComplete = !hasMore), +) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTarget.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTarget.kt new file mode 100644 index 000000000..9a6d46848 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTarget.kt @@ -0,0 +1,375 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import kotlin.math.abs + +/** + * The section id a surface with no sections uses. + * + * Library grids, personal lists and people have exactly one implicit section, + * and it is spelled out rather than left null so that "same section" means the + * same thing everywhere. A nullable section id looked tidier and was a trap: + * nothing in the section list could ever equal null, so a flat surface could + * never match its own launch section and every surviving item resolved as a + * positional fallback — the exact behaviour this contract exists to replace. + * + * Search is NOT one of these, despite looking like a list of results: it mixes + * library items with request-provider results in separate containers, which + * carry unrelated identifier spaces. It uses real section ids per container and + * namespaced item ids, as below. + * + * Surfaces whose identity is composite must namespace and encode it the same + * way on both sides — `catalog:` versus `request::` + * — so that two identifier spaces cannot collide on a bare number. Ids are + * opaque to this contract, which is exactly why the encoding has to be + * deliberate at the surface. + * + * The encoding must be stable, canonical and injective: every domain on a + * heterogeneous surface tagged, and any component that could itself contain the + * separator escaped or length-prefixed. Reserving a separator by convention is + * not enough — a title or provider key containing it silently forges a + * different identity. + */ +internal const val TvFlatSectionId: String = "" + +/** + * Where a surface was when it opened a detail page, and how to find that place + * again afterwards. + * + * Restoration used to be a pair of saved indices, which is only correct while + * the data is identical on the way back. It rarely is: a feed refreshes on + * resume, Continue Watching reorders the moment something is played, a finished + * item leaves the row it was in, a grid is re-sorted, and a recreated process + * rebuilds from whatever the server says now. Saved indices survive all of that + * syntactically and point at different content, so focus lands on something the + * viewer never chose — the failure being removed here. + * + * Identity is therefore the item itself, qualified by the section it was in. + * The pair is the *occurrence*: feeds routinely carry the same item in more + * than one row, and "the copy in Continue Watching" is not the same place as + * "the copy in Recently Added". Indices are kept, but only as coordinates for + * the fallback: they answer "roughly where was I" once the occurrence is gone, + * and nothing else. + * + * [itemId] is deliberately not called a content id. Surfaces restore to + * profiles, libraries, people and requests as well as media, and each brings + * its own identifier. Where a surface's natural identity is composite (a + * request's provider and id, say), it composes one string and uses it + * consistently on both sides. + */ +internal data class TvReturnTarget( + /** The row/section the item was in; [TvFlatSectionId] on a flat surface. */ + val sectionId: String, + val itemId: String, + /** Fallback coordinate only. Never the primary identity. */ + val sectionIndex: Int, + /** Fallback coordinate only. Never the primary identity. */ + val itemIndex: Int, +) + +/** + * Persists a [TvReturnTarget] across process death. + * + * Recreation is the case that most needs identity and least has it: the saved + * *node* the focus restorer would have used is gone, live state like the + * currently focused item id was never saveable, and what comes back is whatever + * the server says now. Indices alone survive that, and are least trustworthy + * exactly when they would otherwise be all that is left. + */ +internal val TvReturnTargetSaver: Saver = listSaver( + save = { target -> + target?.let { listOf(it.sectionId, it.itemId, it.sectionIndex, it.itemIndex) } ?: emptyList() + }, + restore = { saved -> + @Suppress("UNCHECKED_CAST") + val values = saved as List + if (values.isEmpty()) { + null + } else { + TvReturnTarget( + sectionId = values[0] as String, + itemId = values[1] as String, + sectionIndex = values[2] as Int, + itemIndex = values[3] as Int, + ) + } + }, +) + +/** A section of restorable content, as it stands *now*. */ +internal data class TvReturnSection( + val id: String, + val itemIds: List, + /** + * Whether [itemIds] is everything this section will hold *for the load in + * progress* — not for all time. A later refresh is a new snapshot, and a + * complete section can come back different. + * + * False while pages are still loading. Most surfaces here paginate, and an + * item on page four is missing from page one in exactly the way a deleted + * item is — so without this the resolver reads "not loaded yet" as "gone" + * and burns the target on a positional fallback before the real answer + * arrives. + */ + val isComplete: Boolean = true, +) + +/** + * Whether an item found in a *different* section counts as the same place. + * + * Off by default, because the two situations that produce it are + * indistinguishable from the data: an item genuinely moved between rows, or the + * launched occurrence disappeared while a copy that was always there sits + * elsewhere. Treating the second as a match throws focus vertically across the + * feed to a card the viewer never touched, which is worse than landing where + * they were. + * + * Overlapping feeds — Home, where a title can sit in Continue Watching and + * Recently Added at once — must stay on [SameSectionOnly]. Once the occurrence + * is defined as the pair, an item leaving its row means that occurrence is + * gone, and the positional fallback is the honest answer. Only surfaces whose + * sections are disjoint by construction can opt in. + */ +internal enum class TvReturnRelocation { SameSectionOnly, FollowAcrossSections } + +/** Where a surface should put focus when it comes back. */ +internal sealed interface TvReturnResolution { + /** A destination, carrying what it resolved to and not only where. */ + sealed interface Located : TvReturnResolution { + val sectionIndex: Int + val itemIndex: Int + val sectionId: String + val itemId: String + } + + /** The occurrence the viewer launched from is still here. */ + data class Exact( + override val sectionIndex: Int, + override val itemIndex: Int, + override val sectionId: String, + override val itemId: String, + ) : Located + + /** + * The occurrence is gone. This is the closest surviving position — what + * the viewer would reasonably expect under the cursor instead. + */ + data class Nearest( + override val sectionIndex: Int, + override val itemIndex: Int, + override val sectionId: String, + override val itemId: String, + ) : Located + + /** + * Not found, but its absence is not authoritative — something that could + * still produce it has not finished loading. Keep the target and ask again; + * do not consume it. + * + * Two caller obligations come with this, and neither can live in the + * resolver. It must *drive* the loading it is waiting on, because a + * demand-paged surface that only waits will wait forever. And it must bound + * the wait, then re-ask with `treatAbsenceAsFinal = true`, because pages can + * fail, `hasMore` can stay stuck true, and an unbounded wait leaves focus + * unrestored — the dead D-pad this campaign exists to remove. + */ + data object Pending : TvReturnResolution + + /** There is nothing focusable to return to. */ + data object Empty : TvReturnResolution +} + +/** + * Resolve a recorded [target] against the content as it stands now. + * + * Preference order: + * + * 1. The same occurrence — same section identity, same item. Matched on + * identity rather than index, so a reordered feed or a re-sorted grid still + * finds it. + * 2. The same item in another section, only when [relocation] allows it. + * 3. Its old position in its section, if that section survives: whatever took + * the slot. Clamped, because it may have been last. + * 4. The nearest surviving section, if the section went too. + * + * Absence is only acted on once it is authoritative — see + * [TvReturnSection.isComplete], [sectionsComplete] and [treatAbsenceAsFinal]. + * Sections with no items are never chosen, since there is nothing in them to + * focus. + * + * This says *where*, not *when*. A caller still has to scroll the destination + * into composition, attach a requester, and request focus under the observed + * policy; the identities in the result are what let it confirm afterwards that + * it landed on the thing that was resolved, rather than on whatever now + * occupies those coordinates. + * + * Section ids are REQUIRED to be unique and stable within a surface. That is + * not a stylistic preference: the no-stall rule below reads a present, finished + * launch section as the final word, which is only sound if no second section + * could later arrive bearing the same id. A surface that cannot guarantee + * unique ids must namespace them until it can. + * + * Duplicates are nonetheless handled deterministically rather than left to list + * order, so a data bug degrades predictably instead of moving focus about on + * every refresh: every namesake is searched, the one nearest the remembered + * coordinate wins, and within a section a repeated item resolves to the copy + * nearest the remembered position — an exact tie taking the earlier copy. That + * is damage control for a shape the contract does not support, and it is only + * meaningful once the section list is complete. + */ +internal fun resolveTvReturnTarget( + target: TvReturnTarget?, + sections: List, + relocation: TvReturnRelocation = TvReturnRelocation.SameSectionOnly, + /** + * Whether [sections] is every section this surface will have. + * + * The same problem as [TvReturnSection.isComplete], one level up: a feed + * still loading its rows is missing the launch row in exactly the way a + * deleted row is. A per-section flag cannot express that, because a section + * that has not arrived yet is not in the list to carry one. + */ + sectionsComplete: Boolean = true, + /** + * Stop waiting and answer from what is here. + * + * The terminal half of [TvReturnResolution.Pending]. A caller that has + * exhausted its budget sets this instead of misreporting completeness, so + * the fallback stays inside the contract rather than being reimplemented, + * differently, at each of the surfaces. + */ + treatAbsenceAsFinal: Boolean = false, +): TvReturnResolution { + if (target == null) return TvReturnResolution.Empty + + // Every section carrying the launch id, not merely the first: a duplicated + // section id could otherwise shadow the real one with an empty namesake. + val sameSections = sections.withIndex().filter { it.value.id == target.sectionId } + val populated = sections.withIndex().filter { it.value.itemIds.isNotEmpty() } + + // 1 — the occurrence, wherever its section has moved to. When a section id + // appears more than once, the nearest namesake holding the item wins: the + // same nearest-coordinate policy relocation uses, rather than whichever the + // list happens to reach first. + val sameSectionHit = sameSections + .filter { it.value.itemIds.contains(target.itemId) } + .minWithOrNull(nearestTo(target.sectionIndex)) + if (sameSectionHit != null) { + return TvReturnResolution.Exact( + sectionIndex = sameSectionHit.index, + itemIndex = sameSectionHit.value.itemIds.nearestIndexOf(target.itemId, target.itemIndex), + sectionId = sameSectionHit.value.id, + itemId = target.itemId, + ) + } + + // 2 — the item elsewhere, for surfaces that have said that is meaningful. + // Ties go to the section nearest where it was, so a duplicate three rows + // away does not win over one adjacent. + if (relocation == TvReturnRelocation.FollowAcrossSections) { + val relocated = populated + .filter { it.value.id != target.sectionId && it.value.itemIds.contains(target.itemId) } + .minWithOrNull(nearestTo(target.sectionIndex)) + if (relocated != null) { + return TvReturnResolution.Exact( + sectionIndex = relocated.index, + itemIndex = relocated.value.itemIds.nearestIndexOf(target.itemId, target.itemIndex), + sectionId = relocated.value.id, + itemId = target.itemId, + ) + } + } + + // Nothing found. Before falling back, decide whether "not here" is final — + // consuming the target early strands focus on a stand-in for good. + if (!treatAbsenceAsFinal && couldStillArrive(sections, sameSections, relocation, sectionsComplete)) { + return TvReturnResolution.Pending + } + + if (populated.isEmpty()) return TvReturnResolution.Empty + + // 3 — the slot it used to occupy, in the section that outlived it. + val survivingSameSection = sameSections + .filter { it.value.itemIds.isNotEmpty() } + .minWithOrNull(nearestTo(target.sectionIndex)) + if (survivingSameSection != null) { + val itemIndex = target.itemIndex.coerceIn(0, survivingSameSection.value.itemIds.lastIndex) + return TvReturnResolution.Nearest( + sectionIndex = survivingSameSection.index, + itemIndex = itemIndex, + sectionId = survivingSameSection.value.id, + itemId = survivingSameSection.value.itemIds[itemIndex], + ) + } + + // 4 — the section is gone or empty. Nearest survivor by the remembered + // coordinate, then the remembered position inside it. + val fallbackSection = populated.minWithOrNull(nearestTo(target.sectionIndex)) + ?: return TvReturnResolution.Empty + val itemIndex = target.itemIndex.coerceIn(0, fallbackSection.value.itemIds.lastIndex) + return TvReturnResolution.Nearest( + sectionIndex = fallbackSection.index, + itemIndex = itemIndex, + sectionId = fallbackSection.value.id, + itemId = fallbackSection.value.itemIds[itemIndex], + ) +} + +/** + * Whether anything still loading could yet produce the target. + * + * Deliberately policy-sensitive. Waiting on data that could not change the + * answer is not caution, it is a stall: under [TvReturnRelocation.SameSectionOnly] + * a present, finished launch section has already settled the question, and + * sections yet to load are irrelevant to it. + */ +private fun couldStillArrive( + sections: List, + sameSections: List>, + relocation: TvReturnRelocation, + sectionsComplete: Boolean, +): Boolean = when (relocation) { + // Any section still filling could carry the item, and a section not yet + // loaded could arrive carrying it. + TvReturnRelocation.FollowAcrossSections -> !sectionsComplete || sections.any { !it.isComplete } + // Only the launch section can answer. If it is here, its own pages decide + // and sections yet to load are irrelevant — which relies on section ids + // being unique, as the contract requires. If it is absent, it may still be + // on its way. + TvReturnRelocation.SameSectionOnly -> + if (sameSections.isEmpty()) !sectionsComplete else sameSections.any { !it.value.isComplete } +} + +/** + * Index of [itemId], preferring the copy closest to [preferredIndex]. + * + * Ids are expected to be unique within a section, but a feed that repeats one + * should land the viewer near where they were rather than at whichever copy + * comes first. + */ +private fun List.nearestIndexOf(itemId: String, preferredIndex: Int): Int = + withIndex() + .filter { it.value == itemId } + .minByOrNull { abs(it.index - preferredIndex) } + ?.index + ?: -1 + +/** + * Closest to [sectionIndex], preferring the later section when two are equally + * close. + * + * A forward bias, chosen because moving focus backwards lands the viewer in + * content they have already scrolled past. + * + * Not "the row that slid up": for the positional fallback a removed section + * makes its successor land at distance zero, which is no tie at all, so the tie + * there is only reachable around a section that is present but empty. The other + * callers can tie for their own reasons — two equidistant namesakes, or two + * equidistant relocation candidates — and the same bias applies to them. + */ +private fun nearestTo(sectionIndex: Int): Comparator> = + compareBy( + { candidate -> abs(candidate.index - sectionIndex) }, + { candidate -> if (candidate.index >= sectionIndex) 0 else 1 }, + ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSynchronousFocusClaim.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSynchronousFocusClaim.kt new file mode 100644 index 000000000..6f7eb7320 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSynchronousFocusClaim.kt @@ -0,0 +1,46 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.ui.focus.FocusRequester +import org.prairieserver.prairie.common.diagnostics.DiagnosticsFocusLogger + +/** + * A focus claim for callers that have no suspend point to retry in. + * + * [requestFocusUntilObserved] is the right answer wherever a coroutine is + * available, because arrival can be observed and a claim that never lands can + * be retried. Some callers cannot use it at all: + * + * - a `BackHandler` or `onPreviewKeyEvent` branch has to return synchronously + * whether it consumed the key; + * - `DisposableEffect { onDispose { … } }` runs during teardown, where there is + * no scope left to launch into. + * + * Those sites were the argument for leaving some `runCatching { requestFocus() }` + * in place forever. That argument was wrong. Retrying is not the only thing the + * policy provides — the other half is that a failure stops being invisible, and + * that half is available here too. + * + * So this does the one attempt those callers are limited to, and reports when + * it does not land instead of swallowing it. The caller gets the boolean it + * needs; the diagnostic exists whether or not anyone acts on it. `requestFocus` + * throws rather than returning false when its node has not attached, which is + * exactly the case worth knowing about, so the throw is caught and reported + * rather than propagated into a key handler. + * + * @param target short, stable name of what focus was aimed at — it lands in + * diagnostics, so it must not carry titles, ids, or anything else derived + * from the viewer's library. + * @return whether the request was accepted. Accepted is not arrival; nothing + * here can tell the difference. A caller that needs arrival needs a + * coroutine and [requestFocusUntilObserved]. + */ +internal fun FocusRequester.claimFocusOrReport(target: String, action: String): Boolean { + val accepted = runCatching { requestFocus() }.getOrElse { throwable -> + DiagnosticsFocusLogger.transition(target, "$action:threw:${throwable::class.simpleName}") + return false + } + if (!accepted) { + DiagnosticsFocusLogger.transition(target, "$action:rejected") + } + return accepted +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt index 59c3e631a..6b96ec380 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt @@ -7,13 +7,17 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.collectAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -25,6 +29,7 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.tv.MainTvActivity +import org.prairieserver.prairie.tv.ui.components.TvSelectToShowImeHost import org.prairieserver.prairie.tv.ui.shell.TvMainShell import org.prairieserver.prairie.tv.ui.screens.audiobook.TvAudiobookPlayerScreen import org.prairieserver.prairie.tv.ui.screens.auth.TvLoginScreen @@ -44,17 +49,20 @@ import org.prairieserver.prairie.tv.ui.screens.servers.TvServerListScreen import org.prairieserver.prairie.tv.ui.screens.servers.TvServerSwitchDestination import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsPromptScreen import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsReportScreen -import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsScreen +import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSurfacePresence import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel -import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyScreen +import org.prairieserver.prairie.tv.ui.screens.watchtogether.tvWatchTogetherDestination +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget import org.prairieserver.prairie.common.overlays.ProvideCardOverlays import org.prairieserver.prairie.common.diagnostics.DiagnosticsLifecycleLogger import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.tv.watchnext.WatchNextSeeder -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver -import org.prairieserver.prairie.tv.ui.screens.cast.TvPrairieCastStandbyView +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver +import org.prairieserver.prairie.tv.ui.screens.cast.TvSiloCastStandbyView import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -63,8 +71,19 @@ import org.koin.core.qualifier.named private const val RETURN_TO_MANAGE_SERVERS_KEY = "return_to_manage_servers" -internal fun tvShouldShowDiagnosticsPrompt(currentRoute: String?): Boolean = - currentRoute != TvRoute.Diagnostics.route && currentRoute != TvRoute.DiagnosticsReport.ROUTE +/** + * The crash prompt must never cover a diagnostics surface — the viewer is + * already looking at the thing it is asking about, and it would sit on top of + * its own Review target. + * + * The report detail is still a route, so it is matched by name. The settings + * surface is no longer a route (it is a pane inside `Main`), so it reports its + * own presence instead: see [TvDiagnosticsSurfacePresence]. + */ +internal fun tvShouldShowDiagnosticsPrompt( + currentRoute: String?, + diagnosticsSurfaceVisible: Boolean = false, +): Boolean = currentRoute != TvRoute.DiagnosticsReport.ROUTE && !diagnosticsSurfaceVisible /** * Upper bound on re-navigations for one queued deep link. Arrival-gated @@ -74,6 +93,202 @@ internal fun tvShouldShowDiagnosticsPrompt(currentRoute: String?): Boolean = */ private const val MAX_DEEP_LINK_NAV_ATTEMPTS = 3 +/** + * True when item detail for exactly [contentId]/[seasonNumber] is already the + * top of the stack. + * + * `launchSingleTop` cannot answer this: it matches on the destination NODE, and + * every item-detail route shares one node. Using it here meant navigating from + * detail A to related item B reused A's entry instead of pushing, so Back from + * B skipped A entirely. Comparing the concrete arguments keeps the double-Select + * protection that was actually wanted while letting a different item push. + * + * Navigation decodes path arguments, so these compare against the decoded id the + * callers pass — not the percent-encoded form in the route string. + */ +internal fun tvIsAlreadyShowingItemDetail( + currentRoute: String?, + currentContentId: String?, + currentSeasonNumber: Int?, + contentId: String, + seasonNumber: Int?, +): Boolean = + currentRoute == TvRoute.ItemDetail.ROUTE && + currentContentId == contentId && + currentSeasonNumber == seasonNumber + +/** Destinations that own an active playback session. */ +private val tvPlayerRoutes = setOf(TvRoute.Player.ROUTE, TvRoute.AudiobookPlayer.ROUTE) + +/** + * What to do with a playback request given what is already on top. + * + * `launchSingleTop` is the wrong tool here for the same reason it was wrong for + * item detail — it matches the destination node, not its arguments — and it is + * worse for a player: AndroidX implements single-top by reusing the existing + * back-stack entry with new arguments, so the entry's ViewModelStore survives + * and the previous title's player ViewModel (and its session) can live on beside + * the new one. + * + * Suppression means: *the entry this exact request created is still the top + * one*. [TvPlaybackNavigation] records the requested route together with the id + * of the entry it produced, and both must still hold. Note this identifies the + * entry, not its current arguments — see below for why nothing is allowed to + * rewrite a player entry in place. + * + * Weaker keys were tried and are wrong. The route alone ignores what is + * actually on top — cast launches and auto-advance navigate to players without + * coming through here, so it can name a player that is long gone. Route plus + * content id still collides when one of those puts up the SAME title with + * different arguments (an auto-advance handoff), suppressing a real request. + * + * The entry id closes both, because those paths pop and push. It is NOT + * self-sufficient: a `launchSingleTop` navigation mutates an entry's arguments + * while keeping its id, which would leave a stale record looking current. That + * is why Watch Together — the one player-bound path that did this — now routes + * through here too, and why no playback navigation uses `launchSingleTop`. + * + * It is recorded only when the navigation actually produced a NEW entry, so a + * navigation dropped during teardown leaves nothing behind to suppress its own + * retry — including when the player already up happens to be the same title. + */ +internal data class TvPlaybackNavigation(val destination: String, val entryId: String) + +internal enum class TvPlaybackNavAction { Push, ReplaceCurrentPlayer, Suppress } + +internal fun tvPlaybackNavAction( + currentRoute: String?, + currentEntryId: String?, + lastPlaybackNavigation: TvPlaybackNavigation?, + destination: String, +): TvPlaybackNavAction = when { + currentRoute !in tvPlayerRoutes -> TvPlaybackNavAction.Push + // A double Select whose second press landed while the first navigation was + // still animating: same request, same entry, nothing to do. + lastPlaybackNavigation != null && + lastPlaybackNavigation.destination == destination && + lastPlaybackNavigation.entryId == currentEntryId -> TvPlaybackNavAction.Suppress + // A genuinely different playback request while a player is up: take over + // the entry rather than stacking players Back would walk back through. + else -> TvPlaybackNavAction.ReplaceCurrentPlayer +} + +/** + * The navigation to remember after a playback request, or null if nothing + * usable arrived. + * + * [entryIdBefore] is what was on top before navigating. Requiring a different + * id afterwards is what distinguishes "our request landed" from "the navigation + * was dropped and the player already there happens to match" — the latter would + * otherwise be recorded as ours and suppress the retry. + */ +internal fun tvRecordedPlaybackNavigation( + destination: String, + contentId: String, + entryIdBefore: String?, + arrivedEntryId: String?, + arrivedContentId: String?, +): TvPlaybackNavigation? = + if (arrivedEntryId != null && arrivedEntryId != entryIdBefore && arrivedContentId == contentId) { + TvPlaybackNavigation(destination = destination, entryId = arrivedEntryId) + } else { + null + } + +/** The content id argument for whichever player destination [route] is. */ +private fun tvPlayerContentIdArg(route: String?): String? = when (route) { + TvRoute.Player.ROUTE -> TvRoute.Player.ARG_CONTENT_ID + TvRoute.AudiobookPlayer.ROUTE -> TvRoute.AudiobookPlayer.ARG_CONTENT_ID + else -> null +} + +/** Navigates to a playback destination, collapsing an identical repeat. */ +private fun NavHostController.navigateToTvPlayback( + destination: String, + contentId: String, + lastPlaybackNavigation: MutableState, +) { + val top = currentBackStackEntry + val topRoute = top?.destination?.route + when ( + tvPlaybackNavAction( + currentRoute = topRoute, + currentEntryId = top?.id, + lastPlaybackNavigation = lastPlaybackNavigation.value, + destination = destination, + ) + ) { + TvPlaybackNavAction.Suppress -> return + TvPlaybackNavAction.Push -> navigate(destination) + TvPlaybackNavAction.ReplaceCurrentPlayer -> + navigate(destination) { topRoute?.let { popUpTo(it) { inclusive = true } } } + } + // Record only what actually arrived. `navigate` can be dropped (see the + // deep-link collector), and remembering a request that never landed would + // let it suppress its own retry. + // + // A NEW entry id is the load-bearing part. Checking only the destination and + // content id would accept the player that was already there — replacing + // `A?roomId=x` with solo `A` and having the navigation dropped would record + // the untouched Watch Together entry as if it were ours, and the retry would + // then suppress. + val arrived = currentBackStackEntry + lastPlaybackNavigation.value = tvRecordedPlaybackNavigation( + destination = destination, + contentId = contentId, + entryIdBefore = top?.id, + arrivedEntryId = arrived?.id, + arrivedContentId = tvPlayerContentIdArg(arrived?.destination?.route) + ?.let { arg -> arrived?.arguments?.getString(arg) }, + ) +} + +/** + * Watch Together enters either a lobby or a player. The player case is an + * ordinary playback navigation and must go through [navigateToTvPlayback] — + * it used `launchSingleTop`, which mutates the existing player entry's + * arguments while PRESERVING its id, so a recorded solo request could still + * look current afterwards and suppress the user's next real request. + */ +private fun NavHostController.navigateToTvWatchTogether( + room: RoomSnapshot, + lastPlaybackNavigation: MutableState, +) { + val destination = tvWatchTogetherDestination(room) + val contentId = room.selectedContentId + if (watchTogetherEntryTarget(room) == WatchTogetherEntryTarget.Player && contentId != null) { + navigateToTvPlayback( + destination = destination, + contentId = contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) + } else { + navigate(destination) { launchSingleTop = true } + } +} + +/** Pushes item detail, collapsing only an exact repeat of the current page. */ +private fun NavHostController.navigateToTvItemDetail( + contentId: String, + seasonNumber: Int? = null, +) { + val top = currentBackStackEntry + if ( + tvIsAlreadyShowingItemDetail( + currentRoute = top?.destination?.route, + currentContentId = top?.arguments?.getString(TvRoute.ItemDetail.ARG_CONTENT_ID), + currentSeasonNumber = top?.arguments + ?.getString(TvRoute.ItemDetail.ARG_SEASON_NUMBER) + ?.toIntOrNull(), + contentId = contentId, + seasonNumber = seasonNumber, + ) + ) { + return + } + navigate(TvRoute.ItemDetail(contentId, seasonNumber).route) +} + /** * Top-level TV navigation graph. * @@ -113,21 +328,24 @@ fun TvAppNavigation( ) { val navController = rememberNavController() val scope = rememberCoroutineScope() + // The playback request [navigateToTvPlayback] last put on the stack, and + // the entry it produced. Only used to collapse an immediate repeat. + val lastPlaybackNavigation = remember { mutableStateOf(null) } val tokenManager: TokenManager = koinInject() val authRepository: AuthRepository = koinInject() val profileRepository: ProfileRepository = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore = koinInject() val watchNextSeeder: WatchNextSeeder = koinInject() - val prairieCastReceiver: TvPrairieCastReceiver = koinInject() + val siloCastReceiver: TvSiloCastReceiver = koinInject() val diagnosticsViewModel = koinViewModel() val diagnosticsState by diagnosticsViewModel.state.collectAsState() val pendingDeepLink: MutableStateFlow = koinInject(qualifier = named("pendingDeepLink")) - val prairieCastStandby by prairieCastReceiver.standbyState.collectAsState() + val siloCastStandby by siloCastReceiver.standbyState.collectAsState() - LaunchedEffect(prairieCastReceiver) { - prairieCastReceiver.launchRequests.collect { request -> + LaunchedEffect(siloCastReceiver) { + siloCastReceiver.launchRequests.collect { request -> val playback = request.playback val destination = TvRoute.Player( contentId = playback.contentId, @@ -136,11 +354,17 @@ fun TvAppNavigation( audioTrackIndex = playback.audioTrackIndex, subtitleTrackIndex = playback.subtitleTrackIndex, ).route - val replaceCurrentPlayer = navController.currentDestination?.route == TvRoute.Player.ROUTE + // Replace whichever player is on top, not just the video one. This + // only knew about TvRoute.Player, so a cast launch during an + // audiobook stacked over it and Back resurrected the audiobook + // player — restoring a session the viewer thought they had left. + val replacedPlayerRoute = navController.currentDestination?.route + ?.takeIf { it == TvRoute.Player.ROUTE || it == TvRoute.AudiobookPlayer.ROUTE } + // No launchSingleTop: popUpTo is evaluated first, so once the + // player entry is popped there is nothing left for single-top to + // match. Every Launch request deliberately (re)starts playback. navController.navigate(destination) { - if (replaceCurrentPlayer) { - popUpTo(TvRoute.Player.ROUTE) { inclusive = true } - } + replacedPlayerRoute?.let { popUpTo(it) { inclusive = true } } } } } @@ -213,12 +437,35 @@ fun TvAppNavigation( // Arrived: the current destination is this link's target, so the // link is spent. This is the only place a successful content link // is cleared — see the bookkeeping comment above. - val arrived = entry.arguments?.getString(TvRoute.ItemDetail.ARG_CONTENT_ID) == contentId && - when (uri.host) { - "item" -> route.startsWith("item/") - "play" -> route.startsWith("player/") || route.startsWith("audiobook/") - else -> false - } + val itemType = uri.getQueryParameter("type") + val playbackArgs = if (uri.host == "play") { + parseTvPlaybackDeepLinkArgs(uri::getQueryParameter) + } else { + null + } + val arrived = when (uri.host) { + "item" -> + route == TvRoute.ItemDetail.ROUTE && + entry.arguments?.getString(TvRoute.ItemDetail.ARG_CONTENT_ID) == contentId + "play" -> tvPlaybackDeepLinkArrived( + currentRoute = route, + currentContentId = entry.arguments?.getString(TvRoute.Player.ARG_CONTENT_ID), + currentFileId = entry.arguments + ?.getString(TvRoute.Player.ARG_FILE_ID) + ?.toIntOrNull(), + currentQuality = entry.arguments?.getString(TvRoute.Player.ARG_QUALITY), + currentAudioTrackIndex = entry.arguments + ?.getString(TvRoute.Player.ARG_AUDIO_TRACK_INDEX) + ?.toIntOrNull(), + currentSubtitleTrackIndex = entry.arguments + ?.getString(TvRoute.Player.ARG_SUBTITLE_TRACK_INDEX) + ?.toIntOrNull(), + itemType = itemType, + contentId = contentId, + requested = checkNotNull(playbackArgs), + ) + else -> false + } if (arrived) { Log.i(MainTvActivity.DEEP_LINK_TAG, "deep link arrived: ${uri.host}/$contentId") pendingDeepLink.value = null @@ -238,7 +485,7 @@ fun TvAppNavigation( "deep link navigating (attempt $attempts): ${uri.host}/$contentId from $route", ) when (uri.host) { - "item" -> navController.navigate(TvRoute.ItemDetail(contentId).route) + "item" -> navController.navigateToTvItemDetail(contentId) "play" -> { // The Watch Next mapper tags play intents with the item type // (`prairie://play/?type=`) so audiobook tiles @@ -247,23 +494,22 @@ fun TvAppNavigation( // [tvPlayDestinationFor] treats a null type as non-audiobook // and falls through to [TvRoute.Player], preserving today's // behavior for movie/episode tiles. - val itemType = uri.getQueryParameter("type") - val playbackArgs = parseTvPlaybackDeepLinkArgs(uri::getQueryParameter) - navController.navigate( - tvPlayDestinationFor( + val requested = checkNotNull(playbackArgs) + // A retried link (arrival-gated above) must not stack a + // second player over one already being created. + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( itemType = itemType, contentId = contentId, - fileId = playbackArgs.fileId, + fileId = requested.fileId, resumePositionSeconds = null, - audioTrackIndex = playbackArgs.audioTrackIndex, - subtitleTrackIndex = playbackArgs.subtitleTrackIndex, - quality = playbackArgs.quality, + audioTrackIndex = requested.audioTrackIndex, + subtitleTrackIndex = requested.subtitleTrackIndex, + quality = requested.quality, ), - ) { - // A retried link (arrival-gated above) must not stack a - // second player over one already being created. - launchSingleTop = true - } + contentId = contentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) } } } @@ -312,44 +558,55 @@ fun TvAppNavigation( popEnterTransition = { fadeIn(tween(TvPageFadeDurationMs)) }, popExitTransition = { fadeOut(tween(TvPageFadeDurationMs)) }, ) { + // The four auth screens are the select-to-show-IME flow: their fields + // must not raise the stock keyboard on focus, only on SELECT. The host + // wraps them here rather than around the whole NavHost because every + // other text surface (search, the text-entry dialogs) does want the + // keyboard the moment it is focused. composable(TvRoute.ServerSetup.route) { - TvServerSetupScreen( - onContinueToLogin = { signupEnabled -> - navController.navigate(TvRoute.Login(signupEnabled).route) { - popUpTo(TvRoute.ServerSetup.route) { inclusive = true } - } - }, - onNeedsSetup = { navController.navigate(TvRoute.Setup.route) }, - // Companion pairing pushed a server AND completed device-login, - // so the TV is already authenticated — skip the login screen and - // go straight to profile selection (same as a successful sign-in). - onPairedSignIn = { - navController.navigate(TvRoute.ProfileSelection.route) { - popUpTo(TvRoute.ServerSetup.route) { inclusive = true } - } - }, - ) + TvSelectToShowImeHost { + TvServerSetupScreen( + onContinueToLogin = { signupEnabled -> + navController.navigate(TvRoute.Login(signupEnabled).route) { + popUpTo(TvRoute.ServerSetup.route) { inclusive = true } + } + }, + onNeedsSetup = { navController.navigate(TvRoute.Setup.route) }, + // Companion pairing pushed a server AND completed device-login, + // so the TV is already authenticated — skip the login screen and + // go straight to profile selection (same as a successful sign-in). + onPairedSignIn = { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(TvRoute.ServerSetup.route) { inclusive = true } + } + }, + ) + } } composable(TvRoute.Setup.route) { - TvSetupScreen( - onSetupComplete = { - navController.navigate(TvRoute.ProfileSelection.route) { - popUpTo(0) { inclusive = true } - } - }, - ) + TvSelectToShowImeHost { + TvSetupScreen( + onSetupComplete = { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + } + }, + ) + } } composable(TvRoute.Signup.route) { - TvSignupScreen( - onSignupComplete = { - navController.navigate(TvRoute.ProfileSelection.route) { - popUpTo(0) { inclusive = true } - } - }, - onBackToLogin = { navController.popBackStack() }, - ) + TvSelectToShowImeHost { + TvSignupScreen( + onSignupComplete = { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(0) { inclusive = true } + } + }, + onBackToLogin = { navController.popBackStack() }, + ) + } } composable(TvRoute.ServerList.route) { @@ -400,28 +657,30 @@ fun TvAppNavigation( ), ) { backStack -> val signupEnabled = backStack.arguments?.getBoolean(TvRoute.Login.ARG_SIGNUP_ENABLED) ?: false - TvLoginScreen( - signupEnabled = signupEnabled, - onCreateAccount = { navController.navigate(TvRoute.Signup.route) }, - // Point this TV at a different server — drop Login so Back from - // setup can't return to a credential form with no server bound. - onChangeServer = { - navController.navigate(TvRoute.ServerSetup.route) { - popUpTo(TvRoute.Login.ROUTE) { inclusive = true } - launchSingleTop = true - } - }, - onLoginSuccess = { - navController.navigate(TvRoute.ProfileSelection.route) { - popUpTo(TvRoute.Login.ROUTE) { inclusive = true } - } - // Seed Watch Next now and schedule periodic refresh; the user has - // just authenticated so /api/v1/home/sections will return their - // actual continue-watching / next-up. - watchNextSeeder.seedNow() - watchNextSeeder.enqueuePeriodic() - }, - ) + TvSelectToShowImeHost { + TvLoginScreen( + signupEnabled = signupEnabled, + onCreateAccount = { navController.navigate(TvRoute.Signup.route) }, + // Point this TV at a different server — drop Login so Back from + // setup can't return to a credential form with no server bound. + onChangeServer = { + navController.navigate(TvRoute.ServerSetup.route) { + popUpTo(TvRoute.Login.ROUTE) { inclusive = true } + launchSingleTop = true + } + }, + onLoginSuccess = { + navController.navigate(TvRoute.ProfileSelection.route) { + popUpTo(TvRoute.Login.ROUTE) { inclusive = true } + } + // Seed Watch Next now and schedule periodic refresh; the user has + // just authenticated so /api/v1/home/sections will return their + // actual continue-watching / next-up. + watchNextSeeder.seedNow() + watchNextSeeder.enqueuePeriodic() + }, + ) + } } composable(TvRoute.ProfileSelection.route) { @@ -436,12 +695,16 @@ fun TvAppNavigation( watchNextSeeder.seedNow() watchNextSeeder.enqueuePeriodic() }, - onAddProfile = { navController.navigate(TvRoute.CreateProfile.route) }, + onAddProfile = { + navController.navigate(TvRoute.CreateProfile.route) { launchSingleTop = true } + }, onEditProfile = { profileId -> - navController.navigate(TvRoute.EditProfile(profileId).route) + navController.navigate(TvRoute.EditProfile(profileId).route) { + launchSingleTop = true + } }, onChangeServer = { - navController.navigate(TvRoute.ServerList.route) + navController.navigate(TvRoute.ServerList.route) { launchSingleTop = true } }, onSignOut = { scope.launch { @@ -487,27 +750,28 @@ fun TvAppNavigation( }, onManageServers = { mainEntry.savedStateHandle[RETURN_TO_MANAGE_SERVERS_KEY] = true - navController.navigate(TvRoute.ServerList.route) - }, - onOpenDiagnostics = { - navController.navigate(TvRoute.Diagnostics.route) + navController.navigate(TvRoute.ServerList.route) { launchSingleTop = true } }, - onOpenItemDetail = { contentId -> - // launchSingleTop collapses a double-OK on the same card into - // one ItemDetail entry (consecutive identical contentId), so - // Back doesn't appear inert against a duplicate. Distinct - // pushes are unaffected — their route args differ. - navController.navigate(TvRoute.ItemDetail(contentId).route) { + onOpenDiagnosticsReport = { reportId -> + navController.navigate(TvRoute.DiagnosticsReport(reportId).route) { launchSingleTop = true } }, - onOpenLibraryCollectionDetail = { libraryId, collectionId, title -> + onOpenItemDetail = { contentId -> + navController.navigateToTvItemDetail(contentId) + }, + onOpenWatchTogether = { room -> + navController.navigateToTvWatchTogether(room, lastPlaybackNavigation) + }, + onOpenLibraryCollectionDetail = { libraryId, collectionId, title, libraryType -> navController.navigate( - TvRoute.LibraryCollectionDetail(libraryId, collectionId, title).route, + TvRoute.LibraryCollectionDetail(libraryId, collectionId, title, libraryType).route, ) }, onOpenCollectionDetail = { collectionId, title -> - navController.navigate(TvRoute.CollectionDetail(collectionId, title).route) + navController.navigate(TvRoute.CollectionDetail(collectionId, title).route) { + launchSingleTop = true + } }, onSignedOut = { scope.launch { @@ -564,7 +828,9 @@ fun TvAppNavigation( // Server" opens the server list; the user picks an existing // saved server or chooses Add to enter a new URL. onSwitchServer = { - navController.navigate(TvRoute.ServerList.route) + navController.navigate(TvRoute.ServerList.route) { + launchSingleTop = true + } }, onPairDevice = { navController.navigate(TvRoute.PairDevice().route) { @@ -572,28 +838,39 @@ fun TvAppNavigation( } }, onPlayItem = { playContentId, itemType, resumePositionSeconds -> - navController.navigate( - tvPlayDestinationFor( + // A fast double Select otherwise stacks a second player, + // starting two sessions and leaving Back on a duplicate. + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( itemType = itemType, contentId = playContentId, fileId = null, resumePositionSeconds = resumePositionSeconds, ), + contentId = playContentId, + lastPlaybackNavigation = lastPlaybackNavigation, ) }, onOpenPersonDetail = { personId -> - navController.navigate(TvRoute.PersonDetail(personId).route) + navController.navigate(TvRoute.PersonDetail(personId).route) { + launchSingleTop = true + } }, ) } - composable(TvRoute.Diagnostics.route) { - TvDiagnosticsSettingsScreen( - onBack = { navController.popBackStack() }, - onReportSelected = { reportId -> - navController.navigate(TvRoute.DiagnosticsReport(reportId).route) - }, - ) + // ---- Removed route aliases (defensive) ---- see [TvRemovedRoutes]. + for (removedRoute in TvRemovedRoutes) { + composable(removedRoute) { + LaunchedEffect(Unit) { + navController.navigate(TvRoute.Main.route) { + popUpTo(removedRoute) { inclusive = true } + // A restored stack already holds Main below the alias; + // without this the redirect would stack a second one. + launchSingleTop = true + } + } + } } composable( @@ -636,73 +913,57 @@ fun TvAppNavigation( // actually binds to that version instead of always defaulting // to the server's first listed file (which for multi-version // titles is often the lower-resolution encode). - onPlay = { playContentId, fileId, audioTrackIndex, subtitleTrackIndex, itemType, resumePositionSeconds -> - navController.navigate( - tvPlayDestinationFor( + onPlay = { playContentId, fileId, audioTrackIndex, audioPicked, subtitleSelection, itemType, resumePositionSeconds -> + // A fast Select after entering detail can overlap the route + // transition. Collapse an identical second Play request + // instead of creating two player ViewModels and two + // concurrent playback-session starts. + navController.navigateToTvPlayback( + destination = tvPlayDestinationFor( itemType = itemType, contentId = playContentId, fileId = fileId, resumePositionSeconds = resumePositionSeconds, audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, + audioPickedThisSession = audioPicked, + subtitleSelection = subtitleSelection, ), - ) { - // A fast Select after entering detail can overlap the - // route transition. Collapse an identical second Play - // request instead of creating two player ViewModels and - // two concurrent playback-session starts. - launchSingleTop = true - } + contentId = playContentId, + lastPlaybackNavigation = lastPlaybackNavigation, + ) }, onItemDetail = { itemContentId -> - // launchSingleTop suppresses the exact double-tap dupe; a - // distinct related item (always a different contentId) still - // pushes normally. - navController.navigate(TvRoute.ItemDetail(itemContentId).route) { - launchSingleTop = true - } + // The helper, not a bare navigate: a DIFFERENT related + // item pushes — which is what makes the return + // restoration reachable — while an exact repeat is + // collapsed by argument, not by destination node. + navController.navigateToTvItemDetail(itemContentId) }, // Season switching replaces the current detail entry so paging // through seasons never stacks pages — one Back returns to the // screen the user arrived from. onItemDetailReplace = { itemContentId -> val current = navController.currentBackStackEntry?.destination?.route + // No launchSingleTop: popUpTo is evaluated first, so once + // the current page is popped there is nothing left for + // single-top to match. navController.navigate(TvRoute.ItemDetail(itemContentId).route) { - launchSingleTop = true current?.let { popUpTo(it) { inclusive = true } } } }, onSeriesClick = { seriesId -> - navController.navigate(TvRoute.ItemDetail(seriesId).route) { - launchSingleTop = true - } + navController.navigateToTvItemDetail(seriesId) }, onSeasonClick = { seriesId, selectedSeason -> - navController.navigate(TvRoute.ItemDetail(seriesId, selectedSeason).route) { - launchSingleTop = true - } + navController.navigateToTvItemDetail(seriesId, selectedSeason) }, - // Watch Together: the entry dialog resolves a room snapshot; route - // host-with-selection straight to the synced player (carrying - // roomId), otherwise into the lobby to wait/vote/pick. onWatchTogether = { snapshot -> - val hostAlone = - snapshot.selfRole == MemberRole.Host && snapshot.memberCount <= 1 - val target = if (!snapshot.selectedContentId.isNullOrBlank() && !hostAlone) { - TvRoute.Player( - contentId = snapshot.selectedContentId!!, - fileId = snapshot.selectedFileId, - roomId = snapshot.roomId, - resumePositionSeconds = snapshot.anchorPositionSeconds - .takeIf { it.isFinite() && it > 0.0 }, - ).route - } else { - TvRoute.WatchTogetherLobby(roomId = snapshot.roomId).route - } - navController.navigate(target) + navController.navigateToTvWatchTogether(snapshot, lastPlaybackNavigation) }, onOpenPerson = { personId -> - navController.navigate(TvRoute.PersonDetail(personId).route) + navController.navigate(TvRoute.PersonDetail(personId).route) { + launchSingleTop = true + } }, onBack = { navController.popBackStack() }, ) @@ -718,9 +979,7 @@ fun TvAppNavigation( TvPersonDetailScreen( personId = personId, onOpenItemDetail = { itemContentId -> - navController.navigate(TvRoute.ItemDetail(itemContentId).route) { - launchSingleTop = true - } + navController.navigateToTvItemDetail(itemContentId) }, onBack = { navController.popBackStack() }, ) @@ -786,11 +1045,26 @@ fun TvAppNavigation( nullable = true defaultValue = null }, + // Declared rather than left to inference so a restored back + // stack has a defined value: it decides whether the choice + // carries to the next episode. + navArgument(TvRoute.Player.ARG_AUDIO_PICKED) { + type = NavType.StringType + nullable = true + defaultValue = null + }, navArgument(TvRoute.Player.ARG_SUBTITLE_TRACK_INDEX) { type = NavType.StringType nullable = true defaultValue = null }, + // Declared for the same reason as ARG_AUDIO_PICKED: it decides + // whether the carried subtitle counts as the viewer's choice. + navArgument(TvRoute.Player.ARG_SUBTITLE_AUTO_RESOLVED) { + type = NavType.StringType + nullable = true + defaultValue = null + }, navArgument(TvRoute.Player.ARG_AUTO_ADVANCE_COUNT) { type = NavType.StringType nullable = true @@ -801,6 +1075,11 @@ fun TvAppNavigation( nullable = true defaultValue = null }, + navArgument(TvRoute.Player.ARG_EPISODE_SELECTION_HANDOFF_NONCE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { backStack -> val contentId = backStack.arguments @@ -816,15 +1095,32 @@ fun TvAppNavigation( val audioTrackIndex = backStack.arguments ?.getString(TvRoute.Player.ARG_AUDIO_TRACK_INDEX) ?.toIntOrNull() + val audioPickedThisSession = backStack.arguments + ?.getString(TvRoute.Player.ARG_AUDIO_PICKED) == "true" val subtitleTrackIndex = backStack.arguments ?.getString(TvRoute.Player.ARG_SUBTITLE_TRACK_INDEX) ?.toIntOrNull() + val subtitleAutoResolved = backStack.arguments + ?.getString(TvRoute.Player.ARG_SUBTITLE_AUTO_RESOLVED) == "true" val resumePositionOverride = VideoPlayerRouteArgs.parseResumePosition( backStack.arguments?.getString(TvRoute.Player.ARG_RESUME_POSITION), ) val autoAdvanceCount = backStack.arguments ?.getString(TvRoute.Player.ARG_AUTO_ADVANCE_COUNT) ?.toIntOrNull() ?: 0 + val episodeSelectionHandoffNonce = backStack.arguments + ?.getString(TvRoute.Player.ARG_EPISODE_SELECTION_HANDOFF_NONCE) + ?.takeIf(::isValidTvEpisodeSelectionHandoffNonce) + val episodeSelectionHandoff = remember( + backStack, + contentId, + episodeSelectionHandoffNonce, + ) { + processTvEpisodeSelectionHandoffRegistry.claim( + nonce = episodeSelectionHandoffNonce, + targetContentId = contentId, + ) + } TvPlayerScreen( contentId = contentId, preferredFileId = preferredFileId, @@ -832,13 +1128,24 @@ fun TvAppNavigation( roomId = roomId, resumePositionOverride = resumePositionOverride, initialAudioTrackIndex = audioTrackIndex, + initialAudioPickedThisSession = audioPickedThisSession, initialSubtitleTrackIndex = subtitleTrackIndex, + initialSubtitleAutoResolved = subtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, - onPlayNext = { nextContentId, nextCount, nextQuality -> + episodeSelectionHandoff = episodeSelectionHandoff, + onPlayNext = { nextContentId, nextCount, handoff -> + val handoffNonce = processTvEpisodeSelectionHandoffRegistry.register( + targetContentId = nextContentId, + handoff = handoff, + ) // Replace the current player in the back stack so an // auto-played chain doesn't pile up episodes behind Back. navController.navigate( - TvRoute.Player(contentId = nextContentId, quality = nextQuality, autoAdvanceCount = nextCount).route, + TvRoute.Player( + contentId = nextContentId, + autoAdvanceCount = nextCount, + episodeSelectionHandoffNonce = handoffNonce, + ).route, ) { popUpTo(TvRoute.Player.ROUTE) { inclusive = true } } @@ -910,6 +1217,10 @@ fun TvAppNavigation( type = NavType.StringType defaultValue = "" }, + navArgument(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) { + type = NavType.StringType + defaultValue = "" + }, ), ) { backStack -> val libraryId = backStack.arguments @@ -921,14 +1232,16 @@ fun TvAppNavigation( val title = backStack.arguments ?.getString(TvRoute.LibraryCollectionDetail.ARG_TITLE) .orEmpty() + val libraryType = backStack.arguments + ?.getString(TvRoute.LibraryCollectionDetail.ARG_LIBRARY_TYPE) + .orEmpty() TvLibraryCollectionDetailScreen( libraryId = libraryId, collectionId = collectionId, title = title, + libraryType = libraryType, onItemClick = { contentId -> - navController.navigate(TvRoute.ItemDetail(contentId).route) { - launchSingleTop = true - } + navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, ) @@ -954,29 +1267,40 @@ fun TvAppNavigation( collectionId = collectionId, title = title, onItemClick = { contentId -> - navController.navigate(TvRoute.ItemDetail(contentId).route) { - launchSingleTop = true - } + navController.navigateToTvItemDetail(contentId) }, onBack = { navController.popBackStack() }, ) } } - prairieCastStandby?.let { state -> - TvPrairieCastStandbyView( + siloCastStandby?.let { state -> + TvSiloCastStandbyView( state = state, - onDisconnect = prairieCastReceiver::disconnectRemoteControl, + onDisconnect = siloCastReceiver::disconnectRemoteControl, ) } diagnosticsState.prompt - ?.takeIf { tvShouldShowDiagnosticsPrompt(currentEntry?.destination?.route) } + ?.takeIf { + tvShouldShowDiagnosticsPrompt( + currentRoute = currentEntry?.destination?.route, + diagnosticsSurfaceVisible = TvDiagnosticsSurfacePresence.isVisible, + ) + } ?.let { prompt -> TvDiagnosticsPromptScreen( prompt = prompt, - onReview = { navController.navigate(TvRoute.Diagnostics.route) }, + // "Review" means review *this* report, so it lands on the report + // itself rather than on a list the viewer would then have to + // navigate. (The diagnostics list is now Settings › Diagnostics.) + onReview = { + navController.navigate(TvRoute.DiagnosticsReport(prompt.reportId).route) { + launchSingleTop = true + } + }, onSend = { diagnosticsViewModel.uploadPrompt(prompt) }, onAlwaysSend = { diagnosticsViewModel.alwaysSendPrompt(prompt) }, onDontSend = { diagnosticsViewModel.declinePrompt(prompt) }, + allowAlwaysSend = diagnosticsState.allowsAutomaticUpload, ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRouting.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRouting.kt index 3b8503683..931f87e20 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRouting.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRouting.kt @@ -29,8 +29,29 @@ fun tvPlayDestinationFor( fileId: Int?, resumePositionSeconds: Double?, audioTrackIndex: Int? = null, + audioPickedThisSession: Boolean = false, subtitleTrackIndex: Int? = null, quality: String? = null, +): String = tvPlayDestinationFor( + itemType = itemType, + contentId = contentId, + fileId = fileId, + resumePositionSeconds = resumePositionSeconds, + audioTrackIndex = audioTrackIndex, + audioPickedThisSession = audioPickedThisSession, + subtitleSelection = explicitTvSubtitleLaunchSelection(subtitleTrackIndex), + quality = quality, +) + +fun tvPlayDestinationFor( + itemType: String?, + contentId: String, + fileId: Int?, + resumePositionSeconds: Double?, + audioTrackIndex: Int?, + audioPickedThisSession: Boolean, + subtitleSelection: TvSubtitleLaunchSelection?, + quality: String? = null, ): String = if (isAudiobookItemType(itemType)) { // Audiobooks have no audio/subtitle track selection — ignore the indexes. @@ -46,7 +67,9 @@ fun tvPlayDestinationFor( quality = quality, resumePositionSeconds = resumePositionSeconds, audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, + audioPickedThisSession = audioPickedThisSession, + subtitleTrackIndex = subtitleSelection?.selectionIndex, + subtitleAutoResolved = subtitleSelection?.autoResolved == true, ).route } @@ -66,3 +89,34 @@ internal fun parseTvPlaybackDeepLinkArgs( audioTrackIndex = queryParameter("audioTrackIndex")?.toIntOrNull()?.takeIf { it >= 0 }, subtitleTrackIndex = queryParameter("subtitleTrackIndex")?.toIntOrNull()?.takeIf { it >= -1 }, ) + +/** + * Whether the current player entry represents this exact app-owned play link. + * + * Arrival gating must compare the full request, not only content identity. A + * warm deep link can deliberately replay the same file with another subtitle, + * audio track or quality. Treating the title already on screen as "arrived" + * consumed that new link before navigation and left the old player state live. + */ +internal fun tvPlaybackDeepLinkArrived( + currentRoute: String?, + currentContentId: String?, + currentFileId: Int?, + currentQuality: String?, + currentAudioTrackIndex: Int?, + currentSubtitleTrackIndex: Int?, + itemType: String?, + contentId: String, + requested: TvPlaybackDeepLinkArgs, +): Boolean { + if (currentContentId != contentId) return false + if (isAudiobookItemType(itemType)) { + return currentRoute == TvRoute.AudiobookPlayer.ROUTE && + currentFileId == requested.fileId + } + return currentRoute == TvRoute.Player.ROUTE && + currentFileId == requested.fileId && + VideoPlayerRouteArgs.normalizeQuality(currentQuality) == requested.quality && + currentAudioTrackIndex == requested.audioTrackIndex && + currentSubtitleTrackIndex == requested.subtitleTrackIndex +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvEpisodeSelectionHandoffRegistry.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvEpisodeSelectionHandoffRegistry.kt new file mode 100644 index 000000000..fa06564a8 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvEpisodeSelectionHandoffRegistry.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.tv.ui.navigation + +import java.util.LinkedHashMap +import java.util.UUID +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff + +private const val MIN_HANDOFF_NONCE_LENGTH = 16 +private const val MAX_HANDOFF_NONCE_LENGTH = 32 +private const val DEFAULT_MAX_HANDOFFS = 32 +private const val DEFAULT_HANDOFF_TTL_MILLIS = 5 * 60 * 1_000L +private val handoffNoncePattern = Regex("^[A-Za-z0-9_-]+$") + +internal fun isValidTvEpisodeSelectionHandoffNonce(nonce: String?): Boolean = + nonce != null && + nonce.length in MIN_HANDOFF_NONCE_LENGTH..MAX_HANDOFF_NONCE_LENGTH && + handoffNoncePattern.matches(nonce) + +/** + * Process-only transport for the semantic episode handoff. + * + * Navigation persists only an opaque nonce. Entries are target-content bound, + * single-use, short-lived, and capacity bounded so a restored back stack cannot + * recreate selection intent after process death. + */ +internal class TvEpisodeSelectionHandoffRegistry( + private val maxEntries: Int = DEFAULT_MAX_HANDOFFS, + private val ttlMillis: Long = DEFAULT_HANDOFF_TTL_MILLIS, + private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000L }, + private val nonceFactory: () -> String = { + UUID.randomUUID().toString().replace("-", "") + }, +) { + private data class Entry( + val targetContentId: String, + val handoff: EpisodeSelectionHandoff, + val expiresAtMillis: Long, + ) + + private val entries = LinkedHashMap() + + init { + require(maxEntries > 0) + require(ttlMillis > 0L) + } + + @Synchronized + fun register( + targetContentId: String, + handoff: EpisodeSelectionHandoff, + ): String { + require(targetContentId.isNotBlank()) + val now = nowMillis() + removeExpired(now) + while (entries.size >= maxEntries) { + val eldest = entries.keys.firstOrNull() ?: break + entries.remove(eldest) + } + repeat(MAX_NONCE_GENERATION_ATTEMPTS) { + val nonce = nonceFactory() + if (isValidTvEpisodeSelectionHandoffNonce(nonce) && nonce !in entries) { + entries[nonce] = Entry( + targetContentId = targetContentId, + handoff = handoff, + expiresAtMillis = now + ttlMillis, + ) + return nonce + } + } + error("Could not allocate a unique episode handoff nonce.") + } + + /** Claim removes the entry before checking its target, making every attempt single-use. */ + @Synchronized + fun claim( + nonce: String?, + targetContentId: String, + ): EpisodeSelectionHandoff? { + if (!isValidTvEpisodeSelectionHandoffNonce(nonce)) return null + removeExpired(nowMillis()) + val entry = entries.remove(nonce) ?: return null + return entry.handoff.takeIf { entry.targetContentId == targetContentId } + } + + @Synchronized + fun clear() { + entries.clear() + } + + private fun removeExpired(now: Long) { + entries.entries.removeAll { (_, entry) -> now >= entry.expiresAtMillis } + } + + private companion object { + const val MAX_NONCE_GENERATION_ATTEMPTS = 8 + } +} + +/** Empty in a recreated process by construction; never persisted or saved. */ +internal val processTvEpisodeSelectionHandoffRegistry = TvEpisodeSelectionHandoffRegistry() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt index b195838fd..f150c4652 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt @@ -62,7 +62,9 @@ sealed class TvRoute(val route: String) { // --- Main (drawer + nested nav: home, libraries, search, settings) --- data object Main : TvRoute("main") - data object Diagnostics : TvRoute("diagnostics") + + // No "diagnostics" list route: the diagnostics settings surface is a + // category inside Settings (tvOS parity). Only the report detail is pushed. data class DiagnosticsReport(val reportId: String) : TvRoute("diagnostics/report/${reportId.routeEncode()}") { companion object { @@ -75,9 +77,9 @@ sealed class TvRoute(val route: String) { data class ItemDetail(val contentId: String, val seasonNumber: Int? = null) : TvRoute( if (seasonNumber != null) { - "item/$contentId?seasonNumber=$seasonNumber" + "item/${contentId.routeEncode()}?seasonNumber=$seasonNumber" } else { - "item/$contentId" + "item/${contentId.routeEncode()}" }, ) { companion object { @@ -101,13 +103,29 @@ sealed class TvRoute(val route: String) { val resumePositionSeconds: Double? = null, /** Pre-selected audio track index (0-based) chosen on the detail screen. */ val audioTrackIndex: Int? = null, - /** Pre-selected subtitle track index (0-based; -1 = Off). */ + /** + * True when [audioTrackIndex] is a pick the viewer made this session, + * false when it is a durable value seeded onto the detail screen. The + * ordinal alone cannot tell them apart, and the difference decides + * whether the choice carries to the next episode. + */ + val audioPickedThisSession: Boolean = false, + /** Pre-selected subtitle track index (combined space; -1 = Off). */ val subtitleTrackIndex: Int? = null, + /** + * True when [subtitleTrackIndex] is the detail row's Auto preview + * rather than a pick the viewer made. It still decides what plays — + * that is the point of handing it over — but it must never be recorded + * as an explicit choice. + */ + val subtitleAutoResolved: Boolean = false, /** Consecutive auto-advance count for pass-out protection (0 = manual start). */ val autoAdvanceCount: Int = 0, + /** Opaque key for a process-only, target-bound episode selection handoff. */ + val episodeSelectionHandoffNonce: String? = null, ) : TvRoute( buildString { - append("player/$contentId") + append("player/${contentId.routeEncode()}") val query = buildList { if (fileId != null) add("fileId=$fileId") VideoPlayerRouteArgs.normalizeQuality(quality)?.let { value -> @@ -115,8 +133,15 @@ sealed class TvRoute(val route: String) { } if (roomId != null) add("roomId=${roomId.routeEncode()}") if (audioTrackIndex != null) add("audioTrackIndex=$audioTrackIndex") + if (audioPickedThisSession) add("audioPicked=true") if (subtitleTrackIndex != null) add("subtitleTrackIndex=$subtitleTrackIndex") + if (subtitleTrackIndex != null && subtitleAutoResolved) { + add("$ARG_SUBTITLE_AUTO_RESOLVED=true") + } if (autoAdvanceCount > 0) add("autoAdvanceCount=$autoAdvanceCount") + episodeSelectionHandoffNonce + ?.takeIf(::isValidTvEpisodeSelectionHandoffNonce) + ?.let { nonce -> add("$ARG_EPISODE_SELECTION_HANDOFF_NONCE=$nonce") } VideoPlayerRouteArgs.encodeResumePosition(resumePositionSeconds)?.let { value -> add("${VideoPlayerRouteArgs.RESUME_POSITION}=$value") } @@ -126,16 +151,22 @@ sealed class TvRoute(val route: String) { ) { companion object { const val ROUTE = "player/{contentId}?fileId={fileId}&quality={quality}&roomId={roomId}" + - "&audioTrackIndex={audioTrackIndex}&subtitleTrackIndex={subtitleTrackIndex}" + - "&autoAdvanceCount={autoAdvanceCount}&resumePosition={resumePosition}" + "&audioTrackIndex={audioTrackIndex}&audioPicked={audioPicked}" + + "&subtitleTrackIndex={subtitleTrackIndex}" + + "&subtitleAutoResolved={subtitleAutoResolved}" + + "&autoAdvanceCount={autoAdvanceCount}&resumePosition={resumePosition}" + + "&episodeSelectionHandoffNonce={episodeSelectionHandoffNonce}" const val ARG_CONTENT_ID = "contentId" const val ARG_FILE_ID = "fileId" const val ARG_QUALITY = "quality" const val ARG_ROOM_ID = "roomId" const val ARG_AUDIO_TRACK_INDEX = "audioTrackIndex" + const val ARG_AUDIO_PICKED = "audioPicked" const val ARG_SUBTITLE_TRACK_INDEX = "subtitleTrackIndex" + const val ARG_SUBTITLE_AUTO_RESOLVED = "subtitleAutoResolved" const val ARG_AUTO_ADVANCE_COUNT = "autoAdvanceCount" const val ARG_RESUME_POSITION = VideoPlayerRouteArgs.RESUME_POSITION + const val ARG_EPISODE_SELECTION_HANDOFF_NONCE = "episodeSelectionHandoffNonce" } } @@ -150,7 +181,7 @@ sealed class TvRoute(val route: String) { val startPositionSeconds: Double? = null, ) : TvRoute( buildString { - append("audiobook/$contentId") + append("audiobook/${contentId.routeEncode()}") val query = buildList { if (fileId != null) add("fileId=$fileId") VideoPlayerRouteArgs.encodeResumePosition(startPositionSeconds)?.let { value -> @@ -186,14 +217,19 @@ sealed class TvRoute(val route: String) { val libraryId: Int, val collectionId: String, val title: String, + /** Drives which sort keys and filter facets the page offers. */ + val libraryType: String = "", ) : TvRoute( - "library/$libraryId/collection/${collectionId.routeEncode()}?title=${title.routeEncode()}" + "library/$libraryId/collection/${collectionId.routeEncode()}" + + "?title=${title.routeEncode()}&libraryType=${libraryType.routeEncode()}" ) { companion object { - const val ROUTE = "library/{libraryId}/collection/{collectionId}?title={title}" + const val ROUTE = + "library/{libraryId}/collection/{collectionId}?title={title}&libraryType={libraryType}" const val ARG_LIBRARY_ID = "libraryId" const val ARG_COLLECTION_ID = "collectionId" const val ARG_TITLE = "title" + const val ARG_LIBRARY_TYPE = "libraryType" } } @@ -301,30 +337,6 @@ sealed class TvMainRoute(val route: String) { /** Global cross-library catalog browse — opened from Settings. */ data object Browse : TvMainRoute("main/browse") - /** Admin hub + sub-screens — opened from Settings when adminVisible. */ - data object AdminHub : TvMainRoute("main/admin") - data object AdminDashboard : TvMainRoute("main/admin/dashboard") - data object AdminUsers : TvMainRoute("main/admin/users") - data object AdminSessions : TvMainRoute("main/admin/sessions") - data object AdminScans : TvMainRoute("main/admin/scans") - data object AdminLogs : TvMainRoute("main/admin/logs") - - /** - * Admin user create/edit form. `userId` is omitted for create and carried - * as a query arg for edit (NavType can't express a nullable Int path arg). - */ - data class AdminUserEdit(val userId: Int? = null) : - TvMainRoute( - if (userId != null) "main/admin/users/edit?userId=$userId" else "main/admin/users/edit", - ) { - companion object { - const val ROUTE = "main/admin/users/edit?userId={userId}" - const val ARG_USER_ID = "userId" - } - } - - data object ManageSessions : TvMainRoute("main/settings/sessions") - /** Request detail for a discover/search result (tmdb id + media type). */ data class RequestDetail(val mediaType: String, val tmdbId: Int) : TvMainRoute("main/request/$mediaType/$tmdbId") { @@ -336,5 +348,35 @@ sealed class TvMainRoute(val route: String) { } } +/** + * Route strings this app used to register and no longer does. + * + * Navigation restores a saved back stack by destination id, and an id with no + * registered destination makes the restore throw — so a build that simply drops + * a route can crash on first launch after the update, before the replacement + * surface is ever reachable. The phone graph already keeps no-op aliases for its + * removed Video/Audio/Reading destinations for exactly this reason; these are + * the TV equivalents, and they redirect rather than render. + * + * Nothing here brings a withdrawn surface back: the admin and session-management + * screens stay deleted, and each alias lands in Settings. + */ +internal val TvRemovedRoutes: List = listOf( + // Diagnostics became a Settings category rather than a top-level screen. + "diagnostics", +) + +/** Nested [TvRoute.Main] equivalents of [TvRemovedRoutes]. */ +internal val TvRemovedMainRoutes: List = listOf( + "main/settings/sessions", + "main/admin", + "main/admin/dashboard", + "main/admin/users", + "main/admin/users/edit?userId={userId}", + "main/admin/sessions", + "main/admin/scans", + "main/admin/logs", +) + private fun String.routeEncode(): String = URLEncoder.encode(this, StandardCharsets.UTF_8.toString()).replace("+", "%20") diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvSubtitleLaunchSelection.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvSubtitleLaunchSelection.kt new file mode 100644 index 000000000..2741068f8 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvSubtitleLaunchSelection.kt @@ -0,0 +1,26 @@ +package org.prairieserver.prairie.tv.ui.navigation + +/** + * The subtitle decision a Play action carries from the detail screen into the + * player, in COMBINED selection space (-1 = Off). + * + * [autoResolved] is the whole reason this is a type rather than a bare Int. The + * detail row now hands over its Auto preview too — otherwise the player + * re-derives Auto over Media3's mounted tracks, where an external sidecar the + * initial plan never mounted cannot be a candidate, and playback starts on a + * different track than the row displayed. But an auto-resolved index is NOT a + * choice the viewer made: it must not be persisted as a durable per-item + * preference and must not be carried into the next episode as an explicit + * intent. + */ +data class TvSubtitleLaunchSelection( + val selectionIndex: Int, + val autoResolved: Boolean, +) { + /** The value the viewer explicitly picked, or null when Auto resolved it. */ + val explicitSelectionIndex: Int? get() = selectionIndex.takeIf { !autoResolved } +} + +/** A selection the viewer made themselves (null stays "no explicit pick"). */ +fun explicitTvSubtitleLaunchSelection(index: Int?): TvSubtitleLaunchSelection? = + index?.let { TvSubtitleLaunchSelection(it, autoResolved = false) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHeader.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHeader.kt deleted file mode 100644 index 834ec3212..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHeader.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow - -/** - * Shared Admin screen header in the tvOS Aurora/Skyline grammar — a mono-caps - * eyebrow above a `displaySmall` title, padded to the same safe-area / top-menu - * inset the Settings, Requests and Inbox surfaces use. An optional one-shot - * subtitle line surfaces transient admin messages. - * - * This replaces the older per-screen icon + title rows so every admin surface - * matches the rest of the 10-foot UI. - */ -@Composable -fun TvAdminScreenHeader( - eyebrow: String, - title: String, - subtitle: String? = null, -) { - Column( - modifier = Modifier.padding( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset, - bottom = Spacing.lg, - ), - verticalArrangement = Arrangement.spacedBy(Spacing.sm), - ) { - Text( - text = eyebrow, - style = sectionEyebrow, - color = PrairieBlue.copy(alpha = 0.92f), - ) - Text( - text = title, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHubScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHubScreen.kt deleted file mode 100644 index 41044a5e6..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminHubScreen.kt +++ /dev/null @@ -1,187 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.filled.Dashboard -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PlayCircle -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.theme.Spacing - -/** - * TV admin hub — the landing surface for admin management. Lists the admin - * sub-sections and routes into each. Mirrors the phone's - * `AdminHubScreen` (Dashboard / Users / Sessions / Logs / Scans) but adapts the - * UI to 10-foot/D-pad TV with focusable [Card] rows. - * - * Entry to admin is already gated by the Settings surface - * ([TvSettingsViewModel.UiState.adminVisible] = acting-admin + client policy), - * so this hub does not re-run the gate; it is only reachable when admin is - * visible. All sub-sections (Dashboard / Users / Sessions / Scans / Logs) route - * to their TV screens. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminHubScreen( - onOpenDashboard: () -> Unit, - onOpenUsers: () -> Unit, - onOpenSessions: () -> Unit, - onOpenScans: () -> Unit, - onOpenLogs: () -> Unit, - onBack: () -> Unit, -) { - BackHandler(enabled = true) { onBack() } - - val firstRowFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { firstRowFocus.requestFocus() } } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Admin") - - LazyColumn( - modifier = Modifier.fillMaxSize(), - // Top 8dp is the gap under the header; the bottom edge is real - // screen overscan and takes the safe-area token. - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 8.dp, - bottom = Spacing.safeAreaVertical, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - HubRow( - icon = Icons.Filled.Dashboard, - title = "Dashboard", - subtitle = "Server stats & activity", - onClick = onOpenDashboard, - focusRequester = firstRowFocus, - ) - } - item { - HubRow( - icon = Icons.Filled.People, - title = "Users", - subtitle = "Manage accounts & access", - onClick = onOpenUsers, - ) - } - item { - HubRow( - icon = Icons.Filled.PlayCircle, - title = "Sessions", - subtitle = "Now playing & controls", - onClick = onOpenSessions, - ) - } - item { - HubRow( - icon = Icons.Filled.Refresh, - title = "Scans", - subtitle = "Rescan libraries for new media", - onClick = onOpenScans, - ) - } - item { - HubRow( - icon = Icons.AutoMirrored.Filled.Article, - title = "Logs", - subtitle = "App & audit logs", - onClick = onOpenLogs, - ) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun HubRow( - icon: ImageVector, - title: String, - subtitle: String, - onClick: () -> Unit, - focusRequester: FocusRequester? = null, -) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .fillMaxWidth() - .widthIn(max = 960.dp) - .heightIn(min = 44.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 44.dp) - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsScreen.kt deleted file mode 100644 index a33e065f9..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsScreen.kt +++ /dev/null @@ -1,193 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.admin.AdminAuditEntry -import org.prairieserver.prairie.model.admin.AdminLogEntry -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvFilterChip -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel - -/** - * TV Admin "Logs" — App + Audit tabs over the shared AdminRepository log APIs, - * with a level filter (App) and cursor pagination (load-more near the end). - * Mirrors the phone AdminLogsScreen, adapted to D-pad: tab + level chip rails - * above a scrollable list of monospace log rows. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminLogsScreen( - onBack: () -> Unit, - viewModel: TvAdminLogsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val listState = rememberLazyListState() - - BackHandler(enabled = true) { onBack() } - - val tabCount = if (state.tab == TvLogTab.App) state.appEntries.size else state.auditEntries.size - val tabCursor = if (state.tab == TvLogTab.App) state.appCursor else state.auditCursor - val nearEnd by remember(state.tab, tabCount) { - derivedStateOf { - val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return@derivedStateOf false - tabCount > 0 && last >= tabCount - 4 - } - } - // Key on tab/count/cursor too: after a page lands, if the viewport is still - // near the end, nearEnd may stay true and a key on it alone would miss the - // next page. Re-evaluating when count/cursor change re-fires loadMore. - LaunchedEffect(nearEnd, state.tab, tabCount, tabCursor) { - if (nearEnd && tabCursor != null) viewModel.loadMore() - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Logs") - - // Tab rail - Row( - modifier = Modifier.padding(horizontal = Spacing.safeArea), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - TvLogTab.entries.forEach { tab -> - TvFilterChip(text = tab.label, selected = state.tab == tab, onClick = { viewModel.selectTab(tab) }) - } - } - - // Level filter (App tab only) - if (state.tab == TvLogTab.App) { - Row( - modifier = Modifier.padding(horizontal = Spacing.safeArea, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - LOG_LEVELS.forEach { (wire, label) -> - TvFilterChip(text = label, selected = state.level == wire, onClick = { viewModel.setLevel(wire) }) - } - } - } else { - Spacer(Modifier.height(12.dp)) - } - - when { - state.isLoading && tabCount == 0 -> TvLoadingScreen() - state.error != null && tabCount == 0 -> - TvErrorScreen(message = state.error!!, onRetry = viewModel::load) - else -> LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = Spacing.safeArea, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (state.tab == TvLogTab.App) { - items(state.appEntries, key = { it.id }) { AppLogRow(it) } - } else { - items(state.auditEntries, key = { it.id }) { AuditLogRow(it) } - } - if (state.isLoadingMore) { - item { Text("Loading…", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp)) } - } - item { Spacer(Modifier.height(24.dp)) } - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AppLogRow(entry: AdminLogEntry) { - Card( - onClick = {}, - shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), - modifier = Modifier.fillMaxWidth().widthIn(max = 1400.dp), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp)) { - Text( - text = "${entry.timestamp} ${entry.level.uppercase()} ${entry.component}", - style = MaterialTheme.typography.labelMedium, - color = levelColor(entry.level), - fontWeight = FontWeight.SemiBold, - fontFamily = FontFamily.Monospace, - ) - Text( - text = entry.message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AuditLogRow(entry: AdminAuditEntry) { - Card( - onClick = {}, - shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), - modifier = Modifier.fillMaxWidth().widthIn(max = 1400.dp), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp)) { - Text( - text = "${entry.timestamp} ${entry.method} ${entry.statusCode} ${entry.durationMs}ms", - style = MaterialTheme.typography.labelMedium, - color = if (entry.statusCode >= 400) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - fontFamily = FontFamily.Monospace, - ) - Text( - text = entry.path, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - fontFamily = FontFamily.Monospace, - ) - } - } -} - -@Composable -private fun levelColor(level: String) = when (level.lowercase()) { - "error", "fatal" -> MaterialTheme.colorScheme.error - "warn", "warning" -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.primary -} - -private val LOG_LEVELS = listOf( - null to "All", - "info" to "Info", - "warn" to "Warn", - "error" to "Error", -) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsViewModel.kt deleted file mode 100644 index b8b0dd4e0..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminLogsViewModel.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminAuditEntry -import org.prairieserver.prairie.model.admin.AdminLogEntry -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -enum class TvLogTab(val label: String) { App("App"), Audit("Audit") } - -data class TvAdminLogsUiState( - val tab: TvLogTab = TvLogTab.App, - /** App-log level filter; null = all levels. */ - val level: String? = null, - val appEntries: List = emptyList(), - val auditEntries: List = emptyList(), - val appCursor: String? = null, - val auditCursor: String? = null, - val isLoading: Boolean = true, - val isLoadingMore: Boolean = false, - val error: String? = null, -) - -/** - * TV Admin "Logs" — App + Audit log tabs with cursor pagination, mirroring the - * phone AdminLogsScreen against the shared [AdminRepository.getAppLogs] / - * [getAuditLogs]. The App tab supports a level filter (chip). A first page is a - * replace (cursor=null); near-end scroll appends the next page via the server - * cursor. Generation-gated so a tab/filter switch can't be clobbered by a - * slower in-flight page. - */ -class TvAdminLogsViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var generation = 0 - private val _uiState = MutableStateFlow(TvAdminLogsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun selectTab(tab: TvLogTab) { - if (tab == _uiState.value.tab) return - _uiState.update { it.copy(tab = tab) } - load() - } - - fun setLevel(level: String?) { - if (level == _uiState.value.level) return - _uiState.update { it.copy(level = level) } - if (_uiState.value.tab == TvLogTab.App) load() - } - - /** Replace the current tab's list (first page). */ - fun load() { - val gen = ++generation - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(gen, cursor = null, append = false) - } - - fun loadMore() { - val s = _uiState.value - if (s.isLoading || s.isLoadingMore) return - val cursor = if (s.tab == TvLogTab.App) s.appCursor else s.auditCursor - if (cursor == null) return - val gen = generation - _uiState.update { it.copy(isLoadingMore = true) } - fetch(gen, cursor = cursor, append = true) - } - - private fun fetch(gen: Int, cursor: String?, append: Boolean) { - viewModelScope.launch { - val tab = _uiState.value.tab - if (tab == TvLogTab.App) { - when (val result = repository.getAppLogs(level = _uiState.value.level, cursor = cursor)) { - is ApiResult.Success -> { - if (gen != generation) return@launch - _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - appEntries = if (append) it.appEntries + result.data.entries else result.data.entries, - appCursor = result.data.nextCursor, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> finishError(gen, result.errorMessage("Failed to load logs")) - } - } else { - when (val result = repository.getAuditLogs(cursor = cursor)) { - is ApiResult.Success -> { - if (gen != generation) return@launch - _uiState.update { - it.copy( - isLoading = false, - isLoadingMore = false, - auditEntries = if (append) it.auditEntries + result.data.entries else result.data.entries, - auditCursor = result.data.nextCursor, - error = null, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> finishError(gen, result.errorMessage("Failed to load logs")) - } - } - } - } - - private fun finishError(gen: Int, message: String) { - if (gen != generation) return - _uiState.update { it.copy(isLoading = false, isLoadingMore = false, error = message) } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansScreen.kt deleted file mode 100644 index 6160d235a..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansScreen.kt +++ /dev/null @@ -1,214 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.personal.UserLibrary -import org.prairieserver.prairie.tv.ui.components.TvDialogOption -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.TvOptionDialog -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.viewmodel.koinViewModel - -/** - * TV Admin "Scans" — mirrors the phone `AdminScansScreen`: a "Scan all - * libraries" action plus a per-library list where each row opens a dialog to - * scan or cancel that library. Logic lives in [TvAdminScansViewModel] - * (per-library scan/cancel + scan-all via the shared AdminRepository). - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminScansScreen( - onBack: () -> Unit, - viewModel: TvAdminScansViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var actionsTarget by remember { mutableStateOf(null) } - var lastMessage by remember { mutableStateOf(null) } - - BackHandler(enabled = true) { onBack() } - - LaunchedEffect(Unit) { - viewModel.toasts.collect { lastMessage = it } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Scans", subtitle = lastMessage) - - when { - state.isLoading && state.libraries.isEmpty() -> TvLoadingScreen() - - state.error != null && state.libraries.isEmpty() -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::load, - ) - - else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - // Top 8dp is the gap under the header; the bottom edge is real - // screen overscan and takes the safe-area token. - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 8.dp, - bottom = Spacing.safeAreaVertical, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - ActionCard( - title = if (state.scanningAll) "Scanning all libraries…" else "Scan all libraries", - subtitle = "Trigger a full rescan of every library", - enabled = !state.scanningAll, - onClick = { viewModel.scanAll() }, - ) - } - items(state.libraries, key = { it.id }) { library -> - LibraryRow( - library = library, - busy = library.id in state.busyLibraryIds, - onClick = { actionsTarget = library }, - ) - } - } - } - } - - actionsTarget?.let { library -> - TvOptionDialog( - title = library.name, - options = listOf( - TvDialogOption( - key = "scan", - title = "Scan now", - subtitle = "Rescan ${library.name} for new media", - onClick = { - actionsTarget = null - viewModel.scanLibrary(library.id) - }, - ), - TvDialogOption( - key = "cancel-scan", - title = "Cancel scan", - subtitle = "Stop an in-progress scan", - onClick = { - actionsTarget = null - viewModel.cancelLibrary(library.id) - }, - ), - TvDialogOption( - key = "dismiss", - title = "Cancel", - onClick = { actionsTarget = null }, - ), - ), - onDismiss = { actionsTarget = null }, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ActionCard(title: String, subtitle: String, enabled: Boolean, onClick: () -> Unit) { - Card( - onClick = { if (enabled) onClick() }, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 960.dp) - .heightIn(min = 44.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun LibraryRow(library: UserLibrary, busy: Boolean, onClick: () -> Unit) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 960.dp) - .heightIn(min = 44.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - text = library.name, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = library.type.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (busy) { - Text( - text = "Working…", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansViewModel.kt deleted file mode 100644 index 3f472af5e..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansViewModel.kt +++ /dev/null @@ -1,126 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.personal.UserLibrary -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import org.prairieserver.prairie.repository.PersonalDataRepository -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class TvAdminScansUiState( - val isLoading: Boolean = true, - val libraries: List = emptyList(), - /** Library IDs with an in-flight scan or cancel request. */ - val busyLibraryIds: Set = emptySet(), - val scanningAll: Boolean = false, - val error: String? = null, -) - -/** - * TV Admin "Scans" — mirrors the phone `AdminScansViewModel`. Lists the user's - * libraries and runs per-library scan/cancel + scan-all via [AdminRepository] - * (the scan endpoints live on the libraries handler server-side). Busy-set - * tracking disables row buttons while a request is in flight. - */ -class TvAdminScansViewModel( - private val adminRepository: AdminRepository, - private val personalDataRepository: PersonalDataRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(TvAdminScansUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private val _toasts = MutableSharedFlow(extraBufferCapacity = 4) - val toasts: SharedFlow = _toasts.asSharedFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation) - } - } - - fun scanLibrary(id: Int) { - if (id in _uiState.value.busyLibraryIds) return - viewModelScope.launch { - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds + id) } - when (val result = adminRepository.triggerScan(ScanRequest(libraryId = id))) { - is ApiResult.Success -> { - _toasts.emit("Scan started") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to start scan")) - } - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds - id) } - } - } - - fun cancelLibrary(id: Int) { - if (id in _uiState.value.busyLibraryIds) return - viewModelScope.launch { - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds + id) } - when (val result = adminRepository.cancelScan(ScanCancelRequest(libraryId = id))) { - is ApiResult.Success -> { - _toasts.emit("Scan cancelled") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to cancel scan")) - } - _uiState.update { it.copy(busyLibraryIds = it.busyLibraryIds - id) } - } - } - - fun scanAll() { - viewModelScope.launch { - _uiState.update { it.copy(scanningAll = true) } - when (val result = adminRepository.triggerScan(ScanRequest())) { - is ApiResult.Success -> { - _toasts.emit("Scanning…") - refresh() - } - is ApiResult.Error, is ApiResult.NetworkError -> - _toasts.emit(result.errorMessage("Failed to start scan")) - } - _uiState.update { it.copy(scanningAll = false) } - } - } - - private fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { fetch(generation) } - } - - private suspend fun fetch(generation: Int) { - val result = personalDataRepository.listUserLibraries() - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoading = false, - libraries = result.data.sortedBy { lib -> lib.sortOrder }, - error = null, - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load libraries")) - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScreen.kt deleted file mode 100644 index b039a2d21..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScreen.kt +++ /dev/null @@ -1,142 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.auroraGlass -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.viewmodel.AdminStatsViewModel -import org.koin.compose.viewmodel.koinViewModel - -/** - * TV admin stats dashboard — 2-column grid of live server stats. - * Admin user/library management is deferred to a follow-up phase. - * Reuses the shared [AdminStatsViewModel]; no TV-specific copy needed. - * - * Reachable from Settings when [TvSettingsViewModel.UiState.adminVisible] is true - * (acting-admin gate: admin role + primary profile). - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminScreen( - onBack: () -> Unit, - viewModel: AdminStatsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - - BackHandler(enabled = true) { onBack() } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Dashboard") - - when { - state.isLoading && state.stats == null -> TvLoadingScreen() - state.error != null && state.stats == null -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::load, - ) - state.stats != null -> AdminStatsGrid(stats = state.stats!!) - // No stats, not loading, no error (e.g. a refresh cleared the - // error but returned nothing) — offer a retry rather than a blank - // dashboard with only Back to escape. - else -> TvErrorScreen( - message = "No admin stats available.", - onRetry = viewModel::load, - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AdminStatsGrid(stats: AdminStats) { - val tiles = listOf( - StatTile("Total Items", stats.totalItems.toString()), - StatTile("Movies", "${stats.totalMovies} / ${stats.totalMovieFiles} files"), - StatTile("TV Shows", "${stats.totalShows} / ${stats.totalShowFiles} files"), - StatTile("Users", stats.totalUsers.toString()), - StatTile("Active Streams", stats.activeStreams.toString()), - StatTile("Storage", formatBytes(stats.totalStorageBytes)), - ) - - LazyVerticalGrid( - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(horizontal = Spacing.safeArea, vertical = 24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(tiles, key = { it.label }) { tile -> - AdminStatCard(tile) - } - } -} - -private data class StatTile(val label: String, val value: String) - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AdminStatCard(tile: StatTile) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(90.dp) - .auroraGlass(cornerRadius = 10.dp), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = tile.label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = tile.value, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - } - } -} - -private fun formatBytes(bytes: Long): String { - if (bytes < 1024) return "$bytes B" - val kb = bytes / 1024.0 - if (kb < 1024) return "%.1f KB".format(kb) - val mb = kb / 1024.0 - if (mb < 1024) return "%.1f MB".format(mb) - val gb = mb / 1024.0 - if (gb < 1024) return "%.1f GB".format(gb) - val tb = gb / 1024.0 - return "%.2f TB".format(tb) -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminSessionsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminSessionsScreen.kt deleted file mode 100644 index be97efe1e..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminSessionsScreen.kt +++ /dev/null @@ -1,466 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import org.prairieserver.prairie.tv.ui.components.TvDialogOption -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.TvOptionDialog -import org.prairieserver.prairie.tv.ui.components.TvTextInputDialog -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.theme.Spacing -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel - -/** How long a control-result message stays visible before auto-dismiss. */ -private const val ControlMessageVisibleMs = 4_000L - -// --------------------------------------------------------------------------- -// ViewModel (co-located, mirrors the phone's AdminSessionsViewModel) -// --------------------------------------------------------------------------- - -data class TvAdminSessionsUiState( - val isLoading: Boolean = true, - val sessions: List = emptyList(), - val error: String? = null, - /** One-shot user-facing message after a control action. */ - val message: String? = null, - /** - * Bumped on every [message] write so a repeated identical message (e.g. two - * "Session paused" in a row) still restarts the auto-dismiss timer. - */ - val messageNonce: Int = 0, -) - -/** - * Owns the live admin sessions list and per-session playback-control actions - * (pause/resume/stop/terminate). Mirrors the phone's co-located - * `AdminSessionsViewModel`: generation-gated fetches so a refresh that overlaps - * an in-flight load can't clobber newer data; control results surface via a - * one-shot [TvAdminSessionsUiState.message] then trigger a refresh. - * - * Reuses the same shared [AdminRepository] the phone uses. Registered in - * AndroidTvModule. - */ -class TvAdminSessionsViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(TvAdminSessionsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation) - } - } - - fun refresh() = load() - - fun control( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest = SessionControlRequest(), - ) { - viewModelScope.launch { - when (val result = repository.sessionControl(sessionId, action, request)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - message = controlSuccessMessage(action), - messageNonce = it.messageNonce + 1, - ) - } - load() - } - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { - it.copy( - message = result.errorMessage("Failed to ${action.wire} session"), - messageNonce = it.messageNonce + 1, - ) - } - // Refresh on failure too: a failed terminate/stop must not be - // silent — reload so the list reflects the session's real state. - load() - } - } - } - } - - fun consumeMessage() = _uiState.update { it.copy(message = null) } - - private suspend fun fetch(generation: Int) { - val result = repository.getSessions() - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoading = false, sessions = result.data, error = null) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load sessions")) - } - } - } - - private fun controlSuccessMessage(action: SessionControlAction): String = when (action) { - SessionControlAction.Pause -> "Session paused" - SessionControlAction.Resume -> "Session resumed" - SessionControlAction.Stop -> "Session stopped" - SessionControlAction.Terminate -> "Session terminated" - SessionControlAction.Message -> "Message sent" - } -} - -// --------------------------------------------------------------------------- -// Screen -// --------------------------------------------------------------------------- - -/** - * TV admin sessions management — live "now playing" list with per-session - * controls. Mirrors the phone's `AdminSessionsScreen` (pause/resume/stop/ - * terminate via a per-row menu); TV adapts the per-row menu to a focusable - * [TvOptionDialog]. The "Send message" action is touch-keyboard heavy on the - * phone and is deferred on TV. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminSessionsScreen( - onBack: () -> Unit, - viewModel: TvAdminSessionsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var actionsTarget by remember { mutableStateOf(null) } - var terminateTarget by remember { mutableStateOf(null) } - var messageTarget by remember { mutableStateOf(null) } - - BackHandler(enabled = true) { onBack() } - - // Surface control-result messages long enough to read (no snackbar on TV), - // then auto-dismiss. Consuming on the same composition made the message - // visible for <1 frame; hold it briefly so success/failure feedback lands. - // Keyed on the nonce (not the text) so an identical back-to-back message - // still restarts the timer instead of vanishing on the old deadline. - androidx.compose.runtime.LaunchedEffect(state.messageNonce) { - if (state.message != null) { - delay(ControlMessageVisibleMs) - viewModel.consumeMessage() - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Sessions", subtitle = state.message) - - when { - state.isLoading && state.sessions.isEmpty() -> TvLoadingScreen() - - state.error != null && state.sessions.isEmpty() -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::load, - ) - - state.sessions.isEmpty() -> TvErrorScreen( - message = "No active sessions.", - ) - - else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - // Top 8dp is the gap under the header; the bottom edge is real - // screen overscan and takes the safe-area token. - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 8.dp, - bottom = Spacing.safeAreaVertical, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.sessions, key = { it.sessionId }) { session -> - SessionRow( - session = session, - onClick = { - if (session.hasPlaybackControl) actionsTarget = session - }, - ) - } - } - } - } - - actionsTarget?.let { session -> - val options = buildList { - if (session.isPaused) { - add( - TvDialogOption( - key = "resume", - title = "Resume", - onClick = { - actionsTarget = null - viewModel.control(session.sessionId, SessionControlAction.Resume) - }, - ), - ) - } else { - add( - TvDialogOption( - key = "pause", - title = "Pause", - onClick = { - actionsTarget = null - viewModel.control(session.sessionId, SessionControlAction.Pause) - }, - ), - ) - } - add( - TvDialogOption( - key = "stop", - title = "Stop", - onClick = { - actionsTarget = null - viewModel.control(session.sessionId, SessionControlAction.Stop) - }, - ), - ) - add( - TvDialogOption( - key = "terminate", - title = "Terminate", - subtitle = "Forcibly end this stream", - onClick = { - actionsTarget = null - terminateTarget = session - }, - ), - ) - add( - TvDialogOption( - key = "message", - title = "Send message", - subtitle = "Show a message on this device", - onClick = { - actionsTarget = null - messageTarget = session - }, - ), - ) - add( - TvDialogOption( - key = "cancel", - title = "Cancel", - onClick = { actionsTarget = null }, - ), - ) - } - TvOptionDialog( - title = "${session.username} • ${session.mediaTitle}", - options = options, - onDismiss = { actionsTarget = null }, - ) - } - - terminateTarget?.let { session -> - TvOptionDialog( - title = "Terminate session?", - options = listOf( - TvDialogOption( - key = "confirm", - title = "Terminate", - subtitle = "Ends ${session.username}'s stream", - onClick = { - viewModel.control(session.sessionId, SessionControlAction.Terminate) - terminateTarget = null - }, - ), - TvDialogOption( - key = "cancel", - title = "Keep playing", - onClick = { terminateTarget = null }, - ), - ), - onDismiss = { terminateTarget = null }, - ) - } - - messageTarget?.let { session -> - TvTextInputDialog( - title = "Send message", - label = "Message to ${session.username}", - confirmLabel = "Send", - onConfirm = { text -> - viewModel.control( - session.sessionId, - SessionControlAction.Message, - SessionControlRequest(message = text), - ) - messageTarget = null - }, - onDismiss = { messageTarget = null }, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SessionRow(session: AdminSession, onClick: () -> Unit) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 1100.dp), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.Top, - ) { - ThumbhashImage( - url = session.posterUrl.ifBlank { null }, - thumbhash = null, - contentDescription = session.mediaTitle, - modifier = Modifier - .size(width = 66.dp, height = 98.dp) - .clip(RoundedCornerShape(8.dp)), - ) - Spacer(Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = session.mediaTitle, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - session.tvSeasonEpisode()?.let { se -> - val episodeSuffix = session.episodeName.takeIf { it.isNotBlank() } - Text( - text = if (episodeSuffix != null) "$se · $episodeSuffix" else se, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = session.username, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = listOfNotNull( - if (session.isPaused) "Paused" else "Playing", - session.tvProgressLabel(), - ).joinToString(" • "), - style = MaterialTheme.typography.labelMedium, - color = if (session.isPaused) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.primary - }, - ) - Text( - text = session.tvSummaryLine(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -// --------------------------------------------------------------------------- -// TV-local formatters — mirror the phone's AdminSessionFormatters (which live -// in the androidApp package and aren't reachable here). Kept intentionally -// minimal: the high-value labels for a 10-foot list. -// --------------------------------------------------------------------------- - -private fun AdminSession.tvSeasonEpisode(): String? = - if (seasonNumber != null && episodeNumber != null) "S${seasonNumber}E$episodeNumber" else null - -private fun playMethodLabel(playMethod: String): String = when (playMethod.lowercase().replace("_", "").replace(" ", "")) { - "directplay" -> "Direct Play" - "directstream" -> "Direct Stream" - "transcode" -> "Transcode" - else -> playMethod.ifBlank { "Playing" } -} - -private fun AdminSession.tvSummaryLine(): String { - val resolution = targetResolution.ifBlank { sourceVideoResolution } - val bitrateKbps = (targetBitrateKbps ?: streamBitrateKbps ?: sourceBitrateKbps)?.takeIf { it > 0 } - val bitrate = bitrateKbps?.let { - if (it >= 1000) "%.1f Mbps".format(it / 1000.0) else "$it Kbps" - } - return listOfNotNull( - playMethodLabel(playMethod), - bitrate, - resolution.takeIf { it.isNotBlank() }, - nodeDisplayName.takeIf { it.isNotBlank() }, - ).joinToString(" • ") -} - -private fun AdminSession.tvProgressLabel(): String { - val pos = formatClock(positionSeconds) - val dur = (fileDuration ?: 0).toDouble() - return if (dur <= 0.0) pos else "$pos / ${formatClock(dur)}" -} - -private fun formatClock(seconds: Double): String { - if (seconds.isNaN() || seconds < 0) return "0:00" - val total = seconds.toLong() - val h = total / 3600 - val m = (total % 3600) / 60 - val s = total % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUserEditScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUserEditScreen.kt deleted file mode 100644 index f1ee44663..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUserEditScreen.kt +++ /dev/null @@ -1,333 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.OutlinedTextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Button -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.ui.components.TvFilterChip -import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import org.prairieserver.prairie.viewmodel.ADMIN_USER_ROLES -import org.prairieserver.prairie.viewmodel.AdminUserEditViewModel -import org.prairieserver.prairie.viewmodel.roleDisplayName -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Save -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.foundation.layout.size -import androidx.tv.material3.Icon - -/** - * TV admin user create (userId == null) / edit form over the shared - * [AdminUserEditViewModel] — the same form the phone uses. On create, - * username/email/password are editable; on edit they're read-only except an - * optional password reset. Role, enabled, library access and playback quotas - * are editable in both modes. D-pad layout: a scrollable LazyColumn of focusable - * text fields, role chips, toggle cards, and a Save button. Pops back via - * [onSaved] when the save succeeds. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminUserEditScreen( - userId: Int?, - onBack: () -> Unit, - onSaved: () -> Unit, - viewModel: AdminUserEditViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - val firstFieldFocus = remember { FocusRequester() } - - BackHandler(enabled = true) { onBack() } - - // Drive the UI from the ROUTE's userId, not VM state: on an edit-route load - // failure the VM's userId stays null, and using state.isEditMode would - // silently fall back to create-mode (and submit() would CREATE instead of - // update). The route is the source of truth for which mode we're in. - val isEdit = userId != null - // In edit mode the user must finish loading before we let submit() run — - // otherwise the VM (state.userId still null) would route to create. - val editLoaded = state.userId != null - - LaunchedEffect(userId) { viewModel.load(userId) } - LaunchedEffect(state.saveSuccess) { if (state.saveSuccess) onSaved() } - // Focus the first EDITABLE control once content is ready: password in edit - // (username/email are read-only there), username in create. - LaunchedEffect(state.isLoading, isEdit) { - if (!state.isLoading) runCatching { firstFieldFocus.requestFocus() } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Text( - text = if (isEdit) "Edit user" else "Create user", - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 6.dp), - ) - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp, - ), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { - FormField( - label = "Username", - value = state.username, - onChange = viewModel::onUsernameChange, - enabled = !isEdit, - // Only the focus anchor when this field is editable (create). - focusRequester = firstFieldFocus.takeIf { !isEdit }, - ) - } - item { - FormField( - label = "Email", - value = state.email, - onChange = viewModel::onEmailChange, - enabled = !isEdit, - keyboardType = KeyboardType.Email, - ) - } - item { - FormField( - label = if (isEdit) "Reset password (optional)" else "Password", - value = state.password, - onChange = viewModel::onPasswordChange, - isPassword = true, - keyboardType = KeyboardType.Password, - // First editable control in edit mode. - focusRequester = firstFieldFocus.takeIf { isEdit }, - ) - } - - item { - Text( - text = "Role", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ADMIN_USER_ROLES.forEach { role -> - TvFilterChip( - text = roleDisplayName(role), - selected = state.role == role, - onClick = { viewModel.onRoleChange(role) }, - ) - } - } - } - - item { - ToggleCard( - label = "Enabled", - subtitle = "Allow this account to sign in", - checked = state.enabled, - onToggle = { viewModel.onEnabledChange(!state.enabled) }, - ) - } - - item { - FormField( - label = if (isEdit) { - "Library access ids (blank = unchanged)" - } else { - "Library access (comma-separated ids)" - }, - value = state.libraryIdsText, - onChange = viewModel::onLibraryIdsChange, - ) - } - - item { - FormField( - label = "Max streams (blank = unlimited)", - value = state.maxStreamsText, - onChange = viewModel::onMaxStreamsChange, - keyboardType = KeyboardType.Number, - ) - } - item { - FormField( - label = "Max transcodes (blank = unlimited)", - value = state.maxTranscodesText, - onChange = viewModel::onMaxTranscodesChange, - keyboardType = KeyboardType.Number, - ) - } - item { - FormField( - label = "Max profiles (blank = unlimited)", - value = state.maxProfilesText, - onChange = viewModel::onMaxProfilesChange, - keyboardType = KeyboardType.Number, - ) - } - - item { - ToggleCard( - label = "Downloads allowed", - subtitle = "Permit offline downloads", - checked = state.downloadAllowed, - onToggle = { viewModel.onDownloadAllowedChange(!state.downloadAllowed) }, - ) - } - item { - ToggleCard( - label = "Download transcode allowed", - subtitle = "Permit transcoded downloads", - checked = state.downloadTranscodeAllowed, - onToggle = { viewModel.onDownloadTranscodeAllowedChange(!state.downloadTranscodeAllowed) }, - ) - } - - state.error?.let { error -> - item { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.SemiBold, - ) - } - } - - item { - Button( - onClick = viewModel::submit, - // In edit mode, don't allow submit until the user has loaded - // — otherwise the VM (userId still null) would CREATE. - enabled = !state.isSaving && !state.isLoading && (!isEdit || editLoaded), - modifier = Modifier.widthIn(min = 240.dp), - ) { - Icon( - imageVector = if (isEdit) Icons.Default.Save else Icons.Default.PersonAdd, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = when { - state.isSaving -> "Saving…" - isEdit -> "Save changes" - else -> "Create user" - }, - ) - } - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun FormField( - label: String, - value: String, - onChange: (String) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - isPassword: Boolean = false, - keyboardType: KeyboardType = KeyboardType.Text, - focusRequester: FocusRequester? = null, -) { - OutlinedTextField( - value = value, - onValueChange = onChange, - label = { Text(label) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType, - imeAction = ImeAction.Done, - ), - modifier = modifier - .fillMaxWidth() - .widthIn(max = 960.dp) - .height(56.dp) - .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), - colors = tvOutlinedTextFieldColors(), - ) -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun ToggleCard( - label: String, - subtitle: String, - checked: Boolean, - onToggle: () -> Unit, -) { - Card( - onClick = onToggle, - shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 960.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 9.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - text = if (checked) "On" else "Off", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUsersScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUsersScreen.kt deleted file mode 100644 index 83d203c85..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminUsersScreen.kt +++ /dev/null @@ -1,316 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.tv.ui.components.TvDialogOption -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.TvOptionDialog -import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.viewmodel.AdminUsersViewModel -import org.prairieserver.prairie.viewmodel.roleDisplayName -import org.koin.compose.viewmodel.koinViewModel - -/** - * TV admin users management — list + per-user actions. Reuses the shared - * [AdminUsersViewModel] (UI-independent: generation-gated load/refresh, delete, - * one-shot messages) used by the phone's `AdminUsersScreen`, so the logic/flow - * stays in lockstep with the gold-standard phone app. - * - * TV adapts the UI to D-pad: each user is a focusable [Card]; clicking it opens - * a [TvOptionDialog] with the supported actions (currently Delete, mirroring the - * phone's destructive action). Create/edit use a full form on the phone and are - * deferred to a follow-up phase on TV. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvAdminUsersScreen( - onBack: () -> Unit, - onCreateUser: () -> Unit = {}, - onEditUser: (userId: Int) -> Unit = {}, - viewModel: AdminUsersViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var actionsTarget by remember { mutableStateOf(null) } - var pendingDelete by remember { mutableStateOf(null) } - - BackHandler(enabled = true) { onBack() } - - // Re-load when re-entering (parity with phone, which refreshes on resume). - LaunchedEffect(Unit) { viewModel.refresh() } - - // Surface one-shot mutation messages by clearing them after they land; TV - // has no snackbar, so we just consume so the flag doesn't stick. - LaunchedEffect(state.message) { - if (state.message != null) viewModel.consumeMessage() - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - TvAdminScreenHeader(eyebrow = "ADMIN", title = "Users", subtitle = state.message) - - when { - state.isLoading && state.users.isEmpty() -> TvLoadingScreen() - - state.error != null && state.users.isEmpty() -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::load, - ) - - else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - // Top 8dp is the gap under the header; the bottom edge is real - // screen overscan and takes the safe-area token. - contentPadding = PaddingValues( - start = Spacing.safeArea, - end = Spacing.safeArea, - top = 8.dp, - bottom = Spacing.safeAreaVertical, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { AddUserRow(onClick = onCreateUser) } - items(state.users, key = { it.id }) { user -> - UserRow(user = user, onClick = { actionsTarget = user }) - } - } - } - } - - actionsTarget?.let { user -> - TvOptionDialog( - title = user.username, - options = buildList { - add( - TvDialogOption( - key = "edit", - title = "Edit user", - subtitle = "Role, access, quotas & password", - onClick = { - val id = user.id - actionsTarget = null - onEditUser(id) - }, - ), - ) - if (user.role == "admin") { - add( - TvDialogOption( - key = "role-user", - title = "Make standard user", - subtitle = "Remove admin privileges", - onClick = { - actionsTarget = null - viewModel.setRole(user.id, "user") - }, - ), - ) - } else { - add( - TvDialogOption( - key = "role-admin", - title = "Make admin", - subtitle = "Grant admin privileges", - onClick = { - actionsTarget = null - viewModel.setRole(user.id, "admin") - }, - ), - ) - } - add( - TvDialogOption( - key = "enabled", - title = if (user.enabled) "Disable user" else "Enable user", - subtitle = if (user.enabled) "Block sign-in for this account" else "Allow sign-in again", - onClick = { - actionsTarget = null - viewModel.setEnabled(user.id, !user.enabled) - }, - ), - ) - add( - TvDialogOption( - key = "delete", - title = "Delete user", - subtitle = "Permanently remove this account", - onClick = { - actionsTarget = null - pendingDelete = user - }, - ), - ) - add( - TvDialogOption( - key = "cancel", - title = "Cancel", - onClick = { actionsTarget = null }, - ), - ) - }, - onDismiss = { actionsTarget = null }, - ) - } - - pendingDelete?.let { user -> - TvOptionDialog( - title = "Delete ${user.username}?", - options = listOf( - TvDialogOption( - key = "confirm", - title = "Delete", - subtitle = "This cannot be undone", - onClick = { - viewModel.deleteUser(user.id) - pendingDelete = null - }, - ), - TvDialogOption( - key = "cancel", - title = "Keep user", - onClick = { pendingDelete = null }, - ), - ), - onDismiss = { pendingDelete = null }, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun AddUserRow(onClick: () -> Unit) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 960.dp) - .height(36.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - imageVector = Icons.Filled.PersonAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(14.dp), - ) - Text( - text = "Add user", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun UserRow(user: AdminUser, onClick: () -> Unit) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 960.dp) - .heightIn(min = 48.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - text = user.username, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = user.email, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - user.lastActiveAt?.let { - Text( - text = "Last active $it", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Spacer(Modifier.width(16.dp)) - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = roleDisplayName(user.role), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = if (user.role == "admin") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - Text( - text = if (user.enabled) "Enabled" else "Disabled", - style = MaterialTheme.typography.labelMedium, - color = if (user.enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookBookmarksPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookBookmarksPanel.kt index ebfbed10d..94f6fd1cf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookBookmarksPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookBookmarksPanel.kt @@ -22,8 +22,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -35,7 +40,6 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.model.audiobook.AudiobookBookmark -import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus /** * TV audiobook Bookmarks overlay. Mirrors the phone's bookmarks sheet over the @@ -51,12 +55,15 @@ fun TvAudiobookBookmarksPanel( onJumpTo: (AudiobookBookmark) -> Unit, onDelete: (AudiobookBookmark) -> Unit, modifier: Modifier = Modifier, + onFocusAcquisitionFailed: () -> Unit = {}, ) { val addFocus = remember { FocusRequester() } TvAudiobookOverlayScaffold( title = "Bookmarks", - modifier = modifier.then(rememberTvDialogInitialFocus(addFocus)), + initialFocus = addFocus, + modifier = modifier, + onAcquisitionFailed = onFocusAcquisitionFailed, ) { BookmarkActionRow( label = "Bookmark here", @@ -89,8 +96,13 @@ fun TvAudiobookBookmarksPanel( onDelete = { onDelete(bookmark) // The deleted row (keyed by id) leaves composition, so - // move focus back to a stable anchor instead of losing it. - runCatching { addFocus.requestFocus() } + // move focus back to a stable anchor instead of losing + // it. A click handler has no suspend point, so this is + // single-shot and reported rather than swallowed. + addFocus.claimFocusOrReport( + target = "audiobook_bookmark_add", + action = "bookmark_deleted", + ) }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookChaptersPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookChaptersPanel.kt index 5c93e84a4..0c132f07b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookChaptersPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookChaptersPanel.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import org.prairieserver.prairie.audiobook.audiobookChapterLabel import org.prairieserver.prairie.model.catalog.VersionChapter -import kotlinx.coroutines.delay /** * Full-screen, focusable chapters overlay. Auto-scrolls to + highlights the @@ -25,21 +24,27 @@ fun TvAudiobookChaptersPanel( currentChapterIndex: Int, onSelectChapter: (Int) -> Unit, modifier: Modifier = Modifier, + onFocusAcquisitionFailed: () -> Unit = {}, ) { val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } val focusIndex = currentChapterIndex.coerceIn(0, (chapters.size - 1).coerceAtLeast(0)) + // Scroll only. Focus acquisition is the scaffold's, because the current + // chapter can sit hundreds of rows down: it is not laid out until the scroll + // lands, and a fixed sleep-then-request-once raced that every time. LaunchedEffect(Unit) { if (focusIndex in chapters.indices) { runCatching { listState.scrollToItem(focusIndex) } } - // Let the scrolled row compose/lay out before grabbing focus. - delay(100) - runCatching { focusRequester.requestFocus() } } - TvAudiobookOverlayScaffold(title = "Chapters", modifier = modifier) { + TvAudiobookOverlayScaffold( + title = "Chapters", + initialFocus = focusRequester, + modifier = modifier, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) { itemsIndexed(chapters) { index, chapter -> TvAudiobookOverlayRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookOverlay.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookOverlay.kt index 8bfd30b3c..f137a833d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookOverlay.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookOverlay.kt @@ -18,13 +18,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import kotlinx.coroutines.delay import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -35,19 +42,91 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.focus.TvFocusAcquisitionBudgetMillis +import org.prairieserver.prairie.tv.ui.focus.TvFocusRelocationBudgetMillis +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved + +private const val TvAudiobookPanelFocusRetryDelayMillis = 60L + +private val TvAudiobookPanelFocusMaxAttempts = + (TvFocusAcquisitionBudgetMillis / TvAudiobookPanelFocusRetryDelayMillis).toInt() + +/** + * Confirming that a traversal landed is a relocation, not an acquisition: focus + * has already moved and we are only waiting to see where. The short budget is + * the right one — a long wait here just delays the fail-open. + */ +private val TvAudiobookPanelFocusConfirmAttempts = + (TvFocusRelocationBudgetMillis / TvAudiobookPanelFocusRetryDelayMillis).toInt() /** * Right-aligned full-screen overlay panel over a dimming scrim, shared by the * chapters / speed / sleep audiobook panels (spec §4.9 — focusable overlays, * not phone bottom sheets). Back is handled by the host screen. + * + * Acquisition lives here rather than in each panel because every panel needs it + * and the ones that hand-rolled it got it wrong in the same way: a single + * unobserved `requestFocus()`, some of them fired before the target row had + * been laid out. A rejected request was indistinguishable from a successful + * one, so the panel opened with focus still on the transport pill behind the + * scrim — and the containment below cannot help with focus that never entered. + * + * [initialFocus] is required, not optional: a panel with nothing focusable in + * it cannot own focus, and while it is open the covered player is suppressed, + * so "no focus target" means a dead D-pad rather than a cosmetic problem. + * + * [onAcquisitionFailed] is the escape hatch for when that still does not work. + * Acquisition is retried until observed, but "retried until observed" is not + * the same as "guaranteed", and the shared dialog adapter's last resort — ask + * the focus system to enter the overlay by traversal — returns a Boolean that + * can be false, with nothing left to try. Suppressing the player behind an + * overlay that then fails to take focus is strictly worse than the escape bug + * this replaces, so the failure is reported instead of swallowed and the host + * hands the player back. */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @Composable internal fun TvAudiobookOverlayScaffold( title: String, + initialFocus: FocusRequester, modifier: Modifier = Modifier, + onAcquisitionFailed: () -> Unit = {}, content: @Composable ColumnScope.() -> Unit, ) { + var panelHasFocus by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + LaunchedEffect(initialFocus) { + val result = requestFocusUntilObserved( + maxAttempts = TvAudiobookPanelFocusMaxAttempts, + awaitAttempt = { delay(TvAudiobookPanelFocusRetryDelayMillis) }, + requestFocus = initialFocus::requestFocus, + isFocused = { panelHasFocus }, + ) + if (result == TvObservedFocusResult.Focused || panelHasFocus) return@LaunchedEffect + + // Traversal, in case the named row never became focusable but something + // else in the panel did. `exit` cancels searches *leaving* the group and + // does not block entering it. + // + // A true return is not success. `moveFocus` is a global directional + // move: it reports that focus moved, not that it moved into this panel. + // Observed panel focus is the criterion for the request above, and + // abandoning it here would be how a "focus went somewhere else entirely" + // outcome gets recorded as a win while the player stays suppressed. So + // the move is confirmed the same way, and an unconfirmed move is a + // failure like any other. + val entered = runCatching { focusManager.moveFocus(FocusDirection.Enter) }.getOrDefault(false) + if (entered) { + repeat(TvAudiobookPanelFocusConfirmAttempts) { + delay(TvAudiobookPanelFocusRetryDelayMillis) + if (panelHasFocus) return@LaunchedEffect + } + } + if (!panelHasFocus) onAcquisitionFailed() + } + Box( modifier = modifier .fillMaxSize() @@ -65,6 +144,7 @@ internal fun TvAudiobookOverlayScaffold( // stays open. Same recipe as the player HUD picker. .focusGroup() .focusProperties { exit = { FocusRequester.Cancel } } + .onFocusChanged { panelHasFocus = it.hasFocus } .padding(horizontal = 18.dp, vertical = 22.dp), ) { Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt index 69ee2b807..dd23e593a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookPlayerScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -77,6 +78,10 @@ import org.prairieserver.prairie.common.player.PrairiePlaybackService import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvPoster +import org.prairieserver.prairie.tv.ui.focus.TvFocusAcquisitionBudgetMillis +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvFocusSuppressed import com.google.common.util.concurrent.MoreExecutors import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel @@ -84,6 +89,11 @@ import kotlin.math.max private enum class AudiobookPanel { None, Chapters, Speed, Sleep, More, Skip, Bookmarks, About } +private const val TvAudiobookTransportFocusRetryDelayMillis = 60L + +private val TvAudiobookTransportFocusMaxAttempts = + (TvFocusAcquisitionBudgetMillis / TvAudiobookTransportFocusRetryDelayMillis).toInt() + /** * 10-foot, D-pad audiobook player for Android TV. A thin focus/layout view over * the SHARED [AudiobookPlayerViewModel] (android-shared) — no chapter / sleep / @@ -190,6 +200,10 @@ fun TvAudiobookPlayerScreen( override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { viewModel.onPauseStateChanged(!playWhenReady) } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + viewModel.onPlayerError(error) + } } c.addListener(listener) onDispose { runCatching { c.removeListener(listener) } } @@ -222,9 +236,59 @@ fun TvAudiobookPlayerScreen( } // Initial / restored focus lands on play/pause when no panel is open. - LaunchedEffect(state.isLoading, activePanel) { - if (!state.isLoading && activePanel == AudiobookPanel.None) { - runCatching { playPauseFocus.requestFocus() } + // + // Observed, because both moments this covers request focus at a target that + // may not be focusable yet: on first load the transport is composed in the + // same pass, and on panel close the panel is still being torn down. A + // one-shot request that lands early is silently dropped, and with the + // covered controls suppressed while a panel is open there is nothing else + // holding focus to fall back on — the screen would simply go dead. + var transportHasFocus by remember { mutableStateOf(false) } + // Set when an open panel reports that it could not take focus. Suppressing + // the player behind a panel that then holds no focus is worse than the + // escape this whole change is about, so that case hands the player back. + var panelFocusFailed by remember(activePanel) { mutableStateOf(false) } + LaunchedEffect(state.isLoading, state.error != null, activePanel, panelFocusFailed) { + // The error branch replaces the transport entirely, so there is nothing + // to acquire and retrying just burns the budget. Keyed, not just + // guarded, so clearing an error re-runs acquisition. + if (state.isLoading || state.error != null) return@LaunchedEffect + // A panel that could not take focus has to CLOSE, not merely hand the + // player back. Unsuppressing the transport while the panel and its + // scrim are still drawn puts focus on controls the viewer cannot see — + // D-pad and Select land behind the modal — and the escalation below + // never rescues that, because it only fires when the transport also + // fails to take focus. Closing first re-keys this effect against an + // unobstructed player, which then acquires normally. + if (activePanel != AudiobookPanel.None && panelFocusFailed) { + activePanel = AudiobookPanel.None + return@LaunchedEffect + } + if (activePanel != AudiobookPanel.None) return@LaunchedEffect + if (transportHasFocus) return@LaunchedEffect + val result = requestFocusUntilObserved( + maxAttempts = TvAudiobookTransportFocusMaxAttempts, + awaitAttempt = { delay(TvAudiobookTransportFocusRetryDelayMillis) }, + requestFocus = playPauseFocus::requestFocus, + isFocused = { transportHasFocus }, + ) + // Last rung of the ladder. Handing the player back is only useful if + // the player can actually take focus, and by here it has not: the panel + // reported failure and the transport did not answer either. (Strictly + // that says these two places lack focus, not that nothing anywhere has + // it — but a panel that never took focus with the player suppressed + // behind it leaves nothing else plausible.) Another request is not a + // new idea at that point. The one remaining move that changes the tree + // rather than re-asking the same question is dropping the overlay: it + // removes the panel's containment and the scrim, and re-keys this + // effect against an unobstructed player. That is also the terminating + // step — with no panel open the branch cannot fire again, so this + // escalates at most once. + if (result != TvObservedFocusResult.Focused && + !transportHasFocus && + activePanel != AudiobookPanel.None + ) { + activePanel = AudiobookPanel.None } } @@ -239,6 +303,23 @@ fun TvAudiobookPlayerScreen( } } + // A runtime failure can set `error` after playback is already up. That swaps + // the whole content branch for TvErrorScreen, which has no actions in it — + // so an open panel would be left floating over a screen with nothing to + // return focus to once it closes. Close the panel with the content it + // belonged to. + LaunchedEffect(state.error != null) { + if (state.error != null) activePanel = AudiobookPanel.None + } + + // The panels are in-window overlays, so the player stays composed and + // focusable behind them. Containment inside a panel cannot stop Select + // reaching a covered transport button when focus never entered the panel, + // and it cannot stop a panel row's Down walking into the pills below the + // scrim — the covered controls have to leave the focus graph outright. + val panelIsOpen = activePanel != AudiobookPanel.None + val playerFocusSuppressed = panelIsOpen && !panelFocusFailed + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val metrics = tvAudiobookPlayerMetrics(maxWidth.value.toInt(), maxHeight.value.toInt()) @@ -332,7 +413,10 @@ fun TvAudiobookPlayerScreen( ) Spacer(Modifier.height(metrics.transportTopGapDp.dp)) TvAudiobookTransportRow( - modifier = Modifier.focusProperties { down = speedChipFocus }, + modifier = Modifier + .focusProperties { down = speedChipFocus } + .onFocusChanged { transportHasFocus = it.hasFocus }, + focusSuppressed = playerFocusSuppressed, // Pause intent, not transient isPlaying, so the icon // stays stable through a seek's rebuffer. isPlaying = !state.isPaused, @@ -353,6 +437,7 @@ fun TvAudiobookPlayerScreen( Spacer(Modifier.height(metrics.utilityTopGapDp.dp)) TvAudiobookSecondaryControls( modifier = Modifier.focusProperties { up = playPauseFocus }, + focusSuppressed = playerFocusSuppressed, speedLabel = tvAudiobookSpeedLabel(state.playbackSpeed), sleepLabel = tvAudiobookSleepLabel( minutesLeft = state.sleepTimerMinutesLeft, @@ -379,6 +464,7 @@ fun TvAudiobookPlayerScreen( when (activePanel) { AudiobookPanel.Chapters -> TvAudiobookChaptersPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, chapters = state.chapters, currentChapterIndex = currentChapterIndex, onSelectChapter = { idx -> @@ -387,6 +473,7 @@ fun TvAudiobookPlayerScreen( }, ) AudiobookPanel.Speed -> TvAudiobookSpeedPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, currentSpeed = state.playbackSpeed, // Fine-adjust / presets apply live and stay open so the user // can keep tuning; "Set as default" persists and closes. @@ -394,16 +481,19 @@ fun TvAudiobookPlayerScreen( onSetDefault = { viewModel.setDefaultSpeed(it); activePanel = AudiobookPanel.None }, ) AudiobookPanel.Skip -> TvAudiobookSkipIntervalPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, skipBackSeconds = state.skipBackSeconds, skipForwardSeconds = state.skipForwardSeconds, onSelectSkipBack = { viewModel.setSkipBackSeconds(it) }, onSelectSkipForward = { viewModel.setSkipForwardSeconds(it) }, ) AudiobookPanel.Sleep -> TvAudiobookSleepPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, currentChoice = sleepChoice, onSelectSleep = { viewModel.applySleepTimer(it); activePanel = AudiobookPanel.None }, ) AudiobookPanel.More -> TvAudiobookMorePanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, skipLabel = tvAudiobookSkipLabel( skipBackSeconds = state.skipBackSeconds, skipForwardSeconds = state.skipForwardSeconds, @@ -416,6 +506,7 @@ fun TvAudiobookPlayerScreen( AudiobookPanel.Bookmarks -> { val bookmarks by viewModel.bookmarks.collectAsState() TvAudiobookBookmarksPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, bookmarks = bookmarks, onAddCurrent = { viewModel.addBookmark() }, onJumpTo = { bookmark -> @@ -425,18 +516,13 @@ fun TvAudiobookPlayerScreen( onDelete = { viewModel.removeBookmark(it.id) }, ) } - AudiobookPanel.About -> TvAudiobookOverlayScaffold(title = "About") { - Spacer(Modifier.height(12.dp)) - Text( - text = state.overview.orEmpty(), - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.82f), - // Generous cap: a description fits the full-height panel; TV - // can't D-pad-scroll an inner text box. - maxLines = 30, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - ) - } + AudiobookPanel.About -> TvAudiobookAboutPanel( + onFocusAcquisitionFailed = { panelFocusFailed = true }, + overview = state.overview.orEmpty(), + // Same destination as Back from any panel, so the two ways + // out of About do not disagree. + onClose = { activePanel = AudiobookPanel.None }, + ) AudiobookPanel.None -> Unit } } @@ -616,6 +702,7 @@ private fun TvAudiobookSecondaryControls( onStop: () -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, + focusSuppressed: Boolean = false, buttonHeight: Dp = 58.dp, buttonSpacing: Dp = 16.dp, ) { @@ -628,12 +715,14 @@ private fun TvAudiobookSecondaryControls( label = speedLabel, icon = Icons.Filled.Speed, focusRequester = focusRequester, + focusSuppressed = focusSuppressed, height = buttonHeight, onClick = onSpeed, ) TvAudiobookPillButton( label = sleepLabel, icon = Icons.Filled.Bedtime, + focusSuppressed = focusSuppressed, height = buttonHeight, onClick = onSleep, ) @@ -641,6 +730,7 @@ private fun TvAudiobookSecondaryControls( TvAudiobookPillButton( label = "Chapters", icon = Icons.AutoMirrored.Filled.FormatListBulleted, + focusSuppressed = focusSuppressed, height = buttonHeight, onClick = onChapters, ) @@ -648,18 +738,57 @@ private fun TvAudiobookSecondaryControls( TvAudiobookPillButton( label = "More", icon = Icons.Filled.MoreHoriz, + focusSuppressed = focusSuppressed, height = buttonHeight, onClick = onMore, ) TvAudiobookPillButton( label = "Stop", icon = Icons.Filled.Close, + focusSuppressed = focusSuppressed, height = buttonHeight, onClick = onStop, ) } } +/** + * The book description. Its only action is leaving, but it still needs one: + * with the covered player suppressed, a panel holding no focusable at all + * leaves the D-pad dead until Back, and nothing on screen says so. + */ +@Composable +private fun TvAudiobookAboutPanel( + overview: String, + onClose: () -> Unit, + onFocusAcquisitionFailed: () -> Unit, +) { + val closeFocus = remember { FocusRequester() } + TvAudiobookOverlayScaffold( + title = "About", + initialFocus = closeFocus, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { + Spacer(Modifier.height(12.dp)) + Text( + text = overview, + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.82f), + // Generous cap: a description fits the full-height panel; TV + // can't D-pad-scroll an inner text box. + maxLines = 30, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(16.dp)) + TvAudiobookOverlayRow( + label = "Close", + isCurrent = false, + onSelect = onClose, + focusRequester = closeFocus, + ) + } +} + @Composable private fun TvAudiobookMorePanel( skipLabel: String, @@ -667,14 +796,21 @@ private fun TvAudiobookMorePanel( onSkip: () -> Unit, onBookmarks: () -> Unit, onAbout: () -> Unit, + onFocusAcquisitionFailed: () -> Unit, ) { - TvAudiobookOverlayScaffold(title = "More") { + val firstRowFocus = remember { FocusRequester() } + TvAudiobookOverlayScaffold( + title = "More", + initialFocus = firstRowFocus, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { Spacer(Modifier.height(12.dp)) Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { TvAudiobookMoreRow( icon = Icons.Filled.Tune, title = "Skip interval", subtitle = skipLabel, + focusRequester = firstRowFocus, onClick = onSkip, ) TvAudiobookMoreRow( @@ -701,6 +837,7 @@ private fun TvAudiobookMoreRow( title: String, subtitle: String, onClick: () -> Unit, + focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -711,6 +848,7 @@ private fun TvAudiobookMoreRow( .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .background(bg) + .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { e -> if (e.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false @@ -741,6 +879,7 @@ private fun TvAudiobookPillButton( onClick: () -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, + focusSuppressed: Boolean = false, height: Dp = 58.dp, ) { val interactionSource = remember { MutableInteractionSource() } @@ -753,6 +892,8 @@ private fun TvAudiobookPillButton( .clip(RoundedCornerShape(height / 2)) .background(bg) .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } + // Ahead of the focusable, so it binds to this pill's own focus target. + .tvFocusSuppressed(focusSuppressed) .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { e -> if (e.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSkipIntervalPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSkipIntervalPanel.kt index 1961cdb5c..9ad883207 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSkipIntervalPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSkipIntervalPanel.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -26,12 +25,20 @@ fun TvAudiobookSkipIntervalPanel( onSelectSkipBack: (Int) -> Unit, onSelectSkipForward: (Int) -> Unit, modifier: Modifier = Modifier, + onFocusAcquisitionFailed: () -> Unit = {}, ) { val focusRequester = remember { FocusRequester() } - val focusValue = skipBackSeconds - LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + // A persisted interval outside the allowed set would attach the requester to + // no row at all, leaving the panel with nothing to acquire. + val focusValue = skipBackSeconds.takeIf { it in AudiobookSettingsStore.ALLOWED_SKIP } + ?: AudiobookSettingsStore.ALLOWED_SKIP.first() - TvAudiobookOverlayScaffold(title = "Skip interval", modifier = modifier) { + TvAudiobookOverlayScaffold( + title = "Skip interval", + initialFocus = focusRequester, + modifier = modifier, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { SectionLabel("Skip back") AudiobookSettingsStore.ALLOWED_SKIP.forEach { seconds -> TvAudiobookOverlayRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSleepPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSleepPanel.kt index 32ac42bbf..6886fc54d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSleepPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSleepPanel.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import org.prairieserver.prairie.common.player.SleepTimerChoice -import kotlinx.coroutines.delay private data class SleepOption(val label: String, val choice: SleepTimerChoice) @@ -31,18 +30,20 @@ fun TvAudiobookSleepPanel( currentChoice: SleepTimerChoice, onSelectSleep: (SleepTimerChoice) -> Unit, modifier: Modifier = Modifier, + onFocusAcquisitionFailed: () -> Unit = {}, ) { val listState = rememberLazyListState() val focusRequester = remember { FocusRequester() } val focusIndex = SLEEP_OPTIONS.indexOfFirst { it.choice == currentChoice }.coerceAtLeast(0) - LaunchedEffect(Unit) { - listState.scrollToItem(focusIndex) - // Let the target row compose/measure after the scroll before grabbing focus. - delay(100) - runCatching { focusRequester.requestFocus() } - } + // Scroll only; the scaffold retries until focus is observed on the row. + LaunchedEffect(Unit) { listState.scrollToItem(focusIndex) } - TvAudiobookOverlayScaffold(title = "Sleep timer", modifier = modifier) { + TvAudiobookOverlayScaffold( + title = "Sleep timer", + initialFocus = focusRequester, + modifier = modifier, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { LazyColumn( state = listState, modifier = Modifier diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSpeedPanel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSpeedPanel.kt index 7e2439937..86c793a25 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSpeedPanel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookSpeedPanel.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -46,11 +45,16 @@ fun TvAudiobookSpeedPanel( onSelectSpeed: (Float) -> Unit, onSetDefault: (Float) -> Unit, modifier: Modifier = Modifier, + onFocusAcquisitionFailed: () -> Unit = {}, ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } - TvAudiobookOverlayScaffold(title = "Speed", modifier = modifier) { + TvAudiobookOverlayScaffold( + title = "Speed", + initialFocus = focusRequester, + modifier = modifier, + onAcquisitionFailed = onFocusAcquisitionFailed, + ) { // Fine adjust grabs initial focus so ◀/▶ work immediately. SpeedFineAdjustRow( speed = currentSpeed, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookTransportRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookTransportRow.kt index baf8fbd07..398a56cdf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookTransportRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/audiobook/TvAudiobookTransportRow.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon +import org.prairieserver.prairie.tv.ui.focus.tvFocusSuppressed /** * Five-button audiobook transport, D-pad navigable. Mirrors the video player's @@ -68,6 +69,7 @@ fun TvAudiobookTransportRow( onNextChapter: () -> Unit, playPauseFocus: FocusRequester, modifier: Modifier = Modifier, + focusSuppressed: Boolean = false, buttonSize: Dp = 68.dp, primaryButtonWidth: Dp = 112.dp, primaryButtonHeight: Dp = 58.dp, @@ -82,12 +84,14 @@ fun TvAudiobookTransportRow( icon = Icons.Filled.SkipPrevious, description = "Previous chapter", enabled = chaptersEnabled, + focusSuppressed = focusSuppressed, buttonSize = buttonSize, onClick = onPrevChapter, ) TransportIconButton( icon = skipBackIcon(skipBackSeconds), description = "Skip back $skipBackSeconds seconds", + focusSuppressed = focusSuppressed, buttonSize = buttonSize, onClick = onSkipBack, ) @@ -96,6 +100,7 @@ fun TvAudiobookTransportRow( description = if (isPlaying) "Pause" else "Play", isPrimary = true, focusRequester = playPauseFocus, + focusSuppressed = focusSuppressed, buttonSize = buttonSize, primaryButtonWidth = primaryButtonWidth, primaryButtonHeight = primaryButtonHeight, @@ -104,6 +109,7 @@ fun TvAudiobookTransportRow( TransportIconButton( icon = skipForwardIcon(skipForwardSeconds), description = "Skip forward $skipForwardSeconds seconds", + focusSuppressed = focusSuppressed, buttonSize = buttonSize, onClick = onSkipForward, ) @@ -111,6 +117,7 @@ fun TvAudiobookTransportRow( icon = Icons.Filled.SkipNext, description = "Next chapter", enabled = chaptersEnabled, + focusSuppressed = focusSuppressed, buttonSize = buttonSize, onClick = onNextChapter, ) @@ -125,6 +132,7 @@ private fun TransportIconButton( enabled: Boolean = true, isPrimary: Boolean = false, focusRequester: FocusRequester? = null, + focusSuppressed: Boolean = false, buttonSize: Dp = 68.dp, primaryButtonWidth: Dp = 112.dp, primaryButtonHeight: Dp = 58.dp, @@ -160,6 +168,9 @@ private fun TransportIconButton( shape = buttonShape, ) .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } + // Ahead of the focusable, so it binds to this button's own focus + // target rather than being inherited from somewhere up the tree. + .tvFocusSuppressed(focusSuppressed) .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt index 54089ff77..539fe488f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt @@ -16,19 +16,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.focus.onFocusEvent -import kotlinx.coroutines.launch import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -41,13 +34,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -74,6 +74,10 @@ import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill import org.prairieserver.prairie.tv.ui.components.TvPillVariant +import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState +import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext +import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect +import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.theme.Spacing import org.koin.compose.viewmodel.koinViewModel @@ -99,12 +103,10 @@ fun TvLoginScreen( val passwordFocus = remember { FocusRequester() } val usePasswordFocus = remember { FocusRequester() } val signInFocus = remember { FocusRequester() } + val createAccountFocus = remember { FocusRequester() } val backToPhoneFocus = remember { FocusRequester() } val changeServerFocus = remember { FocusRequester() } - val usernameBringIntoView = remember { BringIntoViewRequester() } - val passwordBringIntoView = remember { BringIntoViewRequester() } - val signInBringIntoView = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() + val formScrollState = rememberTvImeAwareFormScrollState() // Phone-first IA (mirrors tvOS TVLoginView): the QR device-login leads, and // the username/password form is one focus-step away behind "Use a password @@ -120,17 +122,50 @@ fun TvLoginScreen( // Default focus follows the active surface: the password form focuses the // username field; the phone-first surface focuses the "Use a password // instead" affordance so the remote never lands on a non-actionable QR. - LaunchedEffect(showPasswordForm) { - if (showPasswordForm) { - runCatching { usernameFocus.requestFocus() } - } else { - runCatching { usePasswordFocus.requestFocus() } + var loginSurfaceHasFocus by remember { mutableStateOf(false) } + // Snapshot-backed: recomposes (and re-keys the claim below) when the viewer + // switches between pointer and key input. + val inputMode = LocalInputModeManager.current.inputMode + LaunchedEffect(showPasswordForm, inputMode) { + // Acquisition on both branches: the surface has just swapped, so + // nothing on it holds focus yet. A dropped claim on the phone-first + // branch strands the remote on a QR code that cannot be actioned. + // + // Touch/mouse exception: nothing is auto-focused for pointer users + // (product call 2026-08-14) — a programmatic claim on a text field in + // touch mode pops the IME despite showKeyboardOnFocus=false, and + // buttons refuse focus in touch mode anyway, so the claim would only + // burn its retry budget. Keying this effect on the input mode re-runs + // the claim the moment a key press flips the mode back, so the D-pad + // always has somewhere to land. + if (inputMode == InputMode.Touch) { + TvFocusLog.d { "login: claim skipped (touch mode, form=$showPasswordForm)" } + return@LaunchedEffect } + val target = if (showPasswordForm) usernameFocus else usePasswordFocus + TvFocusLog.d { + "login: claiming ${if (showPasswordForm) "username field" else "'use password' button"} (mode=$inputMode)" + } + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = target::requestFocus, + isFocused = { loginSurfaceHasFocus }, + ) + TvFocusLog.d { "login: claim result=$result" } + // The form arrives quiet — the viewer summons the keyboard with SELECT + // or a click. The legacy text field pops the IME on a focus arrival no + // matter what showKeyboardOnFocus says (unsupported on this overload, + // see tvShowImeOnSelect), so suppression lives in that modifier, which + // every field in this flow carries. No screen-level hide needed here. } Box( modifier = Modifier .fillMaxSize() + // Either branch's target lives under this root, so "focus is on the + // login surface" is the criterion both claims are protecting. + .onFocusChanged { loginSurfaceHasFocus = it.hasFocus } .imePadding(), ) { TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) @@ -139,10 +174,22 @@ fun TvLoginScreen( modifier = Modifier .align(Alignment.TopCenter) .fillMaxSize() - .verticalScroll(rememberScrollState()) + // The scroll is an IME/odd-surface safety valve only. At the + // reference TV surface (1920x1080 @ 320dpi = 960x540dp) BOTH + // branches must measure shorter than the viewport, because a + // scrolled column takes the brand mark and the SERVER/ACCOUNT/ + // PROFILE step chrome off the top with no D-pad way back — the + // header is not focusable, so nothing can scroll it into view. + // The credential branch is budgeted for this in + // [CredentialFormCard]; keep it that way. + .verticalScroll(formScrollState) + // Vertical padding sits at the overscan floor (Spacing + // .safeAreaVertical) on both branches — dipping under it to buy + // room for a too-tall form just trades a scroll for a bezel + // clip on real hardware. .padding( - top = if (showPasswordForm) 20.dp else 32.dp, - bottom = 32.dp, + top = Spacing.safeAreaVertical, + bottom = Spacing.safeAreaVertical, start = 54.dp, end = 54.dp, ), @@ -155,14 +202,14 @@ fun TvLoginScreen( BrandHeader() AuroraJourneyProgress( currentStep = 2, - modifier = Modifier.width(230.dp), + modifier = Modifier.width(215.dp), ) } - Spacer(modifier = Modifier.height(if (showPasswordForm) Spacing.sm else Spacing.lg)) + Spacer(modifier = Modifier.height(Spacing.sm)) AuroraEyebrow(text = "Account") - Spacer(modifier = Modifier.height(if (showPasswordForm) Spacing.md else Spacing.xl)) + Spacer(modifier = Modifier.height(Spacing.md)) if (showPasswordForm) { CredentialFormCard( @@ -170,11 +217,9 @@ fun TvLoginScreen( usernameFocus = usernameFocus, passwordFocus = passwordFocus, signInFocus = signInFocus, + createAccountFocus = createAccountFocus, backToPhoneFocus = backToPhoneFocus, changeServerFocus = changeServerFocus, - usernameBringIntoView = usernameBringIntoView, - passwordBringIntoView = passwordBringIntoView, - signInBringIntoView = signInBringIntoView, onUsernameChanged = viewModel::onUsernameChanged, onPasswordChanged = viewModel::onPasswordChanged, onLoginClick = viewModel::onLoginClick, @@ -182,8 +227,11 @@ fun TvLoginScreen( onCreateAccount = onCreateAccount, onBackToPhone = { showPasswordForm = false }, onChangeServer = onChangeServer, - scope = scope, - modifier = Modifier.width(400.dp), + // Wider than the old 400dp: the 960dp-wide surface has + // horizontal room to spare, and spending it lets the three + // secondary actions share one row instead of stacking + // three deep down the 540dp axis. + modifier = Modifier.width(520.dp), ) } else { Row( @@ -204,7 +252,7 @@ fun TvLoginScreen( onUsePassword = { showPasswordForm = true }, onChangeServer = onChangeServer, usePasswordFocus = usePasswordFocus, - modifier = Modifier.width(300.dp), + modifier = Modifier.width(320.dp), ) } } @@ -281,11 +329,9 @@ private fun CredentialFormCard( usernameFocus: FocusRequester, passwordFocus: FocusRequester, signInFocus: FocusRequester, + createAccountFocus: FocusRequester, backToPhoneFocus: FocusRequester, changeServerFocus: FocusRequester, - usernameBringIntoView: BringIntoViewRequester, - passwordBringIntoView: BringIntoViewRequester, - signInBringIntoView: BringIntoViewRequester, onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onLoginClick: () -> Unit, @@ -293,33 +339,37 @@ private fun CredentialFormCard( onCreateAccount: () -> Unit, onBackToPhone: () -> Unit, onChangeServer: () -> Unit, - scope: kotlinx.coroutines.CoroutineScope, modifier: Modifier = Modifier, ) { var passwordVisible by remember { mutableStateOf(false) } + // Height budget, not taste: this card plus the screen chrome above it has + // to measure under 540dp (the 1920x1080 @ 320dpi TV surface) with the 24dp + // overscan inset intact, or the root Column starts scrolling and the header + // chrome leaves the screen unreachably. Current budget with the safe area, + // brand row, eyebrow and this card is ~462dp. Before you add a row here or + // relax a gap, spend that ~78dp of headroom knowingly. Column( - verticalArrangement = Arrangement.spacedBy(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = modifier .auroraPanel(20.dp) - .padding(horizontal = 24.dp, vertical = 18.dp), + .padding(horizontal = 24.dp, vertical = 14.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { - Text( - text = "Sign in", - style = TvLoginTextStyles.Title, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = "Use the account from your Prairie server.", - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // Title only — the "ACCOUNT" eyebrow above the card already carries the + // step context, so the explanatory subtitle was a line of height the + // 540dp budget could not afford. + Text( + text = "Sign in", + style = TvLoginTextStyles.Title, + color = MaterialTheme.colorScheme.onBackground, + ) // Username — a mono uppercase caption labels each field, matching the // server-setup card; the Material floating label is dropped so nothing // floats oversized in the border notch. - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + modifier = Modifier.tvImeAwareFieldContext(), + ) { Text( text = "USERNAME", style = TvLoginTextStyles.InputLabel, @@ -332,22 +382,23 @@ private fun CredentialFormCard( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, imeAction = ImeAction.Next, + showKeyboardOnFocus = false, ), enabled = !state.isLoading, textStyle = TvLoginTextStyles.Field, modifier = Modifier .fillMaxWidth() - .height(52.dp) - .bringIntoViewRequester(usernameBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { usernameBringIntoView.bringIntoView() } - } + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect() .focusRequester(usernameFocus), colors = tvOutlinedTextFieldColors(), ) } - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), + modifier = Modifier.tvImeAwareFieldContext(), + ) { Text( text = "PASSWORD", style = TvLoginTextStyles.InputLabel, @@ -381,6 +432,7 @@ private fun CredentialFormCard( keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done, + showKeyboardOnFocus = false, ), keyboardActions = KeyboardActions( onDone = { @@ -393,11 +445,8 @@ private fun CredentialFormCard( textStyle = TvLoginTextStyles.Field, modifier = Modifier .weight(1f) - .height(52.dp) - .bringIntoViewRequester(passwordBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { passwordBringIntoView.bringIntoView() } - } + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect() .focusRequester(passwordFocus), colors = tvOutlinedTextFieldColors(), ) @@ -412,13 +461,7 @@ private fun CredentialFormCard( ) } - Box( - modifier = Modifier - .bringIntoViewRequester(signInBringIntoView) - .onFocusEvent { fs -> - if (fs.hasFocus) scope.launch { signInBringIntoView.bringIntoView() } - }, - ) { + Box { AuroraPrimaryButton( label = if (state.isLoading) "Signing in…" else "Sign In", icon = Icons.AutoMirrored.Filled.Login, @@ -430,71 +473,96 @@ private fun CredentialFormCard( enabled = !state.isLoading, modifier = Modifier .focusProperties { - down = backToPhoneFocus + // Explicit chain, so it must name every stop: skipping + // straight to "Back to phone sign-in" left Create + // Account unreachable by remote on signup-enabled + // servers (the intervening label Text is not focusable, + // so there is no default search to fall back on). + down = if (signupEnabled) createAccountFocus else backToPhoneFocus } .fillMaxWidth() - .height(64.dp), + .height(TvAuthFormDefaults.PrimaryButtonHeight), ) } - // Surfaced only when the server reports public signup is enabled. The - // ServerSetup probe forwards that flag through the Login route so this - // affordance never appears on signup-disabled servers. - if (signupEnabled) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - Text( - text = "Don't have an account yet?", - style = TvLoginTextStyles.Body, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TvHeroActionPill( + // Secondary actions on ONE row, not stacked. Three full-width ghost + // buttons cost ~120dp of the 540dp viewport; side by side they cost + // ~33dp, which is most of what buys this card its headroom. Create + // Account appears only when the server reports public signup is enabled + // (the ServerSetup probe forwards that flag through the Login route). + // + // Labels are sized to survive an equal-weight third of the 472dp card + // interior without wrapping — a wrapped label grows the row's height + // and puts the budget back over. "Phone sign-in" is the short form of + // "Back to phone sign-in" for that reason. + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier.fillMaxWidth(), + ) { + if (signupEnabled) { + AuroraGhostButton( label = "Create Account", - icon = Icons.Default.AccountCircle, - variant = TvPillVariant.Hollow, - heightOverride = 36.dp, - horizontalPaddingOverride = 18.dp, - labelStyle = TvLoginTextStyles.Button, onClick = onCreateAccount, + fontSize = TvLoginSecondaryActionFontSize, + horizontalPadding = TvLoginSecondaryActionPadding, + verticalPadding = 8.dp, + modifier = Modifier + .focusRequester(createAccountFocus) + .focusProperties { + // Explicit chain, matching the primary button's: + // the label Texts around these controls are not + // focusable, so there is no default search to fall + // back on if a link is left implicit. + up = signInFocus + right = backToPhoneFocus + } + .weight(1f), ) } - } - - // Return to the phone-first surface (the QR pairing remains live), or - // bail out to server setup to point this TV at a different server — - // both affordances mirror tvOS TVLoginView. Stacked full-width like the - // QR pane so the long "Back to phone sign-in" label never wraps. - Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + // Return to the phone-first surface (the QR pairing remains live). AuroraGhostButton( - label = "Back to phone sign-in", + label = "Phone sign-in", onClick = onBackToPhone, - fontSize = 18.sp, - horizontalPadding = 18.dp, + fontSize = TvLoginSecondaryActionFontSize, + horizontalPadding = TvLoginSecondaryActionPadding, verticalPadding = 8.dp, modifier = Modifier .focusRequester(backToPhoneFocus) .focusProperties { up = signInFocus - down = changeServerFocus + if (signupEnabled) left = createAccountFocus + right = changeServerFocus } - .fillMaxWidth(), + .weight(1f), ) + // Bail out to server setup to point this TV at a different server — + // mirrors tvOS TVLoginView. AuroraGhostButton( label = "Change server", onClick = onChangeServer, - fontSize = 18.sp, - horizontalPadding = 18.dp, + fontSize = TvLoginSecondaryActionFontSize, + horizontalPadding = TvLoginSecondaryActionPadding, verticalPadding = 8.dp, modifier = Modifier .focusRequester(changeServerFocus) .focusProperties { - up = backToPhoneFocus + up = signInFocus + left = backToPhoneFocus } - .fillMaxWidth(), + .weight(1f), ) } } } +/** + * Type and inset for the sign-in card's side-by-side secondary actions. Branch + * -local on purpose: [TvAuthFormDefaults] is shared with server setup, sign-up + * and first-run setup, and those screens have no reason to shrink. + */ +private val TvLoginSecondaryActionFontSize = 16.sp +private val TvLoginSecondaryActionPadding = 10.dp + private object TvLoginTextStyles { val Hero = TextStyle( fontWeight = FontWeight.Bold, @@ -573,7 +641,7 @@ private fun QrLoginCard( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = modifier .auroraGlass(15.dp) .padding(24.dp), @@ -634,23 +702,33 @@ private fun QrLoginCard( } } - Spacer(modifier = Modifier.height(Spacing.xs)) Box( modifier = Modifier .width(150.dp) .height(1.dp) .background(Color.White.copy(alpha = 0.10f)), ) - Spacer(modifier = Modifier.height(Spacing.xs)) + // Compact 18sp spec matching the password card's stacked buttons — + // at the default 22sp the longer label wraps and the card outgrows + // the 540dp viewport (see the screen-root padding note). AuroraGhostButton( label = "Sign in with a password", onClick = onUsePassword, - modifier = Modifier.focusRequester(usePasswordFocus), + fontSize = 18.sp, + horizontalPadding = 18.dp, + verticalPadding = 8.dp, + modifier = Modifier + .focusRequester(usePasswordFocus) + .fillMaxWidth(), ) AuroraGhostButton( label = "Use another server", onClick = onChangeServer, + fontSize = 18.sp, + horizontalPadding = 18.dp, + verticalPadding = 8.dp, + modifier = Modifier.fillMaxWidth(), ) } } @@ -664,6 +742,7 @@ private fun QrLoginCard( @Composable private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { if (code.isBlank()) return + val tileWidthDp = matchCodeTileWidthDp(code) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.xs), @@ -679,7 +758,7 @@ private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { ), color = Color.White.copy(alpha = 0.6f), ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { code.uppercase().forEach { ch -> val isSep = ch == '-' || ch == ' ' if (isSep) { @@ -687,12 +766,12 @@ private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { text = "–", style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold), color = Color.White.copy(alpha = 0.4f), - modifier = Modifier.width(10.dp), + modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), ) } else { Box( modifier = Modifier - .size(width = 24.dp, height = 30.dp) + .size(width = tileWidthDp.dp, height = 30.dp) .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(6.dp)) .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp)), contentAlignment = Alignment.Center, @@ -712,3 +791,31 @@ private fun MatchCodeTiles(code: String, modifier: Modifier = Modifier) { } } } + +internal const val MATCH_CODE_TILE_WIDTH_DP = 24 +internal const val MATCH_CODE_SEPARATOR_WIDTH_DP = 10 +internal const val MATCH_CODE_TILE_GAP_DP = 2 +internal const val MATCH_CODE_CONTENT_WIDTH_DP = 252 + +internal fun matchCodeTileWidthDp(code: String): Int { + val tileCount = code.count { ch -> ch != '-' && ch != ' ' } + if (tileCount == 0) return MATCH_CODE_TILE_WIDTH_DP + + val separatorCount = code.length - tileCount + val gapWidth = (code.length - 1).coerceAtLeast(0) * MATCH_CODE_TILE_GAP_DP + val availableTileWidth = ( + MATCH_CODE_CONTENT_WIDTH_DP - + separatorCount * MATCH_CODE_SEPARATOR_WIDTH_DP - + gapWidth + ).coerceAtLeast(tileCount) + return minOf(MATCH_CODE_TILE_WIDTH_DP, availableTileWidth / tileCount) +} + +internal fun matchCodeRowWidthDp(code: String): Int { + if (code.isEmpty()) return 0 + val tileWidth = matchCodeTileWidthDp(code) + val characterWidth = code.sumOf { ch -> + if (ch == '-' || ch == ' ') MATCH_CODE_SEPARATOR_WIDTH_DP else tileWidth + } + return characterWidth + (code.length - 1) * MATCH_CODE_TILE_GAP_DP +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt index 92b750031..d9e6fd492 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginViewModel.kt @@ -7,6 +7,7 @@ import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.DeviceLoginRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -90,11 +91,21 @@ class TvLoginViewModel( return@launch } deviceLoginJob?.cancel() - tokenManager.saveTokens( - accessToken = result.data.accessToken, - refreshToken = result.data.refreshToken, - expiresIn = result.data.expiresIn, - ) + try { + tokenManager.replaceAccountSession( + accessToken = result.data.accessToken, + refreshToken = result.data.refreshToken, + expiresIn = result.data.expiresIn, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + handleSessionPersistenceFailure( + accessToken = result.data.accessToken, + refreshToken = result.data.refreshToken, + ) + return@launch + } _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } is ApiResult.Error -> { @@ -134,7 +145,9 @@ class TvLoginViewModel( deviceLoginJob = viewModelScope.launch { deviceLogin.begin( deviceName = android.os.Build.MODEL, - devicePlatform = "androidtv", + // Same spelling as the X-Prairie-Device-Platform header this app + // sends, so one device reports one platform string everywhere. + devicePlatform = "android-tv", ) val terminal = deviceLogin.state.value if (terminal is DeviceLoginRepository.DeviceLoginState.Approved) { @@ -171,14 +184,43 @@ class TvLoginViewModel( return } credentialLoginJob?.cancel() - tokenManager.saveTokens( - accessToken = accessToken, - refreshToken = refreshToken, - expiresIn = response.expiresIn ?: 0L, - ) + try { + tokenManager.replaceAccountSession( + accessToken = accessToken, + refreshToken = refreshToken, + expiresIn = response.expiresIn ?: 0L, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + handleSessionPersistenceFailure(accessToken, refreshToken) + return + } _uiState.update { it.copy(isLoading = false, loginSuccess = true) } } + private suspend fun handleSessionPersistenceFailure( + accessToken: String, + refreshToken: String, + ) { + val committed = runCatching { + tokenManager.getAccessToken() == accessToken && + tokenManager.getRefreshToken() == refreshToken + }.getOrDefault(false) + authCompleted = committed + _uiState.update { + it.copy( + isLoading = false, + loginSuccess = committed, + error = if (committed) { + "Signed in, but local diagnostics cleanup did not finish." + } else { + "Unable to save this session. Try again." + }, + ) + } + } + private fun tryCompleteAuth(): Boolean { if (authCompleted) return false authCompleted = true diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt index b67b19afe..74fc4b139 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -39,12 +40,15 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics +import org.prairieserver.prairie.tv.ui.focus.tvPairDeviceFocusTarget +import org.prairieserver.prairie.tv.ui.focus.TvPairDeviceAction import org.prairieserver.prairie.tv.ui.components.TvTextInputDialog import org.prairieserver.prairie.viewmodel.DevicePairingViewModel import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -import androidx.compose.material.icons.automirrored.filled.Login -import androidx.compose.foundation.layout.size /** * TV Device Pairing — a signed-in TV user approves or denies another device's @@ -69,34 +73,73 @@ fun TvPairDeviceScreen( ) { val state by viewModel.uiState.collectAsState() var showCodeEntry by remember { mutableStateOf(false) } - val firstActionFocus = remember { FocusRequester() } + val enterCodeFocus = remember { FocusRequester() } + val checkFocus = remember { FocusRequester() } + val approveFocus = remember { FocusRequester() } + val doneFocus = remember { FocusRequester() } BackHandler(enabled = true) { onDone() } - LaunchedEffect(state.completedStatus) { - runCatching { firstActionFocus.requestFocus() } - } - // Manual code entry is only meaningful before a token-driven lookup and // before a decision lands; matches the phone's editable-field gating. val canEnterCode = state.token.isNullOrBlank() && state.completedStatus == null + // Approve and Deny exist to act on a specific request, and the request only + // exists once the lookup resolves — a deep link arrives with its token + // already set, so "there is an identifier" was never the right gate. + // + // The two halves are separated because they are different kinds of + // disabled. With no lookup the decision cannot apply at all, so the buttons + // leave the focus graph rather than sitting there as dead stops the D-pad + // walks onto (TV Material keeps disabled buttons focusable, so `enabled` + // alone would not do that — see tvControlSemantics). A decision already in + // flight is momentary, so those stay focusable and merely refuse to act, + // which is what keeps focus from being dropped mid-submit. + val decisionState = TvControlState( + focusable = state.lookup != null, + actionable = state.canDecide, + ) + // Check is gated transiently: it is the only control on a token route + // before the lookup resolves, and it is what the focus target falls back + // to, so it has to stay in the focus graph while its own lookup runs. + val checkState = TvControlState.transient(!state.isLoading && !state.isSubmitting) + + val focusTarget = tvPairDeviceFocusTarget( + hasCompleted = state.completedStatus != null, + hasResolvedLookup = state.lookup != null, + canEnterCode = canEnterCode, + ) + val actionFocus = rememberTvContentInitialFocus( + target = when (focusTarget) { + TvPairDeviceAction.EnterCode -> enterCodeFocus + TvPairDeviceAction.Check -> checkFocus + TvPairDeviceAction.Approve -> approveFocus + TvPairDeviceAction.Done -> doneFocus + }, + contentKey = focusTarget, + ) + Box( modifier = Modifier .fillMaxSize() + .then(actionFocus) .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center, ) { Column( modifier = Modifier - .widthIn(max = 360.dp) + // 480dp content width plus the 48dp horizontal padding on + // each side. On narrower displays fillMaxWidth keeps the + // panel responsive instead of clipping long match phrases. + .widthIn(max = 576.dp) + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(horizontal = 48.dp, vertical = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( - text = "Quick Connect", + text = "Pair Device", style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.SemiBold, @@ -135,15 +178,7 @@ fun TvPairDeviceScreen( ) if (error.startsWith("Sign in")) { Spacer(Modifier.height(12.dp)) - Button(onClick = onSignIn) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Login, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Sign In") - } + Button(onClick = onSignIn) { Text("Sign In") } } } @@ -164,7 +199,7 @@ fun TvPairDeviceScreen( if (canEnterCode) { Button( onClick = { showCodeEntry = true }, - modifier = Modifier.focusRequester(firstActionFocus), + modifier = Modifier.focusRequester(enterCodeFocus), ) { Icon(Icons.Default.Edit, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -172,9 +207,11 @@ fun TvPairDeviceScreen( } } Button( - onClick = { viewModel.lookup() }, - enabled = !state.isLoading && !state.isSubmitting, - modifier = if (canEnterCode) Modifier else Modifier.focusRequester(firstActionFocus), + onClick = { checkState.perform { viewModel.lookup() } }, + enabled = checkState.focusable, + modifier = Modifier + .focusRequester(checkFocus) + .tvControlSemantics(checkState), ) { Icon(Icons.Default.Refresh, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -185,12 +222,22 @@ fun TvPairDeviceScreen( Spacer(Modifier.height(16.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button(onClick = viewModel::deny, enabled = state.canSubmit) { + Button( + onClick = { decisionState.perform(viewModel::deny) }, + enabled = decisionState.focusable, + modifier = Modifier.tvControlSemantics(decisionState), + ) { Icon(Icons.Default.Close, contentDescription = null) Spacer(Modifier.width(8.dp)) Text("Deny") } - Button(onClick = viewModel::approve, enabled = state.canSubmit) { + Button( + onClick = { decisionState.perform(viewModel::approve) }, + enabled = decisionState.focusable, + modifier = Modifier + .focusRequester(approveFocus) + .tvControlSemantics(decisionState), + ) { Icon(Icons.Default.Check, contentDescription = null) Spacer(Modifier.width(8.dp)) Text(if (state.isSubmitting) "Approving…" else "Approve") @@ -199,14 +246,8 @@ fun TvPairDeviceScreen( } else { Button( onClick = onDone, - modifier = Modifier.focusRequester(firstActionFocus), + modifier = Modifier.focusRequester(doneFocus), ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Done") } } @@ -245,7 +286,7 @@ private fun DetailRow(label: String, value: String) { if (value.isBlank()) return Row( modifier = Modifier - .widthIn(max = 480.dp) + .fillMaxWidth() .padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -261,6 +302,7 @@ private fun DetailRow(label: String, value: String) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt index e480c1bbb..66862c235 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -1,20 +1,25 @@ package org.prairieserver.prairie.tv.ui.screens.auth +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusable -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.runtime.setValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding @@ -22,87 +27,80 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.StartOffset -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.focus.onFocusEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.input.InputMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.AlertDialog import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import org.prairieserver.prairie.tv.R +import kotlinx.coroutines.delay +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.common.pairing.PairingReceiver import org.prairieserver.prairie.common.pairing.PairingReceiverStatus import org.prairieserver.prairie.common.pairing.TvPairingAdvertiser +import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.tv.ui.components.AuroraAccent import org.prairieserver.prairie.tv.ui.components.AuroraEyebrow -import org.prairieserver.prairie.tv.ui.components.AuroraInk -import org.prairieserver.prairie.tv.ui.components.auroraGlass import org.prairieserver.prairie.tv.ui.components.AuroraGhostButton +import org.prairieserver.prairie.tv.ui.components.AuroraInk import org.prairieserver.prairie.tv.ui.components.AuroraJourneyProgress import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant +import org.prairieserver.prairie.tv.ui.components.TvHideStockImeOnDispose +import org.prairieserver.prairie.tv.ui.components.auroraGlass +import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState +import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext +import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect +import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel /** - * Server setup — connects the app to a Prairie server. + * Server setup — connects the app to a Silo server. * * While idle, this mirrors tvOS `TVServerSetupView`, scaled for the Shield's * ~960×540dp canvas (the iOS source is laid out in 1920×1080 points): the @@ -128,27 +126,43 @@ fun TvServerSetupScreen( val pairingStatus by pairingReceiver.status.collectAsState() val focusRequester = remember { FocusRequester() } val phoneSetupFocus = remember { FocusRequester() } - val urlBringIntoView = remember { BringIntoViewRequester() } - val connectBringIntoView = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() + var phoneCardHasFocus by remember { mutableStateOf(false) } + val formScrollState = rememberTvImeAwareFormScrollState() val isActivePairing = pairingStatus.isActivePairing - // Companion LAN pairing: advertise `_prairiepair._tcp` while this screen is on - // so a phone running Prairie can push the server URL + drive device-login, + // Companion LAN pairing: advertise `_silopair._tcp` while this screen is on + // so a phone running Silo can push the server URL + drive device-login, // sparing the viewer from typing a URL on the remote. Advertising stops // when the screen leaves the composition. DisposableEffect(Unit) { pairingAdvertiser.start() onDispose { pairingAdvertiser.stop() } } - LaunchedEffect(isActivePairing) { - if (!isActivePairing) { - // Always land on the server-address field, matching tvOS - // (TVServerSetupView `.defaultFocus(.host)`). The user chooses "Set - // up with phone" by navigating to it — we don't pre-select it for - // them (Jim TV QA 2026-07-10). Returning users keep the pre-filled - // field focused too. - runCatching { focusRequester.requestFocus() } + // Snapshot-backed: re-keys the claim when the viewer switches between + // pointer and key input. + val inputMode = LocalInputModeManager.current.inputMode + LaunchedEffect(isActivePairing, inputMode) { + // Pointer users click what they want — and in touch mode the claim + // could not land anyway. Re-run on mode flip so the D-pad always has + // somewhere to start. + if (!isActivePairing && inputMode != InputMode.Touch) { + // Land on the phone-pairing card: companion setup is the + // recommended path, so it gets first focus (product call + // 2026-08-14, reversing the 2026-07-10 field-first default). + // Landing on the URL field also popped the IME over the form, + // and the IME resize scrolled the header chrome off-screen. + TvFocusLog.d { "serverSetup: claiming phone card (mode=$inputMode)" } + val result = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = phoneSetupFocus::requestFocus, + isFocused = { phoneCardHasFocus }, + ) + TvFocusLog.d { "serverSetup: claim result=$result" } + } else { + TvFocusLog.d { + "serverSetup: claim skipped (pairing=$isActivePairing, mode=$inputMode)" + } } } LaunchedEffect(pairingStatus) { @@ -237,7 +251,7 @@ fun TvServerSetupScreen( modifier = Modifier .align(Alignment.TopCenter) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(formScrollState) .padding(horizontal = 48.dp, vertical = 32.dp), ) { Row( @@ -248,7 +262,7 @@ fun TvServerSetupScreen( BrandHeader() AuroraJourneyProgress( currentStep = 1, - modifier = Modifier.width(230.dp), + modifier = Modifier.width(215.dp), ) } @@ -289,11 +303,28 @@ fun TvServerSetupScreen( modifier = Modifier .widthIn(max = 642.dp) .fillMaxWidth() - .height(SERVER_SETUP_CHOOSER_HEIGHT), + // Intrinsic-min, floored — not a bare heightIn and + // not an exact height. Both of those fail, in + // opposite directions: + // - a loose max makes the cards' fillMaxHeight a + // no-op, so the phone card collapses to its pill + // and the weight(1f) beacon box measures zero; + // - an exact height clips the taller card, which + // at 300dp squeezed "Connect to server" down to + // a blank pill (label measured 6px in a 96px + // button). + // Resolving the intrinsic first hands the Row a + // tight height, so fillMaxHeight still resolves, + // while the floor keeps the chooser at a real card + // height when content is short. tvOS pins 580pt; + // here the content decides above that floor. + .height(IntrinsicSize.Min) + .heightIn(min = SERVER_SETUP_CHOOSER_MIN_HEIGHT), ) { PhoneSetupCard( focusRequester = phoneSetupFocus, modifier = Modifier + .onFocusChanged { phoneCardHasFocus = it.hasFocus } .weight(1f) .fillMaxHeight(), ) @@ -307,9 +338,6 @@ fun TvServerSetupScreen( onServerUrlChanged = viewModel::onServerUrlChanged, onConnectClick = viewModel::onConnectClick, focusRequester = focusRequester, - urlBringIntoView = urlBringIntoView, - connectBringIntoView = connectBringIntoView, - scope = scope, modifier = Modifier .weight(1f) .fillMaxHeight(), @@ -345,12 +373,12 @@ private fun PhoneSetupCard( ) .padding(24.dp), ) { + // Top-leading pill, matching tvOS TVServerSetupView.phoneCard. Text( text = "RECOMMENDED · USE PHONE", style = TvServerSetupTextStyles.Pill, color = Color.White.copy(alpha = 0.70f), modifier = Modifier - .align(Alignment.CenterHorizontally) .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(50)) .border(1.dp, Color.White.copy(alpha = 0.14f), RoundedCornerShape(50)) .padding(horizontal = 14.dp, vertical = 7.dp), @@ -369,36 +397,43 @@ private fun PhoneSetupCard( @Composable private fun PhoneSetupBody(modifier: Modifier = Modifier) { + // Beacon centered, copy left-aligned beneath it — mirrors tvOS + // TVServerSetupView.phoneCard (iPhone → phone). Column( - horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp), modifier = modifier.fillMaxWidth(), ) { SearchingBeacon( - modifier = Modifier.size(PHONE_SETUP_BEACON_SIZE), + modifier = Modifier + .size(PHONE_SETUP_BEACON_SIZE) + .align(Alignment.CenterHorizontally), ) Text( - text = "Looking for your phone…", + text = "Looking for a phone…", style = TvServerSetupTextStyles.Headline, color = Color.White, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), ) Text( - text = "Open Prairie on your phone on this Wi-Fi to set up this TV without typing.", + text = "Open Silo on a phone connected to the same Wi-Fi. Accept the " + + "setup card and Silo will securely bring over the server and account.", style = TvServerSetupTextStyles.PairingDetail, color = Color.White.copy(alpha = 0.72f), - maxLines = 2, + maxLines = 4, overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } } private val PHONE_SETUP_BEACON_SIZE = 96.dp -private val SERVER_SETUP_CHOOSER_HEIGHT = 300.dp + +/** + * Floor for the phone/manual chooser. The manual card's intrinsic height + * normally exceeds this; the floor only matters when it doesn't, keeping the + * phone card from collapsing to its pill. + */ +private val SERVER_SETUP_CHOOSER_MIN_HEIGHT = 300.dp @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -407,75 +442,65 @@ private fun ManualEntryCard( onServerUrlChanged: (String) -> Unit, onConnectClick: () -> Unit, focusRequester: FocusRequester, - urlBringIntoView: BringIntoViewRequester, - connectBringIntoView: BringIntoViewRequester, - scope: CoroutineScope, modifier: Modifier = Modifier, ) { - val keyboardController = LocalSoftwareKeyboardController.current + TvHideStockImeOnDispose() Column( verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = modifier .auroraGlass(16.dp, emphasized = true) - .verticalScroll(rememberScrollState()) .padding(24.dp), ) { - Text( - text = "Enter it here", - style = TvServerSetupTextStyles.Headline, - color = Color.White, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .tvImeAwareFieldContext(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text( + text = "Enter the server address", + style = TvServerSetupTextStyles.Headline, + color = Color.White, + ) - Text( - text = "SERVER ADDRESS", - style = TvServerSetupTextStyles.InputLabel, - color = Color.White.copy(alpha = 0.52f), - ) + Text( + text = "SERVER ADDRESS", + style = TvServerSetupTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) - OutlinedTextField( - value = state.serverUrl, - onValueChange = onServerUrlChanged, - placeholder = { - Text( - text = "media.example.com", - style = TvServerSetupTextStyles.FieldText, - ) - }, - singleLine = true, - textStyle = TvServerSetupTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - showKeyboardOnFocus = false, - ), - keyboardActions = KeyboardActions( - onGo = { - if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { - onConnectClick() - } + OutlinedTextField( + value = state.serverUrl, + onValueChange = onServerUrlChanged, + placeholder = { + Text( + text = "silo.example.com", + style = TvServerSetupTextStyles.FieldText, + ) }, - ), - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .bringIntoViewRequester(urlBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { urlBringIntoView.bringIntoView() } - } - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyUp && - (event.key == Key.DirectionCenter || event.key == Key.Enter || event.key == Key.NumPadEnter) - ) { - keyboardController?.show() - true - } else { - false - } - } - .focusRequester(focusRequester), - colors = tvOutlinedTextFieldColors(), - ) + singleLine = true, + textStyle = TvServerSetupTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions( + onGo = { + if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { + onConnectClick() + } + }, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect() + .focusRequester(focusRequester), + colors = tvOutlinedTextFieldColors(), + ) + } UrlShortcutRow( enabled = !state.isLoading, @@ -497,18 +522,32 @@ private fun ManualEntryCard( style = TvServerSetupTextStyles.Error, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else { + // Mirrors tvOS's lock.shield reassurance line. Truthful here too: + // bare hosts probe https:// first and fall to http:// only when + // the viewer typed it (probeTvServerSetupCandidates). + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = Color.White.copy(alpha = 0.72f), + modifier = Modifier.size(14.dp), + ) + Text( + text = "Secure HTTPS is tried automatically.", + style = TvServerSetupTextStyles.PairingDetail, + color = Color.White.copy(alpha = 0.72f), + ) + } } - Box( - modifier = Modifier - .bringIntoViewRequester(connectBringIntoView) - .onFocusEvent { fs -> - if (fs.hasFocus) scope.launch { connectBringIntoView.bringIntoView() } - }, - ) { + Box { AuroraPrimaryButton( - label = if (state.isLoading) "Connecting…" else "Connect", - icon = Icons.Default.Link, + label = if (state.isLoading) "Connecting…" else "Connect to server", + icon = null, enabled = canSubmitTvServerUrl(state.serverUrl, state.isLoading), onClick = { if (canSubmitTvServerUrl(state.serverUrl, state.isLoading)) { @@ -517,7 +556,7 @@ private fun ManualEntryCard( }, modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(TvAuthFormDefaults.PrimaryButtonHeight), ) } } @@ -562,11 +601,16 @@ private fun OrDivider(modifier: Modifier = Modifier) { verticalArrangement = Arrangement.Center, modifier = modifier, ) { + // Hairlines fade toward the screen edges, matching tvOS orDivider. Box( modifier = Modifier .width(1.dp) .weight(1f) - .background(Color.White.copy(alpha = 0.16f)), + .background( + Brush.verticalGradient( + listOf(Color.Transparent, Color.White.copy(alpha = 0.16f)), + ), + ), ) Text( text = "OR", @@ -578,7 +622,11 @@ private fun OrDivider(modifier: Modifier = Modifier) { modifier = Modifier .width(1.dp) .weight(1f) - .background(Color.White.copy(alpha = 0.16f)), + .background( + Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.16f), Color.Transparent), + ), + ), ) } } @@ -648,15 +696,24 @@ private fun ActivePairingPanel( modifier: Modifier = Modifier, ) { val allowFocusRequester = remember { FocusRequester() } + var consentHasFocus by remember { mutableStateOf(false) } LaunchedEffect(status) { if (status is PairingReceiverStatus.ConsentRequested) { - runCatching { allowFocusRequester.requestFocus() } + // A consent prompt whose Allow button never takes focus cannot be + // answered from a remote at all. + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = allowFocusRequester::requestFocus, + isFocused = { consentHasFocus }, + ) } } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.md), modifier = modifier + .onFocusChanged { consentHasFocus = it.hasFocus } .fillMaxWidth(), ) { when (status) { @@ -694,7 +751,6 @@ private fun ActivePairingPanel( ) { AuroraPrimaryButton( label = "Allow", - icon = Icons.Default.Check, onClick = onAllow, focusRequester = allowFocusRequester, modifier = Modifier @@ -792,7 +848,6 @@ private fun ActivePairingPanel( ) AuroraPrimaryButton( label = "Try again", - icon = Icons.Default.Refresh, onClick = onCancel, modifier = Modifier.width(320.dp), ) @@ -866,6 +921,7 @@ private fun completedSummary(names: List): String = @Composable private fun MatchCodeCard(code: String) { if (code.isBlank()) return + val tileWidthDp = matchCodeTileWidthDp(code) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm), @@ -879,19 +935,19 @@ private fun MatchCodeCard(code: String) { style = TvServerSetupTextStyles.CodeLabel, color = Color.White.copy(alpha = 0.62f), ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(MATCH_CODE_TILE_GAP_DP.dp)) { code.uppercase().forEach { ch -> if (ch == '-' || ch == ' ') { Text( text = "–", style = TvServerSetupTextStyles.CodeSeparator, color = Color.White.copy(alpha = 0.42f), - modifier = Modifier.width(12.dp), + modifier = Modifier.width(MATCH_CODE_SEPARATOR_WIDTH_DP.dp), ) } else { Box( modifier = Modifier - .size(width = 34.dp, height = 42.dp) + .size(width = tileWidthDp.dp, height = 42.dp) .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(7.dp)) .border(1.dp, Color.White.copy(alpha = 0.20f), RoundedCornerShape(7.dp)), contentAlignment = Alignment.Center, @@ -924,10 +980,11 @@ private object TvServerSetupTextStyles { color = Color.White, ) + /** tvOS continuumHeadline (36pt → 18dp at the 0.5x map, +2 readability). */ val Headline = TextStyle( fontWeight = FontWeight.SemiBold, - fontSize = 22.sp, - lineHeight = 28.sp, + fontSize = 20.sp, + lineHeight = 26.sp, letterSpacing = 0.sp, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt index 2bdb5eba2..ab998fab7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt @@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -26,16 +23,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -48,16 +47,18 @@ import androidx.tv.material3.Text import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant -import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill -import org.prairieserver.prairie.tv.ui.components.TvPillVariant +import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton +import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState +import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext +import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect +import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.theme.Spacing -import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel /** * First-time server setup — creates the initial admin account on a freshly - * installed Prairie server. Mirrors the phone's `SetupScreen` logic via + * installed Silo server. Mirrors the phone's `SetupScreen` logic via * [TvSetupViewModel]; on success the user is already signed in (tokens * persisted by [org.prairieserver.prairie.repository.AuthRepository.setup]) so the * flow advances to profile selection. @@ -73,11 +74,7 @@ fun TvSetupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val usernameBringIntoView = remember { BringIntoViewRequester() } - val emailBringIntoView = remember { BringIntoViewRequester() } - val passwordBringIntoView = remember { BringIntoViewRequester() } - val submitBringIntoView = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() + val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.setupSuccess) { if (state.setupSuccess) { @@ -85,11 +82,23 @@ fun TvSetupScreen( onSetupComplete() } } - LaunchedEffect(Unit) { runCatching { usernameFocus.requestFocus() } } + // A text field on a first-run screen: if this claim is dropped the + // remote has nothing to act on and no touch fallback exists. + // Snapshot-backed input mode drives the claim: null contentKey while the + // viewer is in touch mode (a programmatic claim on a text field pops the + // IME; pointer users click the field themselves), and the key change on + // flipping back to key input re-runs the claim so the D-pad always has + // somewhere to land. + val inputMode = LocalInputModeManager.current.inputMode + val usernameFocusModifier = rememberTvContentInitialFocus( + target = usernameFocus, + contentKey = if (inputMode == InputMode.Touch) null else inputMode, + ) Box( modifier = Modifier .fillMaxSize() + .then(usernameFocusModifier) .imePadding(), ) { TvAuroraBackdrop(variant = TvAuroraVariant.Welcome) @@ -100,7 +109,7 @@ fun TvSetupScreen( .align(Alignment.TopCenter) .width(420.dp) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(formScrollState) .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), ) { BrandHeader() @@ -108,7 +117,7 @@ fun TvSetupScreen( Spacer(modifier = Modifier.height(Spacing.xs)) Text( - text = "Welcome to Prairie", + text = "Welcome to Silo", style = TvAuthFormTextStyles.Title, color = Color.White, ) @@ -118,70 +127,100 @@ fun TvSetupScreen( color = Color.White.copy(alpha = 0.72f), ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - label = { Text("Username", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(usernameBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { usernameBringIntoView.bringIntoView() } - } - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "USERNAME", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + singleLine = true, + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + showKeyboardOnFocus = false, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect() + .focusRequester(usernameFocus), + colors = tvOutlinedTextFieldColors(), + ) + } - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - label = { Text("Email", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(emailBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { emailBringIntoView.bringIntoView() } - }, - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "EMAIL", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + singleLine = true, + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + showKeyboardOnFocus = false, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect(), + colors = tvOutlinedTextFieldColors(), + ) + } - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - label = { Text("Password", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onCreateAccountClick() }, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(passwordBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { passwordBringIntoView.bringIntoView() } - }, - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "PASSWORD", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions( + onDone = { if (!state.isLoading) viewModel.onCreateAccountClick() }, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect(), + colors = tvOutlinedTextFieldColors(), + ) + } if (state.error != null) { Text( @@ -191,20 +230,15 @@ fun TvSetupScreen( ) } - Box( - modifier = Modifier - .bringIntoViewRequester(submitBringIntoView) - .onFocusEvent { fs -> if (fs.hasFocus) scope.launch { submitBringIntoView.bringIntoView() } }, - ) { - TvHeroActionPill( + Box { + AuroraPrimaryButton( label = if (state.isLoading) "Creating account…" else "Create Account", icon = Icons.AutoMirrored.Filled.ArrowForward, - variant = TvPillVariant.Filled, - heightOverride = 32.dp, - horizontalPaddingOverride = 19.dp, - labelStyle = TvAuthFormTextStyles.Button, enabled = !state.isLoading, onClick = viewModel::onCreateAccountClick, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.PrimaryButtonHeight), ) } } @@ -245,25 +279,23 @@ internal object TvAuthFormTextStyles { lineHeight = 24.sp, letterSpacing = 0.sp, ) - val FieldLabel = TextStyle( + /** Mono uppercase caption above each input — the auth-flow field idiom + * (server setup and sign-in); Material's floating label renders oversized + * in the border notch at TV type scale. */ + val InputLabel = TextStyle( + fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp, + lineHeight = 19.sp, + letterSpacing = 3.sp, ) val FieldText = TextStyle( fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 16.sp, + fontSize = 17.sp, + lineHeight = 22.sp, letterSpacing = 0.sp, color = Color.White, ) - val Button = TextStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - lineHeight = 16.sp, - letterSpacing = 0.sp, - ) val Error = TextStyle( fontWeight = FontWeight.SemiBold, fontSize = 16.sp, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt index 9c36cc8df..b73abe886 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt @@ -12,45 +12,46 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.InputMode import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant -import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill -import org.prairieserver.prairie.tv.ui.components.TvPillVariant +import org.prairieserver.prairie.tv.ui.components.AuroraGhostButton +import org.prairieserver.prairie.tv.ui.components.AuroraPrimaryButton +import org.prairieserver.prairie.tv.ui.components.rememberTvImeAwareFormScrollState +import org.prairieserver.prairie.tv.ui.components.tvImeAwareFieldContext +import org.prairieserver.prairie.tv.ui.components.tvShowImeOnSelect +import org.prairieserver.prairie.tv.ui.components.TvAuthFormDefaults import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.theme.Spacing -import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel /** @@ -72,12 +73,7 @@ fun TvSignupScreen( ) { val state by viewModel.uiState.collectAsState() val usernameFocus = remember { FocusRequester() } - val usernameBringIntoView = remember { BringIntoViewRequester() } - val emailBringIntoView = remember { BringIntoViewRequester() } - val passwordBringIntoView = remember { BringIntoViewRequester() } - val inviteBringIntoView = remember { BringIntoViewRequester() } - val submitBringIntoView = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() + val formScrollState = rememberTvImeAwareFormScrollState() LaunchedEffect(state.signupSuccess) { if (state.signupSuccess) { @@ -85,11 +81,23 @@ fun TvSignupScreen( onSignupComplete() } } - LaunchedEffect(Unit) { runCatching { usernameFocus.requestFocus() } } + // A text field on a first-run screen: if this claim is dropped the + // remote has nothing to act on and no touch fallback exists. + // Snapshot-backed input mode drives the claim: null contentKey while the + // viewer is in touch mode (a programmatic claim on a text field pops the + // IME; pointer users click the field themselves), and the key change on + // flipping back to key input re-runs the claim so the D-pad always has + // somewhere to land. + val inputMode = LocalInputModeManager.current.inputMode + val usernameFocusModifier = rememberTvContentInitialFocus( + target = usernameFocus, + contentKey = if (inputMode == InputMode.Touch) null else inputMode, + ) Box( modifier = Modifier .fillMaxSize() + .then(usernameFocusModifier) .imePadding(), ) { TvAuroraBackdrop(variant = TvAuroraVariant.SignIn) @@ -100,7 +108,7 @@ fun TvSignupScreen( .align(Alignment.TopCenter) .width(420.dp) .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(formScrollState) .padding(top = 24.dp, bottom = Spacing.lg, start = Spacing.xl, end = Spacing.xl), ) { BrandHeader() @@ -113,95 +121,135 @@ fun TvSignupScreen( color = Color.White, ) Text( - text = "Join this Prairie server with your invite.", + text = "Join this Silo server with your invite.", style = TvAuthFormTextStyles.Body, color = Color.White.copy(alpha = 0.72f), ) - OutlinedTextField( - value = state.username, - onValueChange = viewModel::onUsernameChanged, - label = { Text("Username", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(usernameBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { usernameBringIntoView.bringIntoView() } - } - .focusRequester(usernameFocus), - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "USERNAME", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.username, + onValueChange = viewModel::onUsernameChanged, + singleLine = true, + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + showKeyboardOnFocus = false, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect() + .focusRequester(usernameFocus), + colors = tvOutlinedTextFieldColors(), + ) + } - OutlinedTextField( - value = state.email, - onValueChange = viewModel::onEmailChanged, - label = { Text("Email", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(emailBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { emailBringIntoView.bringIntoView() } - }, - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "EMAIL", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.email, + onValueChange = viewModel::onEmailChanged, + singleLine = true, + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + showKeyboardOnFocus = false, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect(), + colors = tvOutlinedTextFieldColors(), + ) + } - OutlinedTextField( - value = state.password, - onValueChange = viewModel::onPasswordChanged, - label = { Text("Password", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(passwordBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { passwordBringIntoView.bringIntoView() } - }, - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "PASSWORD", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.password, + onValueChange = viewModel::onPasswordChanged, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + showKeyboardOnFocus = false, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect(), + colors = tvOutlinedTextFieldColors(), + ) + } - OutlinedTextField( - value = state.inviteCode, - onValueChange = viewModel::onInviteCodeChanged, - label = { Text("Invite Code", style = TvAuthFormTextStyles.FieldLabel, color = Color.White) }, - singleLine = true, - textStyle = TvAuthFormTextStyles.FieldText, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { if (!state.isLoading) viewModel.onSignupClick() }, - ), - enabled = !state.isLoading, + Column( + verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier .fillMaxWidth() - .bringIntoViewRequester(inviteBringIntoView) - .onFocusEvent { fs -> - if (fs.isFocused) scope.launch { inviteBringIntoView.bringIntoView() } - }, - colors = tvOutlinedTextFieldColors(), - ) + .tvImeAwareFieldContext(), + ) { + Text( + text = "INVITE CODE", + style = TvAuthFormTextStyles.InputLabel, + color = Color.White.copy(alpha = 0.52f), + ) + OutlinedTextField( + value = state.inviteCode, + onValueChange = viewModel::onInviteCodeChanged, + singleLine = true, + textStyle = TvAuthFormTextStyles.FieldText, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + showKeyboardOnFocus = false, + ), + keyboardActions = KeyboardActions( + onDone = { if (!state.isLoading) viewModel.onSignupClick() }, + ), + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(TvAuthFormDefaults.FieldHeight) + .tvShowImeOnSelect(), + colors = tvOutlinedTextFieldColors(), + ) + } if (state.error != null) { Text( @@ -211,37 +259,25 @@ fun TvSignupScreen( ) } - Row( - horizontalArrangement = Arrangement.spacedBy(Spacing.md), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Box( + Box { + AuroraPrimaryButton( + label = if (state.isLoading) "Signing up…" else "Sign Up", + icon = Icons.AutoMirrored.Filled.ArrowForward, + enabled = !state.isLoading, + onClick = viewModel::onSignupClick, modifier = Modifier - .bringIntoViewRequester(submitBringIntoView) - .onFocusEvent { fs -> if (fs.hasFocus) scope.launch { submitBringIntoView.bringIntoView() } }, - ) { - TvHeroActionPill( - label = if (state.isLoading) "Signing up…" else "Sign Up", - icon = Icons.AutoMirrored.Filled.ArrowForward, - variant = TvPillVariant.Filled, - heightOverride = 32.dp, - horizontalPaddingOverride = 19.dp, - labelStyle = TvAuthFormTextStyles.Button, - enabled = !state.isLoading, - onClick = viewModel::onSignupClick, - ) - } - TvHeroActionPill( - label = "Sign In Instead", - icon = Icons.AutoMirrored.Filled.Login, - variant = TvPillVariant.Hollow, - heightOverride = 32.dp, - horizontalPaddingOverride = 19.dp, - labelStyle = TvAuthFormTextStyles.Button, - onClick = onBackToLogin, + .fillMaxWidth() + .height(TvAuthFormDefaults.PrimaryButtonHeight), ) } + AuroraGhostButton( + label = "Sign In Instead", + onClick = onBackToLogin, + fontSize = 18.sp, + horizontalPadding = 18.dp, + verticalPadding = 8.dp, + modifier = Modifier.fillMaxWidth(), + ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/browse/TvBrowseScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/browse/TvBrowseScreen.kt index ac00a879d..9b4ea9982 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/browse/TvBrowseScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/browse/TvBrowseScreen.kt @@ -25,8 +25,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -39,6 +44,9 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import org.prairieserver.prairie.tv.ui.focus.TvRestoreFocusOnModalDismiss import org.prairieserver.prairie.tv.ui.components.TvCatalogEmptyState import org.prairieserver.prairie.tv.ui.components.TvCatalogGrid import org.prairieserver.prairie.tv.ui.components.TvErrorScreen @@ -71,8 +79,22 @@ fun TvBrowseScreen( ) { val state by viewModel.uiState.collectAsState() var showFilterSheet by remember { mutableStateOf(false) } + // The sheet is a modal focus owner, so something has to hand focus back + // when it closes. Left to the focus system, the successor is picked + // geometrically and lands on whatever the dimmed page happened to have. + val filterOpenerFocus = remember { FocusRequester() } + var filterOpenerFocused by remember { mutableStateOf(false) } + val filterOpenerModifier = Modifier + .focusRequester(filterOpenerFocus) + .onFocusChanged { filterOpenerFocused = it.isFocused } + TvRestoreFocusOnModalDismiss( + visible = showFilterSheet, + opener = filterOpenerFocus, + isOpenerFocused = { filterOpenerFocused }, + ) val firstItemFocusRequester = remember { FocusRequester() } + var browseGridHasFocus by remember { mutableStateOf(false) } var initialFocusRequested by remember { mutableStateOf(false) } LaunchedEffect(state.items.isNotEmpty()) { @@ -81,7 +103,12 @@ fun TvBrowseScreen( // a slow first load (empty here) would permanently skip grid focus. if (initialFocusRequested || state.items.isEmpty()) return@LaunchedEffect kotlinx.coroutines.delay(120) - runCatching { firstItemFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = firstItemFocusRequester::requestFocus, + isFocused = { browseGridHasFocus }, + ) initialFocusRequested = true } @@ -90,6 +117,7 @@ fun TvBrowseScreen( Box( modifier = Modifier .fillMaxSize() + .onFocusChanged { browseGridHasFocus = it.hasFocus } .background(MaterialTheme.colorScheme.background), ) { Column(modifier = Modifier.fillMaxSize()) { @@ -99,6 +127,7 @@ fun TvBrowseScreen( sortLabel = sortLabel, filter = state.filter, onOpenFilters = { showFilterSheet = true }, + filterOpenerModifier = filterOpenerModifier, modifier = Modifier.padding( top = TvTopMenuLayout.contentTopInset, start = Spacing.safeArea, @@ -122,6 +151,7 @@ fun TvBrowseScreen( sortLabel = sortLabel, filter = state.filter, onOpenFilters = { showFilterSheet = true }, + filterOpenerModifier = filterOpenerModifier, modifier = Modifier.padding( top = TvTopMenuLayout.contentTopInset, start = Spacing.safeArea, @@ -136,6 +166,7 @@ fun TvBrowseScreen( onItemClick = onOpenItemDetail, onLoadMore = viewModel::loadMore, onOpenFilters = { showFilterSheet = true }, + filterOpenerModifier = filterOpenerModifier, firstItemFocusRequester = firstItemFocusRequester, ) } @@ -251,6 +282,7 @@ private fun BrowseGrid( onItemClick: (String) -> Unit, onLoadMore: () -> Unit, onOpenFilters: () -> Unit, + filterOpenerModifier: Modifier, firstItemFocusRequester: FocusRequester, ) { // Shared catalog grid: pagination, focus-restorer, header + empty state. @@ -282,6 +314,7 @@ private fun BrowseGrid( sortLabel = sortLabel, filter = state.filter, onOpenFilters = onOpenFilters, + filterOpenerModifier = filterOpenerModifier, ) }, emptyState = { @@ -300,6 +333,7 @@ private fun BrowseHeader( sortLabel: String, filter: TvBrowseFilter, onOpenFilters: () -> Unit, + filterOpenerModifier: Modifier, modifier: Modifier = Modifier, ) { Column( @@ -330,6 +364,7 @@ private fun BrowseHeader( filter = filter, sortLabel = sortLabel, onOpenFilters = onOpenFilters, + filterOpenerModifier = filterOpenerModifier, ) } } @@ -344,6 +379,7 @@ private fun FilterRow( filter: TvBrowseFilter, sortLabel: String, onOpenFilters: () -> Unit, + filterOpenerModifier: Modifier, modifier: Modifier = Modifier, ) { FlowRow( @@ -353,7 +389,11 @@ private fun FilterRow( horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - FilterEntryButton(label = "Filter", onClick = onOpenFilters) + FilterEntryButton( + label = "Filter", + onClick = onOpenFilters, + modifier = filterOpenerModifier, + ) if (filter.genre != null) { ActiveFilterPill(label = "Genre: ${filter.genre}") diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt index e9ae92db4..a4c168137 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt @@ -1,10 +1,12 @@ package org.prairieserver.prairie.tv.ui.screens.calendar -import androidx.compose.foundation.BorderStroke import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -21,15 +23,11 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.LocalBringIntoViewSpec -import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -48,13 +46,18 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -75,8 +78,17 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.common.calendar.localDisplayAirTime +import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.calendar.CalendarBadge import org.prairieserver.prairie.model.calendar.CalendarFilter import org.prairieserver.prairie.model.calendar.CalendarItem @@ -85,22 +97,30 @@ import org.prairieserver.prairie.tv.ui.components.LocalAmbientBackdropTint import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.TvRootHeroBackdrop import org.prairieserver.prairie.tv.ui.components.rememberAmbientBackdropTintState +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusAcquisitionBudgetMillis +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvReturnRelocation +import org.prairieserver.prairie.tv.ui.focus.TvReturnResolution +import org.prairieserver.prairie.tv.ui.focus.TvReturnSection +import org.prairieserver.prairie.tv.ui.focus.TvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.TvReturnTargetSaver +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.resolveTvReturnTarget import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.DarkOnPrimary import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.Spacing +import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec import org.prairieserver.prairie.viewmodel.CalendarViewModel -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import java.time.LocalDate -import java.time.format.DateTimeFormatter -import java.util.Locale /** * TV calendar / upcoming screen. Reuses the shared [CalendarViewModel] that * drives the phone Calendar screen (week-bucketed releases from - * `GET /api/v1/calendar`). Rebuilt to match the prairie-apple tvOS Calendar: + * `GET /api/v1/calendar`). Rebuilt to match the silo-apple tvOS Calendar: * * - A single segmented Following / Trending / All capsule (one container, the * selected segment filled), matching tvOS without an Android-only rail. @@ -119,13 +139,14 @@ import java.util.Locale * Mirrors [org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendationsScreen] * for the koinViewModel + initial-focus-once pattern. */ -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Composable fun TvCalendarScreen( onOpenItemDetail: (contentId: String) -> Unit, onInitialContentFocus: () -> Unit = {}, + onMoveUpToMenu: () -> Unit = {}, focusRequest: Int = 0, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: CalendarViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -133,6 +154,7 @@ fun TvCalendarScreen( // First focusable element is the segmented filter capsule so the D-pad // lands there when the Calendar tab swaps in; gate the jump so a silent // re-emission doesn't yank focus back after the user has navigated. + var calendarFilterHasFocus by remember { mutableStateOf(false) } val filterFocusRequesters = remember { mapOf( CalendarFilter.Following to FocusRequester(), @@ -146,13 +168,154 @@ fun TvCalendarScreen( val listState = rememberLazyListState() val scope = rememberCoroutineScope() + var shelfFocusDay by remember { mutableStateOf(null) } + var shelfFocusRequest by remember { mutableIntStateOf(0) } + var shelfFocusItemIndex by remember { mutableIntStateOf(0) } + + // Where the viewer was when they opened something. The day is the section + // and the item's own contentId is its identity — NOT detailContentId, which + // several episodes of one show share and which would therefore send focus + // to whichever of them the week happens to list first. + var returnTarget by rememberSaveable(stateSaver = TvReturnTargetSaver) { + mutableStateOf(null) + } + // Recorded by a CLICK and by nothing else, which is what makes a target + // mean "I opened something and I am coming back". + // + // The pending flag is saveable and is the actual return signal. Inferring + // it from "a target survived this composition" required Calendar to have + // been disposed and recreated, which a fast Back during the outgoing + // transition does not do — the target would then sit unconsumed and make + // some later, ordinary tab re-selection look like a return instead. + // + // The flat surfaces also record on focus, and that is right for them: they + // are pushed routes where Back is the only way in, so a saved target can + // only have come from a trip. Calendar is a root tab. Browsing a card, + // switching to Home, and selecting Calendar again would look identical to a + // detail return, and restoring there would quietly defeat the shell's + // documented reset to the controls. The cost is that process death while + // merely browsing forgets the position — which is the lesser of the two. + var returnPending by rememberSaveable { mutableStateOf(false) } + // Bumped when the destination resumes, whether or not it was recreated. + var resumeGeneration by remember { mutableIntStateOf(0) } + CalendarResumeSignal { resumeGeneration++ } + // What the restoration is waiting to see take focus, and what last did. + // Confirmation is by identity: having called requestFocus() is not evidence + // that anything received it. + var focusedItemId by remember { mutableStateOf(null) } + + val recordReturnTarget: (String, CalendarItem, Int) -> Unit = { date, item, index -> + returnPending = true + returnTarget = TvReturnTarget( + sectionId = date, + itemId = item.contentId, + sectionIndex = state.weekDates.indexOf(date).coerceAtLeast(0), + itemIndex = index, + ) + } + // Explicit shell-to-screen handoff. The shell bumps this token whenever // Calendar is selected, including re-selection after a restored route. // Target the active segment directly instead of asking the parent content // group to guess a descendant (which falls back to Home while navigating). - LaunchedEffect(focusRequest, state.isLoading) { + // A return owns entry. The shell bumps its token on every Calendar + // selection, a Back out of item detail included, so without this the shell + // handoff would scroll to the top and claim the controls while the + // restoration was still working — two claimants, and the later one wins by + // accident rather than by decision. + // Keyed on state.days, not just the week's dates. A refresh keeps the same + // seven dates while replacing everything inside them, so weekDates alone + // could not tell a completed refresh from no change at all — and the effect + // would never re-resolve. + LaunchedEffect(resumeGeneration, state.isLoading, state.isRefreshing, state.days) { + // isRefreshing as well as isLoading. Resolving mid-refresh answers + // against cards the refresh is about to replace, and both + // FollowAcrossSections and treatAbsenceAsFinal are claims about a FINAL + // snapshot — applying them to a provisional one consumes the target on + // an answer that was never authoritative. + if (!returnPending || state.isLoading || state.isRefreshing) return@LaunchedEffect + val sections = state.weekDates.map { date -> + TvReturnSection(id = date, itemIds = state.itemsFor(date).map { it.contentId }) + } + val located = resolveTvReturnTarget( + target = returnTarget, + sections = sections, + // A calendar item can legitimately change day when an air date is + // corrected. The viewer went to see that item, not that slot, so + // following it is what they meant. + relocation = TvReturnRelocation.FollowAcrossSections, + // The week is fixed and nothing pages, so absence is already final + // — there is no later arrival to wait for. + treatAbsenceAsFinal = true, + ) as? TvReturnResolution.Located + if (located == null) { + // A week that renders no cards has nothing to restore to, and the + // shell handoff is the right answer for it — waiting for a later + // load would hold entry open on a screen already showing the viewer + // its empty or error state. + returnTarget = null + returnPending = false + // Release the claim, exactly as the timeout path does. Waking the + // shell effect is no use if it then finds the token already + // applied and stands down again, which in the retained case leaves + // the screen with nothing focused. + lastAppliedFocusRequest = -1 + return@LaunchedEffect + } + // Claim the shell handoff before driving, so the effect below treats it + // as already applied rather than racing this one. + lastAppliedFocusRequest = focusRequest + listState.scrollToItem(located.sectionIndex + CalendarShelfIndexOffset) + // Let the shelf compose and attach before the token names it. + androidx.compose.runtime.withFrameNanos { } + shelfFocusItemIndex = located.itemIndex + shelfFocusDay = located.sectionId + shelfFocusRequest += 1 + + // Wait to SEE the card take focus. requestFocus() can be dropped — an + // unattached requester, a shelf still composing, a focus transaction + // that rolls back — and every one of those returns without telling us. + val landed = withTimeoutOrNull(TvFocusAcquisitionBudgetMillis) { + // Settled on the target, not merely seen there. focusedItemId now + // tracks CURRENT focus, but a snapshot of it is still only a moment + // — focus traversal can pass through the target on its way + // somewhere else, and a bare first { } would call that a landing. + // Holding the value across a short window is what distinguishes + // arriving from passing by. + snapshotFlow { focusedItemId } + .transformLatest { id -> + if (id == located.itemId) { + delay(TvCalendarReturnSettleMillis) + emit(Unit) + } + } + .first() + } != null + + returnTarget = null + returnPending = false + if (landed) { + // Only now. Reporting the handoff on having ASKED would leave the + // shell believing content owns focus while nothing does, and the + // top menu suppressed behind it. + onInitialContentFocus() + } else { + // Give entry back. Releasing the claim re-arms the shell effect, + // which returnPending has just re-keyed. + lastAppliedFocusRequest = -1 + } + } + + // returnPending is a key, not just a condition. Standing aside is only safe + // if standing down wakes this again: a return that resolves to nothing — + // the week failed to load, or came back empty — would otherwise leave the + // screen with no claimant at all, because this effect had already run and + // returned early. When the restoration does drive, it claims the token + // first, so the re-run stops at the line above instead. + LaunchedEffect(focusRequest, state.isLoading, returnPending) { if (focusRequest == lastAppliedFocusRequest) return@LaunchedEffect if (state.isLoading) return@LaunchedEffect + if (returnPending) return@LaunchedEffect val layoutInfo = listState.layoutInfo val itemExtent = (layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0) + layoutInfo.mainAxisItemSpacing @@ -172,16 +335,29 @@ fun TvCalendarScreen( androidx.compose.runtime.withFrameNanos { } androidx.compose.runtime.withFrameNanos { } val requester = filterFocusRequesters[state.filter] ?: filterFocusRequester - val applied = runCatching { requester.requestFocus() }.getOrDefault(false) + val applied = requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { androidx.compose.runtime.withFrameNanos { } }, + requestFocus = requester::requestFocus, + isFocused = { calendarFilterHasFocus }, + ) == TvObservedFocusResult.Focused if (applied) { // Keep the shell bar suppressed through Android's delayed initial // focus pass. Reconfirm the filter after that pass, then release - // suppression on the following frame. + // suppression on the following frame. The reconfirm is a second + // claim against the same target, so it is observed too. kotlinx.coroutines.delay(120) - runCatching { requester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { androidx.compose.runtime.withFrameNanos { } }, + requestFocus = requester::requestFocus, + isFocused = { calendarFilterHasFocus }, + ) androidx.compose.runtime.withFrameNanos { } - lastAppliedFocusRequest = focusRequest - onInitialContentFocus() + if (lastAppliedFocusRequest != focusRequest) { + lastAppliedFocusRequest = focusRequest + onInitialContentFocus() + } } } // Focus hand-off for day selection: picking a day in the week strip scrolls @@ -191,8 +367,6 @@ fun TvCalendarScreen( val snapControlsToInitialPosition: () -> Unit = { scope.launch { listState.animateScrollToItem(0) } } - var shelfFocusDay by remember { mutableStateOf(null) } - var shelfFocusRequest by remember { mutableIntStateOf(0) } val tintState = rememberAmbientBackdropTintState() val initialTintItem = state.weekDates .asSequence() @@ -206,7 +380,16 @@ fun TvCalendarScreen( CompositionLocalProvider(LocalAmbientBackdropTint provides tintState) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + // The zone callbacks only fire when a calendar control or shelf + // GAINS focus, so moving Up from the filter into the top menu + // left the flag true with focus outside the screen entirely. + // A later shell handoff then read that stale true, skipped both + // claims, and still reported initial content focus — and only + // another calendar zone gaining focus could clear it, which the + // skipped handoff could never cause. + .onFocusChanged { if (!it.hasFocus) calendarFilterHasFocus = false }, ) { TvRootHeroBackdrop( content = null, @@ -214,7 +397,7 @@ fun TvCalendarScreen( modifier = Modifier.fillMaxSize(), ) - val controls: @Composable () -> Unit = { + val controls: @Composable ((CalendarControlFocusZone) -> Unit) -> Unit = { onControlFocused -> CalendarControls( selectedFilter = state.filter, weekDates = state.weekDates, @@ -225,7 +408,7 @@ fun TvCalendarScreen( firstSegmentFocusRequester = filterFocusRequester, segmentFocusRequesters = filterFocusRequesters, selectedDayFocusRequester = selectedDayFocusRequester, - onControlFocused = snapControlsToInitialPosition, + onControlFocused = onControlFocused, includeTopInset = false, onSelectFilter = viewModel::setFilter, onSelectDay = { date -> @@ -236,6 +419,13 @@ fun TvCalendarScreen( } if (state.itemsFor(date).isNotEmpty()) { shelfFocusDay = date + // Explicitly zero. Relying on the consume callback + // to have reset it makes this depend on the + // previous request having run to completion — and a + // shelf effect cancelled while its scroll suspends + // never reaches that reset, leaving a day selection + // to inherit the restoration's card index. + shelfFocusItemIndex = 0 shelfFocusRequest += 1 } }, @@ -250,19 +440,52 @@ fun TvCalendarScreen( // naturally pushes both controls upward instead of pinning them. CalendarList( onContentUpFallbackChanged = onContentUpFallbackChanged, + onMoveUpToMenu = onMoveUpToMenu, state = state, listState = listState, controls = controls, + activeFilterFocusRequester = filterFocusRequesters[state.filter] ?: filterFocusRequester, + onControlFocused = { zone -> + // The flag this screen's filter claim is observed on. It was + // declared and never assigned, so it read false forever: the + // claim burned every attempt and reported Exhausted even when + // focus had landed, which meant the reconfirm after Android's + // delayed focus pass, the bar-suppression release and + // onInitialContentFocus() never ran. + calendarFilterHasFocus = zone == CalendarControlFocusZone.Filter + if (zone != null) snapControlsToInitialPosition() + }, + onFocusRequestAcknowledged = { + if (lastAppliedFocusRequest != focusRequest) { + lastAppliedFocusRequest = focusRequest + onInitialContentFocus() + } + }, onRefresh = viewModel::refresh, onShowEverything = { viewModel.setFilter(CalendarFilter.All) }, selectedDayFocusRequester = selectedDayFocusRequester, shelfFocusDay = shelfFocusDay, shelfFocusRequest = shelfFocusRequest, + shelfFocusItemIndex = shelfFocusItemIndex, onShelfFocusConsumed = { shelfFocusDay = null shelfFocusRequest = 0 + shelfFocusItemIndex = 0 + }, + onItemFocused = { _, item, _, focused -> + if (focused) { + tintState.set(null, item.posterUrl) + focusedItemId = item.contentId + } else if (focusedItemId == item.contentId) { + // Only if this card is still the one on record. A gain + // elsewhere lands before this loss arrives, and an + // unconditional clear would wipe the new position. + focusedItemId = null + } + }, + onItemClicked = { date, item, index -> + recordReturnTarget(date, item, index) }, - onItemFocused = { item -> tintState.set(null, item.posterUrl) }, onOpenItemDetail = onOpenItemDetail, ) } @@ -280,7 +503,7 @@ private fun CalendarControls( firstSegmentFocusRequester: FocusRequester, segmentFocusRequesters: Map, selectedDayFocusRequester: FocusRequester, - onControlFocused: () -> Unit, + onControlFocused: (CalendarControlFocusZone) -> Unit, includeTopInset: Boolean, onSelectFilter: (String) -> Unit, onSelectDay: (String) -> Unit, @@ -335,7 +558,7 @@ private fun CalendarControlRow( onSelectFilter: (String) -> Unit, firstSegmentFocusRequester: FocusRequester, segmentFocusRequesters: Map, - onControlFocused: () -> Unit, + onControlFocused: (CalendarControlFocusZone) -> Unit, ) { Row( modifier = Modifier @@ -368,7 +591,7 @@ private fun FilterBar( onSelect: (String) -> Unit, firstSegmentFocusRequester: FocusRequester, segmentFocusRequesters: Map, - onControlFocused: () -> Unit, + onControlFocused: (CalendarControlFocusZone) -> Unit, ) { val presets = listOf( CalendarFilter.Following to "Following", @@ -391,7 +614,7 @@ private fun FilterBar( onClick = { onSelect(value) }, focusRequester = segmentFocusRequesters[value] ?: if (index == 0) firstSegmentFocusRequester else null, - onFocused = onControlFocused, + onFocused = { onControlFocused(CalendarControlFocusZone.Filter) }, ) } } @@ -454,7 +677,7 @@ private fun WeekStrip( isCurrentWeek: Boolean, hasEvents: (String) -> Boolean, selectedDayFocusRequester: FocusRequester, - onControlFocused: () -> Unit, + onControlFocused: (CalendarControlFocusZone) -> Unit, onSelectDay: (String) -> Unit, onPrevWeek: () -> Unit, onNextWeek: () -> Unit, @@ -467,7 +690,11 @@ private fun WeekStrip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { - ChevronButton(arrow = NavArrow.Prev, onClick = onPrevWeek) + ChevronButton( + arrow = NavArrow.Prev, + onClick = onPrevWeek, + onFocused = { onControlFocused(CalendarControlFocusZone.WeekStrip) }, + ) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -479,14 +706,21 @@ private fun WeekStrip( isToday = date == today, hasEvents = hasEvents(date), focusRequester = if (date == selectedDay) selectedDayFocusRequester else null, - onFocused = onControlFocused, + onFocused = { onControlFocused(CalendarControlFocusZone.WeekStrip) }, onClick = { onSelectDay(date) }, ) } } - ChevronButton(arrow = NavArrow.Next, onClick = onNextWeek) + ChevronButton( + arrow = NavArrow.Next, + onClick = onNextWeek, + onFocused = { onControlFocused(CalendarControlFocusZone.WeekStrip) }, + ) if (!isCurrentWeek) { - TodayButton(onClick = onToday) + TodayButton( + onClick = onToday, + onFocused = { onControlFocused(CalendarControlFocusZone.WeekStrip) }, + ) } Spacer(modifier = Modifier.weight(1f)) Text( @@ -505,7 +739,11 @@ private enum class NavArrow { Prev, Next } @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ChevronButton(arrow: NavArrow, onClick: () -> Unit) { +private fun ChevronButton( + arrow: NavArrow, + onClick: () -> Unit, + onFocused: () -> Unit, +) { val shape = CircleShape Surface( onClick = onClick, @@ -519,7 +757,9 @@ private fun ChevronButton(arrow: NavArrow, onClick: () -> Unit) { pressedContentColor = FocusedContent, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.06f), - modifier = Modifier.size(28.dp), + modifier = Modifier + .onFocusChanged { if (it.isFocused) onFocused() } + .size(28.dp), ) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Icon( @@ -538,7 +778,7 @@ private fun ChevronButton(arrow: NavArrow, onClick: () -> Unit) { @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TodayButton(onClick: () -> Unit) { +private fun TodayButton(onClick: () -> Unit, onFocused: () -> Unit) { val shape = RoundedCornerShape(100.dp) Surface( onClick = onClick, @@ -552,6 +792,7 @@ private fun TodayButton(onClick: () -> Unit) { pressedContentColor = FocusedContent, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + modifier = Modifier.onFocusChanged { if (it.isFocused) onFocused() }, ) { Box( modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), @@ -674,27 +915,81 @@ internal fun shouldReturnCalendarFocusToControls( focusedShelfIndex == firstFocusableShelfIndex && !isReturningToControls +internal enum class CalendarControlFocusZone { Filter, WeekStrip } + +internal enum class CalendarUpFallbackAction { + EnterMenu, + FocusFilter, + ReturnToControls, + StayInContent, + MoveWithinContent, +} + +internal fun calendarUpFallbackAction( + focusedShelfIndex: Int?, + firstFocusableShelfIndex: Int, + isReturningToControls: Boolean, + focusedControlZone: CalendarControlFocusZone?, + isRepeat: Boolean = false, +): CalendarUpFallbackAction = when { + // A return to the controls is already in flight. It stays in flight until + // the controls actually take focus, and for those frames the shelf still + // reports its own index — so neither the null-index check below nor + // shouldReturnCalendarFocusToControls (which goes false the instant the + // return starts) can hold a held key. Without this, the same press that + // began the handoff leaks straight into geometric movement. + isRepeat && isReturningToControls -> CalendarUpFallbackAction.StayInContent + shouldReturnCalendarFocusToControls( + focusedShelfIndex = focusedShelfIndex, + firstFocusableShelfIndex = firstFocusableShelfIndex, + isReturningToControls = isReturningToControls, + ) -> if (isRepeat) CalendarUpFallbackAction.StayInContent else CalendarUpFallbackAction.ReturnToControls + focusedShelfIndex == null && isRepeat -> CalendarUpFallbackAction.StayInContent + focusedControlZone == CalendarControlFocusZone.WeekStrip -> CalendarUpFallbackAction.FocusFilter + focusedControlZone == CalendarControlFocusZone.Filter -> CalendarUpFallbackAction.EnterMenu + else -> CalendarUpFallbackAction.MoveWithinContent +} + // MARK: - Day list @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalTvMaterial3Api::class) @Composable private fun CalendarList( - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + onMoveUpToMenu: () -> Unit = {}, state: org.prairieserver.prairie.viewmodel.CalendarUiState, listState: LazyListState, - controls: @Composable () -> Unit, + controls: @Composable ((CalendarControlFocusZone) -> Unit) -> Unit, + activeFilterFocusRequester: FocusRequester, + /** + * Which control zone holds focus, or null when the controls lose it. + * + * The zone matters: the screen's filter claim is observed on the filter row + * specifically, and a caller that only hears "some control took focus" + * cannot tell that apart from the week strip. + */ + onControlFocused: (CalendarControlFocusZone?) -> Unit, + onFocusRequestAcknowledged: () -> Unit, onRefresh: () -> Unit, onShowEverything: () -> Unit, selectedDayFocusRequester: FocusRequester, shelfFocusDay: String?, shelfFocusRequest: Int, + shelfFocusItemIndex: Int, onShelfFocusConsumed: () -> Unit, - onItemFocused: (CalendarItem) -> Unit, + onItemFocused: (date: String, item: CalendarItem, index: Int, focused: Boolean) -> Unit, + onItemClicked: (date: String, item: CalendarItem, index: Int) -> Unit, onOpenItemDetail: (contentId: String) -> Unit, ) { val snapScope = rememberCoroutineScope() val focusManager = androidx.compose.ui.platform.LocalFocusManager.current + var selectedDayHasFocus by remember { mutableStateOf(false) } var isReturningToControls by remember { mutableStateOf(false) } + var focusedControlZone by remember { mutableStateOf(null) } + val clearControlFocusZone: () -> Unit = { + focusedControlZone = null + onControlFocused(null) + } val firstFocusableDayIndex = state.weekDates.indexOfFirst { state.itemsFor(it).isNotEmpty() } val onShelfFocused: (Int) -> Unit = { index -> // Item zero is the filter/week control shell. @@ -706,19 +1001,29 @@ private fun CalendarList( if (!isReturningToControls) { isReturningToControls = true snapScope.launch { + // The controls row is list item zero, and focusing a shelf + // snaps that shelf to the top — which scrolls item zero out of + // the composed window. A requester on an un-composed row is + // not attached, so every claim below was refused and Up from + // the first shelf looked dead. Bring it back first; the date's + // on-focus scroll then finds nothing left to move. + if (listState.layoutInfo.visibleItemsInfo.none { it.index == 0 }) { + listState.animateScrollToItem(0) + withFrameNanos { } + } // Claim the date; its on-focus callback owns the sole vertical // animation. Keeping one scroll authority avoids the small // hitch caused by focus bring-into-view and two list animations // all racing toward item zero. - var claimed = runCatching { - selectedDayFocusRequester.requestFocus() - }.getOrDefault(false) - repeat(6) { - if (claimed) return@repeat - androidx.compose.runtime.withFrameNanos { } - claimed = runCatching { selectedDayFocusRequester.requestFocus() }.getOrDefault(false) - if (!claimed) kotlinx.coroutines.delay(40) - } + // Was a hand-rolled six-attempt loop keyed on requestFocus() + // returning true — acceptance, not arrival. The shared policy + // does the same pacing and judges it on observed focus. + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = selectedDayFocusRequester::requestFocus, + isFocused = { selectedDayHasFocus }, + ) kotlinx.coroutines.delay(80) isReturningToControls = false } @@ -731,24 +1036,52 @@ private fun CalendarList( // when focus is already in the controls item, mirror the shell's default // (moveFocus within content; false -> shell hands off to the menu bar). var focusedShelfIndex by remember { mutableStateOf(null) } - val calendarUpFallback = remember(firstFocusableDayIndex) { - { - if (shouldReturnCalendarFocusToControls( + val onCalendarControlFocused: (CalendarControlFocusZone) -> Unit = { zone -> + focusedControlZone = zone + onControlFocused(zone) + onFocusRequestAcknowledged() + } + val currentCalendarUpFallback = rememberUpdatedState<(Boolean) -> Boolean> { isRepeat -> + when (calendarUpFallbackAction( focusedShelfIndex = focusedShelfIndex, firstFocusableShelfIndex = firstFocusableDayIndex, isReturningToControls = isReturningToControls, + focusedControlZone = focusedControlZone, + isRepeat = isRepeat, ) ) { - onMoveUpToControls() - true - } else { - focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up) + CalendarUpFallbackAction.EnterMenu -> { + onMoveUpToMenu() + true + } + CalendarUpFallbackAction.FocusFilter -> { + // Key handlers answer synchronously whether they consumed + // the press, so this cannot await arrival — but a claim that + // is refused must not vanish silently either. + activeFilterFocusRequester.claimFocusOrReport( + target = "calendar_filter", + action = "up_fallback", + ) + } + CalendarUpFallbackAction.ReturnToControls -> { + onMoveUpToControls() + true + } + CalendarUpFallbackAction.StayInContent -> true + CalendarUpFallbackAction.MoveWithinContent -> { + focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Up) + } } - } } - DisposableEffect(onContentUpFallbackChanged) { - onContentUpFallbackChanged?.invoke(calendarUpFallback) - onDispose { onContentUpFallbackChanged?.invoke(calendarUpFallback) } + // Keep the registered identity stable while its implementation reads the + // current loaded-day state. A new lambda keyed by firstFocusableDayIndex + // would otherwise leave the shell holding the pre-load callback until this + // screen disposes. + val calendarUpFallbackRegistration: (Boolean) -> Boolean = + remember { { isRepeat -> currentCalendarUpFallback.value(isRepeat) } } + DisposableEffect(onContentUpFallbackChanged, calendarUpFallbackRegistration) { + onContentUpFallbackChanged?.invoke(calendarUpFallbackRegistration) + onDispose { onContentUpFallbackChanged?.invoke(calendarUpFallbackRegistration) } } // The day-snap is the ONLY vertical scroller: with the default spec the @@ -771,7 +1104,14 @@ private fun CalendarList( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item(key = "calendar-controls") { - controls() + // Arrival for the shelf→controls Up hand-off is "the CONTROLS row + // holds focus". This used to be observed on the whole list, which + // is already true while a shelf card is focused — so the claim + // returned "focused" without ever requesting, and Up from the first + // shelf was a silent no-op that re-armed 80ms later. + Box(modifier = Modifier.onFocusChanged { selectedDayHasFocus = it.hasFocus }) { + controls(onCalendarControlFocused) + } } // Keep the control item in this same LazyColumn for every data state. @@ -786,6 +1126,7 @@ private fun CalendarList( title = state.error ?: "Failed to load calendar", subtitle = "Press the week arrows to try another week.", action = CalendarAction("Refresh", onRefresh), + onActionFocused = clearControlFocusZone, ) } } @@ -800,6 +1141,7 @@ private fun CalendarList( CalendarAction("Refresh", onRefresh) }, topAligned = true, + onActionFocused = clearControlFocusZone, ) } } @@ -812,8 +1154,12 @@ private fun CalendarList( isToday = date == state.today, items = state.itemsFor(date), focusRequest = if (date == shelfFocusDay) shelfFocusRequest else 0, + focusItemIndex = if (date == shelfFocusDay) shelfFocusItemIndex else 0, onFocusApplied = onShelfFocusConsumed, - onItemFocused = onItemFocused, + onItemFocusChanged = { item, itemIndex, focused -> + onItemFocused(date, item, itemIndex, focused) + }, + onItemClicked = { item, itemIndex -> onItemClicked(date, item, itemIndex) }, // Snap the day whose shelf owns focus to the top of the list // (QA 2026-07-08: default bring-into-view revealed only the // focused CARD, stranding the previous day's caption strip @@ -822,6 +1168,7 @@ private fun CalendarList( onShelfFocusChanged = { focused -> if (focused) { focusedShelfIndex = index + clearControlFocusZone() } else if (focusedShelfIndex == index) { focusedShelfIndex = null } @@ -842,13 +1189,20 @@ private fun DayShelf( isToday: Boolean, items: List, focusRequest: Int, + /** + * Which card the token should land on. Zero for a week-strip day + * selection, which means "this day" and nothing finer; the card actually + * left behind when a return is being restored. + */ + focusItemIndex: Int = 0, onFocusApplied: () -> Unit = {}, - onItemFocused: (CalendarItem) -> Unit, + onItemFocusChanged: (item: CalendarItem, index: Int, focused: Boolean) -> Unit, + onItemClicked: (item: CalendarItem, index: Int) -> Unit = { _, _ -> }, onShelfFocused: () -> Unit = {}, onShelfFocusChanged: (Boolean) -> Unit = {}, onOpenItemDetail: (contentId: String) -> Unit, ) { - val firstCardFocusRequester = remember { FocusRequester() } + val targetCardFocusRequester = remember { FocusRequester() } val rowState = rememberLazyListState() // A changing, non-zero focus token (from a week-strip day selection) kicks @@ -859,15 +1213,25 @@ private fun DayShelf( // item 0 first so the first card is composed and its FocusRequester attached // — otherwise, after the shelf has been scrolled horizontally, the first // card may be off-screen and the request is dropped. + val targetCardIndex = focusItemIndex.coerceIn(0, (items.size - 1).coerceAtLeast(0)) + var shelfHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(focusRequest) { if (focusRequest > 0 && items.isNotEmpty()) { - rowState.scrollToItem(0) - runCatching { firstCardFocusRequester.requestFocus() } - onFocusApplied() + rowState.scrollToItem(targetCardIndex) + // onFocusApplied() retires the pending request. Retiring it after a + // claim that was dropped loses the request entirely — nothing + // focused and nothing left to retry it — so it now fires only on + // observed acquisition. The shelf already reports its own focus. + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = targetCardFocusRequester::requestFocus, + isFocused = { shelfHasFocus }, + ) + if (landed == TvObservedFocusResult.Focused) onFocusApplied() } } - - var shelfHasFocus by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() @@ -901,14 +1265,22 @@ private fun DayShelf( ), horizontalArrangement = Arrangement.spacedBy(CalendarCardSpacing), ) { - items(items, key = { "$date-${it.contentId}" }) { item -> + itemsIndexed(items, key = { _, it -> "$date-${it.contentId}" }) { index, item -> CalendarEventCard( item = item, - // First card holds the focus requester so a week-strip - // day selection can hand focus down to this shelf. - focusRequester = if (item == items.first()) firstCardFocusRequester else null, - onFocused = { onItemFocused(item) }, - onClick = { onOpenItemDetail(item.detailContentId) }, + // The card the pending token names holds the requester, + // so one mechanism serves both a week-strip day + // selection and a restored return. + focusRequester = targetCardFocusRequester.takeIf { index == targetCardIndex }, + onFocusChanged = { focused -> onItemFocusChanged(item, index, focused) }, + onClick = { + onItemClicked(item, index) + // detailContentId is where the card GOES; contentId + // is what the card IS. Several episodes of one show + // share a destination, so identity has to come from + // the item, not from the route. + onOpenItemDetail(item.detailContentId) + }, ) } } @@ -976,14 +1348,17 @@ private val posterShape = RoundedCornerShape(10.dp) private fun CalendarEventCard( item: CalendarItem, focusRequester: FocusRequester?, - onFocused: () -> Unit, + onFocusChanged: (Boolean) -> Unit, onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + // Both edges. Gain alone made the screen's record of "what has focus" + // sticky, so a card focus passed over on the way somewhere else still + // looked like the current position long after focus had moved on. LaunchedEffect(isFocused, item.contentId) { - if (isFocused) onFocused() + onFocusChanged(isFocused) } // tvOS FocusableCalendarCard: the poster alone is the focus-lifted @@ -1138,6 +1513,7 @@ private fun CalendarMessage( subtitle: String, action: CalendarAction, topAligned: Boolean = false, + onActionFocused: () -> Unit, ) { Box( modifier = Modifier @@ -1186,6 +1562,7 @@ private fun CalendarMessage( pressedContentColor = FocusedContent, ), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), + modifier = Modifier.onFocusChanged { if (it.isFocused) onActionFocused() }, ) { Box( modifier = Modifier @@ -1243,3 +1620,30 @@ private fun emptyCopy(filter: String): String = when (filter) { CalendarFilter.Trending -> "No trending releases this week." else -> "No movie releases or episode airings in this week." } + +/** The filter/week control shell occupies list slot zero, ahead of every day. */ +private const val CalendarShelfIndexOffset: Int = 1 + +/** + * Fires whenever this destination resumes — coming back from a detail route, + * and also from the app being foregrounded. + * + * The signal has to be the lifecycle rather than composition identity: a Back + * pressed during the outgoing transition can leave the destination composed, + * and then "was I recreated?" answers a question nobody asked. + */ +@Composable +private fun CalendarResumeSignal(onResume: () -> Unit) { + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val currentOnResume by rememberUpdatedState(onResume) + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) currentOnResume() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + +/** Focus must hold the target this long to count as arrived rather than passing. */ +private const val TvCalendarReturnSettleMillis: Long = 120L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt index bc9e17be5..dc8ff6faf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionDetailScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.ui.focus.FocusRequester +import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme @@ -39,16 +41,32 @@ fun TvCollectionDetailScreen( ), ) { val state by viewModel.uiState.collectAsState() + val gridState = rememberLazyGridState() BackHandler(enabled = true) { onBack() } - val firstItemFocusRequester = remember { FocusRequester() } - var initialFocusRequested by remember { mutableStateOf(false) } - LaunchedEffect(state.items.firstOrNull()?.contentId) { - if (initialFocusRequested || state.items.isEmpty()) return@LaunchedEffect - runCatching { firstItemFocusRequester.requestFocus() } - initialFocusRequested = true - } + val restoreItemFocusRequester = remember { FocusRequester() } + // Returns land on the card the viewer opened, not the top of the + // collection. This also covers first entry, where no target is recorded and + // the resolution is the first item — the same place the plain initial focus + // put it. + // + // Not quite the same lifecycle, though: the old adapter re-armed whenever + // the first item's identity changed, whereas this runs once. Harmless here, + // because loading only appends pages so the first item does not move, but a + // surface that replaces its contents in place would need the difference + // thought about rather than assumed. + val restoration = rememberTvFlatReturnRestoration( + itemIds = state.items.map { it.contentId }, + hasMore = state.hasMore, + isLoadingMore = state.isLoadingMore, + errorMessage = state.error, + surfaceKey = collectionId, + onLoadMore = viewModel::loadMore, + scrollToItem = { itemIndex -> gridState.scrollToItem(itemIndex) }, + requestFocus = restoreItemFocusRequester::requestFocus, + onRestored = {}, + ) Column( modifier = Modifier @@ -74,9 +92,25 @@ fun TvCollectionDetailScreen( items = state.items, isLoading = state.isLoadingMore, hasMore = state.hasMore, - onItemClick = onItemClick, + onItemClick = { contentId -> + restoration.onItemClicked( + itemId = contentId, + index = state.items.indexOfFirst { it.contentId == contentId }, + ) + onItemClick(contentId) + }, onLoadMore = viewModel::loadMore, - firstItemFocusRequester = firstItemFocusRequester, + gridState = gridState, + restoreItemIndex = restoration.requesterItemIndex, + restoreItemFocusRequester = restoreItemFocusRequester, + onRestoreRequesterAttached = restoration::onRequesterAttached, + onItemFocusedAtIndex = { item, index, focused -> + if (focused) { + restoration.onItemFocused(item.contentId, index) + } else { + restoration.onItemFocusLost(item.contentId) + } + }, emptyState = { TvCatalogEmptyState(message = "This collection is empty.") }, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt index 71d7edfd7..43ed7c41e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCollectionsScreen.kt @@ -43,6 +43,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.model.personal.Collection +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.theme.Spacing @@ -62,13 +63,14 @@ fun TvCollectionsScreen( val firstCollectionFocusRequester = remember { FocusRequester() } val firstCollectionId = state.sections.firstNotNullOfOrNull { it.collections.firstOrNull()?.id } - var initialFocusRequested by remember { mutableStateOf(false) } - LaunchedEffect(firstCollectionId) { - if (initialFocusRequested || firstCollectionId == null) return@LaunchedEffect - runCatching { firstCollectionFocusRequester.requestFocus() } - onInitialContentFocus() - initialFocusRequested = true - } + // Latching "attempted" as "acquired" left the grid with no focus owner when + // the first request landed during lazy placement, and told the shell that + // content focus had succeeded anyway. + val contentInitialFocus = rememberTvContentInitialFocus( + target = firstCollectionFocusRequester, + contentKey = firstCollectionId, + onAcquired = onInitialContentFocus, + ) val lifecycleOwner = LocalLifecycleOwner.current var skippedFirstResume by remember { mutableStateOf(false) } @@ -85,6 +87,7 @@ fun TvCollectionsScreen( Column( modifier = Modifier .fillMaxSize() + .then(contentInitialFocus) .background(MaterialTheme.colorScheme.background), ) { Header() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCreateCollectionDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCreateCollectionDialog.kt index 2b76a6962..6dc0b4aff 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCreateCollectionDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/collections/TvCreateCollectionDialog.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.tv.ui.screens.collections import androidx.compose.foundation.background +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,22 +37,19 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Button import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.components.TvHideStockImeOnDispose import org.prairieserver.prairie.tv.ui.components.TvFilterChip import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.tv.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.filled.Add /** - * Dialog for creating a new user collection. Contains a Prairie-owned text field + * Dialog for creating a new user collection. Contains a Silo-owned text field * bound to a local name state, a Manual/Smart type selector (mirroring the * phone's CreateCollectionSheet), and a Create button. The parent owns the * loading / error / selected-type state — we just render and call back. @@ -76,11 +74,18 @@ fun TvCreateCollectionDialog( keyboardController?.show() } } + TvHideStockImeOnDispose() - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + // imePadding below is inert without this: a Dialog gets its own window, + // which by default fits system windows itself and reports no IME inset. + properties = DialogProperties(decorFitsSystemWindows = false), + ) { Box( modifier = Modifier .fillMaxSize() + .imePadding() .background(Color.Black.copy(alpha = 0.85f)), contentAlignment = Alignment.Center, ) { @@ -165,12 +170,6 @@ fun TvCreateCollectionDialog( vertical = 6.dp, ), ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text(text = "Cancel", style = TvCreateCollectionTextStyles.Button) } Button( @@ -181,12 +180,6 @@ fun TvCreateCollectionDialog( vertical = 6.dp, ), ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = if (isCreating) "Creating..." else "Create", style = TvCreateCollectionTextStyles.Button, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt index 447e8aca2..b16187b1e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvAudiobookDetailHero.kt @@ -35,6 +35,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection +import org.prairieserver.prairie.tv.ui.navigation.explicitTvSubtitleLaunchSelection import org.prairieserver.prairie.tv.ui.components.TvPoster import org.prairieserver.prairie.tv.ui.components.TvPrimaryPillButton import org.prairieserver.prairie.tv.ui.components.TvSecondaryPillButton @@ -48,7 +50,7 @@ internal fun TvAudiobookDetailHero( detail: ItemDetail, state: TvItemDetailUiState, playFocus: FocusRequester, - onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, itemType: String?, resumePositionSeconds: Double?) -> Unit, + onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, overview: String?, modifier: Modifier = Modifier, ) { @@ -172,7 +174,8 @@ internal fun TvAudiobookDetailHero( detail.contentId, null, state.selectedAudioIndex, - state.selectedSubtitleIndex, + state.audioPickedThisSession, + explicitTvSubtitleLaunchSelection(state.selectedSubtitleIndex), detail.type, startPosition, ) @@ -186,7 +189,8 @@ internal fun TvAudiobookDetailHero( detail.contentId, null, state.selectedAudioIndex, - state.selectedSubtitleIndex, + state.audioPickedThisSession, + explicitTvSubtitleLaunchSelection(state.selectedSubtitleIndex), detail.type, 0.0, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt index 43c1ea30b..6c653fc8a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -54,8 +55,10 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.CastMember +import org.prairieserver.prairie.tv.ui.theme.TvRailScrollBehavior +import org.prairieserver.prairie.tv.ui.theme.tvRailPinOnFocus import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated -import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults +import org.prairieserver.prairie.tv.ui.theme.siloCardDefaults internal fun restoredRailIndex(lastFocusedIndex: Int, itemCount: Int): Int? = if (itemCount <= 0) null else lastFocusedIndex.coerceIn(0, itemCount - 1) @@ -100,7 +103,11 @@ fun TvCastCrewSection( val photoSize = 100.dp var lastFocusedIndex by rememberSaveable { mutableIntStateOf(-1) } val rememberedEntryRequester = remember { FocusRequester() } - val rememberedEntryIndex = restoredRailIndex(lastFocusedIndex, cast.take(24).size) + val castListState = rememberLazyListState() + // One list per cast snapshot: a fresh take() per composition re-keys the + // LazyRow interval on every focus move. + val visibleCast = remember(cast) { cast.take(24) } + val rememberedEntryIndex = restoredRailIndex(lastFocusedIndex, visibleCast.size) Column( modifier = modifier, @@ -111,7 +118,9 @@ fun TvCastCrewSection( modifier = Modifier.padding(horizontal = horizontalContentPadding), ) + TvRailScrollBehavior { LazyRow( + state = castListState, modifier = Modifier .fillMaxWidth() .focusProperties { @@ -152,7 +161,7 @@ fun TvCastCrewSection( ), ) { itemsIndexed( - cast.take(24), + visibleCast, key = { idx, member -> "${member.personId ?: member.name}-${member.order}-$idx" }, contentType = { _, _ -> "cast-member" }, ) { index, member -> @@ -175,6 +184,7 @@ fun TvCastCrewSection( Modifier }, ) + .tvRailPinOnFocus(castListState, index, horizontalContentPadding) .onFocusChanged { state -> if (state.isFocused) { lastFocusedIndex = index @@ -187,6 +197,7 @@ fun TvCastCrewSection( ) } } + } } } @@ -200,7 +211,7 @@ private fun TvCastCard( onClick: () -> Unit = {}, ) { val shape = CircleShape - val cardFocus = prairieCardDefaults(shape = shape, focusedScale = 1.05f) + val cardFocus = siloCardDefaults(shape = shape, focusedScale = 1.05f) val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt index 8287a87fe..16157ca59 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailEpisodeRail.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -61,6 +62,8 @@ import org.prairieserver.prairie.common.ui.components.ThumbhashImage import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.tv.ui.components.TvMediaCardActions import org.prairieserver.prairie.tv.ui.components.TvMediaCardContextMenu +import org.prairieserver.prairie.tv.ui.theme.TvRailScrollBehavior +import org.prairieserver.prairie.tv.ui.theme.tvRailPinOnFocus import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface import org.prairieserver.prairie.tv.ui.theme.PrairieSecondaryText import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated @@ -118,6 +121,7 @@ internal fun TvDetailEpisodeRail( listState.animateScrollBy(itemCenter - viewportCenter) } + TvRailScrollBehavior { LazyRow( modifier = modifier .fillMaxWidth() @@ -149,11 +153,11 @@ internal fun TvDetailEpisodeRail( ), horizontalArrangement = Arrangement.spacedBy(18.dp), ) { - items( + itemsIndexed( episodes, - key = { it.contentId }, - contentType = { "episode-card" }, - ) { episode -> + key = { _, episode -> episode.contentId }, + contentType = { _, _ -> "episode-card" }, + ) { index, episode -> val isCurrent = episode.contentId == currentContentId TvDetailEpisodeCard( episode = episode, @@ -162,14 +166,19 @@ internal fun TvDetailEpisodeRail( onClick = { onEpisodeSelected(episode) }, onSetWatched = { watched -> onSetWatched(episode.contentId, watched) }, onSetFavorite = { favorite -> onSetFavorite(episode.contentId, favorite) }, - modifier = if (isCurrent) { - Modifier.focusRequester(defaultFocusRequester) - } else { - Modifier - }, + modifier = Modifier + .tvRailPinOnFocus(listState, index, Spacing.safeArea) + .then( + if (isCurrent) { + Modifier.focusRequester(defaultFocusRequester) + } else { + Modifier + }, + ), ) } } + } } @Composable diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt index f84da8a3f..f47e30ee0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -62,14 +63,13 @@ internal sealed class TvHeroFactToken { } /** - * Full-bleed cinematic hero for the Android TV detail screen. Mirrors the - * tvOS `TVDetailHero` 1:1. + * Full-bleed cinematic hero for the Android TV detail screen. Structurally + * mirrors tvOS `TVDetailHero`. * * Layout = a `ZStack(bottomLeading)`: a near-full-viewport backdrop, a * 4-stop horizontal darkening on the left, a soft vertical fade into the * rail body at the bottom, then the bottom-anchored editorial + action - * column on the left. The "Starring …" credit floats upper-right as its own - * trailing overlay (tvOS `starringOverlay`), NOT inside the editorial column. + * column on the left. * * Apple sizes the hero relative to the viewport (`heroHeight = 980` of a * 1080-pt canvas ≈ 0.907×), so we compute the height as a fraction of the @@ -92,12 +92,20 @@ internal fun TvDetailHero( overview: String?, tagline: String?, factsLine: List, - starringText: String?, + directorText: String?, actions: @Composable () -> Unit, modifier: Modifier = Modifier, // Optional description-translation affordance (Apple tvOS parity), // rendered as its own focus stop directly under the synopsis. translation: (@Composable () -> Unit)? = null, + /** + * How far (px) the page has scrolled past the hero's top. Read inside the + * draw phase only, so scrolling never recomposes the hero. The backdrop + * recedes with it — dims toward the page background and drifts at a + * fraction of the scroll — so moving into the body reads as the hero + * giving way rather than being chopped off by the next section. + */ + scrollOffsetPx: () -> Float = { 0f }, ) { // heroHeight = 980 of a 1080-pt tvOS canvas ≈ 0.907 × viewport height. // The TV theme keeps dp geometry at device density, so screenHeightDp maps @@ -121,7 +129,15 @@ internal fun TvDetailHero( thumbhash = backdropThumbhash, contentDescription = title, contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize(), + modifier = Modifier + .matchParentSize() + .graphicsLayer { + val offset = scrollOffsetPx().coerceAtLeast(0f) + val fadeDistance = size.height * BackdropFadeHeightFraction + val progress = if (fadeDistance > 0f) (offset / fadeDistance).coerceIn(0f, 1f) else 0f + alpha = 1f - progress * (1f - BackdropMinAlpha) + translationY = minOf(offset, size.height) * BackdropParallaxFactor + }, ) } else { Box(modifier = Modifier.matchParentSize().background(DarkSurface)) @@ -157,36 +173,6 @@ internal fun TvDetailHero( ), ) - // "Starring …" floats in the upper-right of the hero, right-aligned — - // tvOS `.overlay(alignment: .trailing)` + `.padding(.bottom, heroHeight - // * 0.45)` (the bottom padding on the vertically-centered overlay - // pushes the credit into the top-right region). 2-line limit; sized up - // from the raw tvOS ~2x mapping (24pt → 12sp) per design review, with - // maxWidth widened to match so casts don't ellipsize sooner. - starringText?.takeIf { it.isNotBlank() }?.let { line -> - Text( - text = line, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 20.sp, - color = Color.White.copy(alpha = 0.8f), - textAlign = TextAlign.End, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TextStyle( - shadow = Shadow( - color = Color.Black.copy(alpha = 0.55f), - offset = Offset(0f, 2f), - blurRadius = 6f, - ), - ), - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = Spacing.safeArea, bottom = heroHeight * 0.45f) - .widthIn(max = 280.dp), - ) - } - // Reserve and pin the action cluster before measuring the editorial // column. The hero can therefore keep the fixed tvOS viewport framing // without letting tall copy collapse visible-but-focusable controls. @@ -204,6 +190,7 @@ internal fun TvDetailHero( overview = overview, tagline = tagline, factsLine = factsLine, + directorText = directorText, contentMaxWidth = contentMaxWidth, verticalSpacing = editorialSpacing, collapsedSynopsisLines = collapsedSynopsisLines, @@ -227,6 +214,12 @@ internal fun TvDetailHero( } } +/** Scrolling ~40% of the hero height fades the backdrop to [BackdropMinAlpha]. */ +private const val BackdropFadeHeightFraction = 0.4f +private const val BackdropMinAlpha = 0.35f +/** Backdrop drifts down at this fraction of the scroll — a light parallax. */ +private const val BackdropParallaxFactor = 0.4f + @Composable private fun EditorialColumn( title: String, @@ -237,6 +230,7 @@ private fun EditorialColumn( overview: String?, tagline: String?, factsLine: List, + directorText: String?, contentMaxWidth: androidx.compose.ui.unit.Dp, verticalSpacing: androidx.compose.ui.unit.Dp, collapsedSynopsisLines: Int, @@ -269,6 +263,20 @@ private fun EditorialColumn( } translation?.invoke() + // Quiet "Directed by …" credit between the synopsis and the facts row. + // 14sp = the ten-foot metadata floor; the 0.62 alpha keeps it reading + // as a credit rather than another synopsis line. + directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (factsLine.isNotEmpty()) { FactsRow(tokens = factsLine) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt index 2febb2743..37d340701 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt @@ -77,12 +77,6 @@ internal object TvDetailMetadata { return tokens } - fun starringText(detail: ItemDetail): String? { - val names = detail.cast.take(3).map { it.name.trim() }.filter { it.isNotEmpty() } - if (names.isEmpty()) return null - return "Starring ${names.joinToString(", ")}" - } - private fun typeLabel(detail: ItemDetail): String = when { isAudiobookItemType(detail.type) -> "Audiobook" else -> when (detail.type.lowercase()) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt index 5cd26424a..8f9efffa9 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt @@ -2,13 +2,18 @@ package org.prairieserver.prairie.tv.ui.screens.detail import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.BringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -23,52 +28,45 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.BringIntoViewSpec -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.layout.positionInRoot -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalContext import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BookmarkAdded import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Tv import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key @@ -76,6 +74,11 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -88,31 +91,48 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf import org.prairieserver.prairie.audiobook.AudioPlaybackTrack import org.prairieserver.prairie.audiobook.AudiobookTimeline import org.prairieserver.prairie.audiobook.buildAudiobookTimeline +import org.prairieserver.prairie.common.ui.movieDirectorCredit +import org.prairieserver.prairie.metadata.DescriptionTranslationPhase import org.prairieserver.prairie.model.audiobook.AudiobookNarration import org.prairieserver.prairie.model.catalog.EpisodeListItem import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.catalog.isAudiobookItemType +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay import org.prairieserver.prairie.model.ebook.MediaRelatedItem import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.metadata.MetadataAiOnView import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection +import org.prairieserver.prairie.tv.ui.navigation.explicitTvSubtitleLaunchSelection import org.prairieserver.prairie.tv.ui.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.TvMediaRow import org.prairieserver.prairie.tv.ui.components.TvOptionDialog +import org.prairieserver.prairie.tv.ui.components.TvPillVariant import org.prairieserver.prairie.tv.ui.components.TvPrimaryPillButton +import org.prairieserver.prairie.tv.ui.components.TvRowStyle import org.prairieserver.prairie.tv.ui.components.TvSecondaryPillButton import org.prairieserver.prairie.tv.ui.components.TvSquareToggleButton -import org.prairieserver.prairie.tv.ui.components.TvPillVariant -import org.prairieserver.prairie.tv.ui.components.TvRowStyle +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.screens.audiobook.formatAudiobookTime import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvSuggestToRoomViewModel @@ -121,21 +141,12 @@ import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherView import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.TvControlCorner import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec -import kotlin.math.roundToInt -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.koin.compose.viewmodel.koinViewModel -import org.koin.compose.koinInject -import org.prairieserver.prairie.metadata.DescriptionTranslationPhase -import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore -import org.prairieserver.prairie.model.metadata.MetadataAiOnView -import org.koin.core.parameter.parametersOf @Composable fun TvItemDetailScreen( contentId: String, seasonNumber: Int? = null, - onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, itemType: String?, resumePositionSeconds: Double?) -> Unit, + onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onItemDetail: (contentId: String) -> Unit, onItemDetailReplace: (contentId: String) -> Unit = onItemDetail, onSeriesClick: (seriesId: String) -> Unit, @@ -209,7 +220,7 @@ private fun TvDetailContent( detail: ItemDetail, state: TvItemDetailUiState, viewModel: TvItemDetailViewModel, - onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, itemType: String?, resumePositionSeconds: Double?) -> Unit, + onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onItemDetail: (contentId: String) -> Unit, onItemDetailReplace: (contentId: String) -> Unit, onSeriesClick: (seriesId: String) -> Unit, @@ -233,6 +244,35 @@ private fun TvDetailContent( // restore loop exits on it instead of re-requesting for a fixed window, // which held focus hostage on that card for ~a second after returning. val castRestoreFocused = remember { mutableStateOf(false) } + // Same treatment for More Like This. Returning from a related item only + // became reachable once item-detail navigation stopped reusing this entry; + // before that you never came back to this page, so the generic + // snap-to-hero below was the only outcome that existed. + // + // Stores the CONTENT ID, not the list index. After process death the saved + // index could outlive the list it indexed: the rail reloads over the + // network, so the index can arrive before the list does, and the reloaded + // list can come back in a different order — restoring focus to whichever + // title now happens to sit at that position. + var pendingSimilarContentId by rememberSaveable(detail.contentId) { + mutableStateOf(null) + } + // Paired with the id because the id alone is an ABA token: a newer request + // for the SAME content passes every ownership check the old coroutine + // makes, letting it clear or override the new one. + var pendingSimilarGeneration by rememberSaveable(detail.contentId) { mutableStateOf(0) } + val similarReturnFocus = remember { FocusRequester() } + val similarRestoreFocused = remember { mutableStateOf(false) } + // Bumped once the target resolves, so the row scrolls its own LazyRow to + // that card: a card outside the composed window leaves the requester + // unattached and every retry doomed. + var similarRestoreRequest by remember { mutableStateOf(0) } + // Resolved against the CURRENT list, so it simply stays -1 until the rail + // has loaded and becomes correct if the order changed. + val pendingSimilarIndex = pendingSimilarContentId?.let { pendingId -> + state.moreLikeThis.indexOfFirst { it.contentId == pendingId } + } ?: -1 + val pendingSimilarIndexNow = rememberUpdatedState(pendingSimilarIndex) val firstSimilarFocus = remember { FocusRequester() } val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -256,21 +296,105 @@ private fun TvDetailContent( // value alone: keep the pending window open (the rail's enter // targets the launch card while it is) across the pop transition, // re-requesting every couple of frames. - var restored = false - for (attempt in 0 until 40) { - if (castRestoreFocused.value) { - restored = true - break - } - restored = runCatching { castReturnFocus.requestFocus() }.getOrDefault(false) || restored - withFrameNanos { } - withFrameNanos { } - } + // Success comes ONLY from the rail's focus callback. Accumulating + // requestFocus()'s return value defeated the very rollback this + // loop exists to survive: one transient true skipped the Play + // fallback even though focus had bounced back off the redirect. + // This loop was already judging on observed focus, which is why it + // worked; it just open-coded the pacing. The policy does the same + // thing, and the two-frame cadence is preserved. + var restored = requestFocusUntilObserved( + maxAttempts = CAST_RESTORE_MAX_ATTEMPTS, + awaitAttempt = { + withFrameNanos { } + withFrameNanos { } + }, + requestFocus = castReturnFocus::requestFocus, + isFocused = { castRestoreFocused.value }, + ) == TvObservedFocusResult.Focused + // The last attempt's request can land after the loop's final check, + // so re-read before giving up — otherwise Play immediately steals + // focus from a restore that actually succeeded. + if (!restored) restored = castRestoreFocused.value pendingCastFocusIndex = -1 - if (restored) return@LaunchedEffect + if (restored) { + // Don't leave the other rail's requester armed. + pendingSimilarContentId = null + return@LaunchedEffect + } } + // A pending More Like This restore is owned by the effect below, which + // can outlive this one while it waits for the rail to load. It performs + // the hero fallback itself if the target never turns up. + if (pendingSimilarContentId != null) return@LaunchedEffect listState.scrollToItem(0) - runCatching { playFocus.requestFocus() } + playFocus.claimFocusOrReport(target = "detail_play", action = "entry_fallback") + } + + // Returning from a related item: land back on the card that opened it + // rather than snapping to the hero. + // + // Keyed on the CONTENT ID alone, deliberately. Keying it on the pending id + // as well fired this on the way OUT — the moment the click recorded it — + // so it spun its whole window against a page being navigated away from, + // cleared the pending id, and left nothing to restore on the way back. + // Content id only means it runs once per entry to this page, which is + // exactly when a restore is due. + LaunchedEffect(detail.contentId) { + // The exact request this coroutine owns. Every step below re-checks it, + // because clearing or replacing the pending id does NOT cancel this + // coroutine — without the token it could keep requesting focus for the + // rest of its window on behalf of a return nobody is waiting for. + val ownedContentId = pendingSimilarContentId ?: return@LaunchedEffect + val ownedGeneration = pendingSimilarGeneration + fun stillOwned() = + pendingSimilarContentId == ownedContentId && + pendingSimilarGeneration == ownedGeneration + + // Two different waits, on two different clocks. + // + // First the DATA. After process death the rail reloads over the network + // — debounced, then several requests — so the target may not exist yet. + // Counting frames for that was measuring the wrong thing entirely: a + // ~120-frame budget is one or two seconds depending on refresh rate, + // and a load finishing just past it silently became a hero fallback. + val result = restoreMoreLikeThisFocus( + awaitTarget = { + snapshotFlow { pendingSimilarIndexNow.value }.first { it >= 0 } + }, + stillOwned = ::stillOwned, + // Once the target exists, ask the row to scroll it into its composed + // window before focus requests begin. + onTargetResolved = { similarRestoreRequest += 1 }, + isTargetFocused = { similarRestoreFocused.value }, + // The return value is not evidence: the row's enter redirect can + // roll an accepted request back. Only its focus callback counts. + requestTargetFocus = { + similarReturnFocus.claimFocusOrReport( + target = "detail_similar_card", + action = "return_restore", + ) + }, + awaitFocusAttempt = { + withFrameNanos { } + withFrameNanos { } + }, + // Never turned up, or focus kept rolling back — leave the viewer + // somewhere usable. The policy holds ownership across this + // suspension and re-checks it before requesting Play focus. + scrollToFallback = { listState.scrollToItem(0) }, + requestFallbackFocus = { + playFocus.claimFocusOrReport( + target = "detail_play", + action = "similar_restore_fallback", + ) + }, + dataTimeoutMillis = RESTORE_DATA_TIMEOUT_MS, + attachmentTimeoutMillis = RESTORE_ATTACH_TIMEOUT_MS, + ) + if (result != TvSimilarFocusRestoreResult.Revoked && stillOwned()) { + pendingSimilarContentId = null + } } val isEpisodicType = detail.type in setOf("series", "season", "episode") @@ -335,8 +459,18 @@ private fun TvDetailContent( // instead of appearing only after it settles; when the hero has // been disposed off-screen the requests fail and we re-focus // after the scroll composes it again. - val focusedImmediately = runCatching { selectorFocus.requestFocus() }.isSuccess || - runCatching { playFocus.requestFocus() }.isSuccess + // runCatching{}.isSuccess was true whenever the call did not THROW, + // so a requestFocus that returned false still counted as focused — + // the scroll then ran as though the highlight had already moved. + // These report the request's own answer. + val focusedImmediately = + selectorFocus.claimFocusOrReport( + target = "detail_selector", + action = "return_to_top", + ) || playFocus.claimFocusOrReport( + target = "detail_play", + action = "return_to_top", + ) if (focusedImmediately) { // Let the focus system enqueue its automatic bring-into-view // first, then cancel/replace that scroll with the paced @@ -345,8 +479,15 @@ private fun TvDetailContent( } listState.animateScrollToItemPaced(0) if (!focusedImmediately) { - if (runCatching { selectorFocus.requestFocus() }.isFailure) { - runCatching { playFocus.requestFocus() } + if (!selectorFocus.claimFocusOrReport( + target = "detail_selector", + action = "return_to_top_retry", + ) + ) { + playFocus.claimFocusOrReport( + target = "detail_play", + action = "return_to_top_retry", + ) } } } @@ -389,8 +530,7 @@ private fun TvDetailContent( val heroHasFocus = remember { mutableStateOf(false) } val detailBringIntoViewSpec = remember(heroHasFocus) { object : BringIntoViewSpec { - override val scrollAnimationSpec: AnimationSpec = - TvSmoothBringIntoViewSpec.scrollAnimationSpec + override val scrollAnimationSpec: AnimationSpec = DetailAnchorScrollSpec override fun calculateScrollDistance( offset: Float, @@ -407,6 +547,21 @@ private fun TvDetailContent( Box( modifier = Modifier .fillMaxSize() + // A pending restore is a convenience, and the moment the viewer + // steers for themselves it stops being one. This is the only signal + // that a move was genuinely user-initiated — a focus-gain callback + // is not, because the rail's own enter redirect produces one. + // Returns false throughout: this observes, it never consumes. + .onPreviewKeyEvent { event -> + if ( + pendingSimilarContentId != null && + event.type == KeyEventType.KeyDown && + event.key in tvDirectionalKeys + ) { + pendingSimilarContentId = null + } + false + } .background(MaterialTheme.colorScheme.background), ) { CompositionLocalProvider(LocalBringIntoViewSpec provides detailBringIntoViewSpec) { @@ -431,6 +586,13 @@ private fun TvDetailContent( ) } else { TvDetailHero( + scrollOffsetPx = { + if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + Float.MAX_VALUE + } + }, title = detail.title, seriesTitle = if (detail.type == "episode") detail.seriesTitle else null, logoUrl = detail.logoUrl, @@ -445,7 +607,7 @@ private fun TvDetailContent( preferredQuality = state.preferredQuality, selectedFileId = heroSelectedFileId, ), - starringText = TvDetailMetadata.starringText(detail), + directorText = movieDirectorCredit(detail), translation = translationSlot, actions = { HeroActionRow( @@ -488,7 +650,8 @@ private fun TvDetailContent( detail.contentId, null, state.selectedAudioIndex, - state.selectedSubtitleIndex, + state.audioPickedThisSession, + explicitTvSubtitleLaunchSelection(state.selectedSubtitleIndex), detail.type, track.startOffsetSeconds, ) @@ -558,35 +721,10 @@ private fun TvDetailContent( // with `anchor: .center`), so focusing "Season N" // sits where an episode focus sits, and coming back // up from Cast & Crew restores the same position. - var episodesSectionHasFocus by remember { mutableStateOf(false) } - var episodesSectionCenterY by remember { mutableStateOf(null) } Box( - modifier = Modifier - .onGloballyPositioned { coords -> - episodesSectionCenterY = - coords.positionInRoot().y + coords.size.height / 2f - } - .onFocusChanged { focusState -> - val nowFocused = focusState.hasFocus - if (nowFocused && !episodesSectionHasFocus) { - coroutineScope.launch { - // Let the focus system enqueue its - // automatic bring-into-view first, - // then cancel/replace that scroll - // with the centered section anchor. - withFrameNanos { } - if (!episodesSectionHasFocus) return@launch - val center = episodesSectionCenterY ?: return@launch - val viewportCenter = - listState.layoutInfo.viewportSize.height / 2f - listState.animateScrollBy( - value = center - viewportCenter, - animationSpec = DetailAnchorScrollSpec, - ) - } - } - episodesSectionHasFocus = nowFocused - }, + modifier = Modifier.detailSectionAnchor(listState, coroutineScope) { height, viewport -> + (viewport - height) / 2f + }, ) { EpisodesSection( detail = detail, @@ -619,6 +757,7 @@ private fun TvDetailContent( } if (showsCastSection) { + Box(modifier = Modifier.detailBodySectionAnchor(listState, coroutineScope)) { TvCastCrewSection( cast = detail.cast, horizontalContentPadding = Spacing.safeArea, @@ -639,18 +778,25 @@ private fun TvDetailContent( // actually fires — openPerson can no-op when // the person can't be resolved. viewModel.openPerson(member) { personId -> + pendingSimilarContentId = null pendingCastFocusIndex = index castRestoreFocused.value = false onOpenPerson(personId) } }, ) + } } if (showsDetailsSection) { DetailsSection( detail = detail, - modifier = Modifier.padding(horizontal = Spacing.safeArea), + modifier = Modifier + .detailBodySectionAnchor(listState, coroutineScope) + // The section pads its own inner inset so the + // focus highlight box extends past the text + // instead of starting flush at its left edge. + .padding(horizontal = Spacing.safeArea - TvDetailsFocusInset), ) } @@ -658,7 +804,10 @@ private fun TvDetailContent( // tvOS `TVSimilarRail`: an editorial detail section // header (Recommended / More Like This) over a bare // poster rail — no See-all on the detail page. - Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + Column( + modifier = Modifier.detailBodySectionAnchor(listState, coroutineScope), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { TvDetailSectionHeader( title = "More Like This", modifier = Modifier.padding(horizontal = Spacing.safeArea), @@ -667,7 +816,44 @@ private fun TvDetailContent( title = "More Like This", showHeader = false, items = state.moreLikeThis, - onItemClick = onItemDetail, + onItemClick = { clickedContentId -> + pendingCastFocusIndex = -1 + pendingSimilarGeneration += 1 + pendingSimilarContentId = clickedContentId + similarRestoreFocused.value = false + onItemDetail(clickedContentId) + }, + restoreFocusIndex = pendingSimilarIndex, + // Only while a return is pending, so ordinary + // re-entry stops being forced at the return + // target and goes back to the row restorer's + // own remembered card. + restoreFocusRequester = similarReturnFocus + .takeIf { pendingSimilarIndex >= 0 }, + restoreFocusRequest = similarRestoreRequest + .takeIf { pendingSimilarIndex >= 0 } ?: 0, + onItemFocusedAtIndex = if (pendingSimilarIndex >= 0) { + { focusedItem, _ -> + // ONLY the target counts. Revoking + // when some other card gains focus + // was self-defeating: the row's own + // enter redirect lands on card 0 + // first, so the restore cancelled + // itself on the way to card N. + // onFocusChanged carries no evidence + // that a move was user-initiated — + // the key handler on the root does. + // Identity, not position: index + // arithmetic is what broke when the + // row learned to deduplicate, and + // the item is right here anyway. + if (focusedItem.contentId == pendingSimilarContentId) { + similarRestoreFocused.value = true + } + } + } else { + null + }, style = TvRowStyle.Poster, horizontalPadding = Spacing.safeArea, rowTopPadding = 0.dp, @@ -714,7 +900,8 @@ private fun TvDetailContent( detail.contentId, null, state.selectedAudioIndex, - state.selectedSubtitleIndex, + state.audioPickedThisSession, + explicitTvSubtitleLaunchSelection(state.selectedSubtitleIndex), detail.type, chapter.startSeconds, ) @@ -735,7 +922,7 @@ private fun HeroActionRow( viewModel: TvItemDetailViewModel, playFocus: FocusRequester, selectorFocus: FocusRequester, - onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, subtitleTrackIndex: Int?, itemType: String?, resumePositionSeconds: Double?) -> Unit, + onPlay: (contentId: String, fileId: Int?, audioTrackIndex: Int?, audioPickedThisSession: Boolean, subtitleSelection: TvSubtitleLaunchSelection?, itemType: String?, resumePositionSeconds: Double?) -> Unit, onSeriesClick: (seriesId: String) -> Unit, onSeasonClick: (seriesId: String, seasonNumber: Int) -> Unit, onWatchTogether: (RoomSnapshot) -> Unit, @@ -766,7 +953,7 @@ private fun HeroActionRow( } } // Series / season detail target the *next-up episode* rather than the - // container itself (mirrors prairie-apple's TVSeriesDetailView / + // container itself (mirrors silo-apple's TVSeriesDetailView / // TVSeasonDetailView). For those types the hero Play button, the resume // position, and the inline selector row all bind to the next-up episode's // own playback detail; movie / episode detail keep the container behavior. @@ -794,7 +981,8 @@ private fun HeroActionRow( val hasWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && !isAudiobookItemType(detail.type) val hasSuggestionTarget = detail.type in setOf("movie", "episode") || nextUp != null - val canSuggestToRoom = activeRoom != null && hasSuggestionTarget + val canSuggestToRoom = + CLIENT_WATCH_TOGETHER_SURFACE_ENABLED && activeRoom != null && hasSuggestionTarget val hasOverflowMenu = hasOverflowNavigation || hasWatchTogether || canSuggestToRoom // Version set + selection state driving the selector row / Play file id. @@ -810,6 +998,10 @@ private fun HeroActionRow( // version, keeping Play and the UI in agreement. ?.takeIf { fileId -> selectorVersions.any { it.fileId == fileId } } val selectorAudioIndex = if (isSeriesOrSeason) state.selectedNextUpAudioIndex else state.selectedAudioIndex + // Provenance has to follow the same branch as the ordinal: a fresh next-up + // pick was otherwise reported using the unrelated container-level flag. + val selectorAudioPicked = + if (isSeriesOrSeason) state.nextUpAudioPickedThisSession else state.audioPickedThisSession val selectorSubtitleIndex = if (isSeriesOrSeason) state.selectedNextUpSubtitleIndex else state.selectedSubtitleIndex val selectorLastFileId = if (isSeriesOrSeason) { @@ -832,6 +1024,27 @@ private fun HeroActionRow( } val selectedFileId = selectedVersion?.fileId val hasTrackOverride = selectorAudioIndex != null || selectorSubtitleIndex != null + // Exactly what the Subtitles pill is displaying — including the Auto + // preview — so playback starts on that track instead of re-deciding from + // the tracks Media3 happens to have mounted. Built from the SAME version + // and the SAME context the pill renders from. + val subtitleLaunchSelection = TvPlaybackFormatting.subtitleLaunchSelection( + version = selectedVersion, + selectedSubtitleTrackIndex = selectorSubtitleIndex, + // No displayed version means no displayed pill: stay silent and let the + // player resolve, rather than asserting an "Auto - None" nobody saw. + autoContext = selectedVersion?.let { version -> + TvPlaybackFormatting.SubtitleAutoContext( + preferredLanguage = state.preferredSubtitleLanguage, + mode = state.subtitleMode, + showForced = state.showForcedSubtitles, + audioLanguage = TvPlaybackFormatting.resolvedAudioLanguage( + version, + selectorAudioIndex, + ), + ) + }, + ) val playFileId = selectorSelectedFileId ?: selectedFileId.takeIf { hasTrackOverride } // The effective playable version drives the inline playback selector row. val isAudiobook = isAudiobookItemType(detail.type) @@ -885,7 +1098,7 @@ private fun HeroActionRow( playLaunchPending = true onPlay( playContentId, playFileId, - selectorAudioIndex, selectorSubtitleIndex, + selectorAudioIndex, selectorAudioPicked, subtitleLaunchSelection, playType, resumePosition, ) } @@ -904,7 +1117,7 @@ private fun HeroActionRow( playLaunchPending = true onPlay( playContentId, playFileId, - selectorAudioIndex, selectorSubtitleIndex, + selectorAudioIndex, selectorAudioPicked, subtitleLaunchSelection, playType, 0.0, ) } @@ -1086,13 +1299,7 @@ private fun HeroActionRow( isBusy = watchTogetherState.isBusy, error = watchTogetherState.error, onHost = { watchTogetherViewModel.createRoom(playContentId, playFileId) }, - onHostVote = { - watchTogetherViewModel.createRoom( - playContentId, - playFileId, - RoomSelectionMode.Vote, - ) - }, + onHostVote = watchTogetherViewModel::createEmptyVoteRoom, onJoin = { watchTogetherViewModel.clearError() joinCodeOpen = true @@ -1223,7 +1430,7 @@ private fun EpisodesSection( selectedSeason = state.selectedSeason, onSeasonSelected = onSeasonSelected, onDirectionUp = onReturnToHero, - modifier = Modifier.padding(horizontal = Spacing.safeArea), + horizontalContentPadding = Spacing.safeArea, ) } @@ -1290,10 +1497,15 @@ private fun currentEpisodeRailContentId(detail: ItemDetail, state: TvItemDetailU else -> null } -private fun episodeEyebrowLabel(detail: ItemDetail, state: TvItemDetailUiState): String { - state.selectedSeason?.takeIf { it > 0 }?.let { return "Season $it" } +internal fun episodeEyebrowLabel(detail: ItemDetail, state: TvItemDetailUiState): String { + state.seasons + .firstOrNull { it.seasonNumber == state.selectedSeason } + ?.let { season -> + return if (season.isSpecialsForDisplay()) "Specials" else "Season ${season.seasonNumber}" + } + state.selectedSeason?.let { return if (it == 0) "Specials" else "Season $it" } if (detail.type == "episode") { - detail.seasonNumber?.takeIf { it > 0 }?.let { return "Season $it" } + detail.seasonNumber?.let { return if (it == 0) "Specials" else "Season $it" } } return "This Season" } @@ -1301,7 +1513,7 @@ private fun episodeEyebrowLabel(detail: ItemDetail, state: TvItemDetailUiState): /** * Static, non-focusable placeholder shown in the selector slot while the * next-up episode's playback detail loads. Compose-for-TV analogue of - * prairie-apple's `TVVersionPillPlaceholder` — a dimmed "Version" pill. + * silo-apple's `TVVersionPillPlaceholder` — a dimmed "Version" pill. */ @Composable private fun TvVersionPillPlaceholder(modifier: Modifier = Modifier) { @@ -1357,7 +1569,8 @@ private fun DetailsSection( .background( color = if (factsFocused) Color.White.copy(alpha = 0.06f) else Color.Transparent, shape = RoundedCornerShape(18.dp), - ), + ) + .padding(horizontal = TvDetailsFocusInset, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(28.dp), ) { TvDetailSectionHeader(title = "Details") @@ -1365,6 +1578,13 @@ private fun DetailsSection( } } +/** + * Inner inset between the Details focus-highlight box and its text; the + * caller subtracts it from the safe-area padding so the text stays aligned + * with the other sections while the box breathes around it. + */ +private val TvDetailsFocusInset = 20.dp + @Composable private fun TvAudiobookPartsSection( tracks: List, @@ -1679,7 +1899,7 @@ private fun org.prairieserver.prairie.model.catalog.LeafItemUserData.resumePosit * Hero Play button label. Movie / episode detail keep the plain Play / * Resume form; series / season detail target the next-up episode and read * "Play S2 · E3" / "Resume S2 · E3" (series) or "Play E4" / "Resume E4" - * (season), mirroring prairie-apple's `playButtonLabel(for:)`. + * (season), mirroring silo-apple's `playButtonLabel(for:)`. */ private fun playButtonLabel( isSeriesOrSeason: Boolean, @@ -1756,11 +1976,66 @@ internal fun resolveTvDetailHeroArtwork( } /** - * Pacing for the hero ↔ episodes anchor scrolls — tvOS's detail focus - * choreography runs `easeInOut(0.45)` (`TVDetailFocusScroll.swift`); 260ms - * tuned on-device per design review. + * The ONE motion spec for every scroll on the detail page — section anchors, + * return-to-hero, and the fallback bring-into-view. tvOS's detail focus + * choreography runs `easeInOut(0.45)` (`TVDetailFocusScroll.swift`); here a + * slightly quicker fast-out/slow-in reads calmer over long distances than + * ease-in-out (which lurches mid-flight) and the page no longer mixes a + * 260ms anchor with a 620ms rail reveal. */ -private val DetailAnchorScrollSpec = tween(durationMillis = 260, easing = EaseInOut) +private val DetailAnchorScrollSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) + +/** + * Where a body section's top edge lands when focus enters it, as a fraction + * of the viewport height. Anchoring the SECTION (not the focused card) means + * Cast, Details and More Like This all frame identically, so each Down is a + * uniform section-sized step instead of a gutter nudge of a different size. + */ +private const val DetailSectionAnchorFraction = 0.18f + +/** + * Anchors the section to a fixed viewport line the moment focus ENTERS it + * (moving within the section does nothing). [targetY] receives the section's + * height and the viewport height and returns the y (in viewport px) its top + * should sit at. The generalisation of the episodes-section centering, so + * every body section shares one choreography. + */ +private fun Modifier.detailSectionAnchor( + listState: LazyListState, + scope: CoroutineScope, + targetY: (sectionHeight: Float, viewportHeight: Float) -> Float, +): Modifier = composed { + var hasFocus by remember { mutableStateOf(false) } + var topInRoot by remember { mutableStateOf(null) } + var height by remember { mutableStateOf(0f) } + this + .onGloballyPositioned { coords -> + topInRoot = coords.positionInRoot().y + height = coords.size.height.toFloat() + } + .onFocusChanged { focusState -> + val nowFocused = focusState.hasFocus + if (nowFocused && !hasFocus) { + scope.launch { + // Let the focus system enqueue its automatic bring-into-view + // first, then cancel/replace that scroll with the anchor. + withFrameNanos { } + if (!hasFocus) return@launch + val top = topInRoot ?: return@launch + val viewport = listState.layoutInfo.viewportSize.height.toFloat() + listState.animateScrollBy( + value = top - targetY(height, viewport), + animationSpec = DetailAnchorScrollSpec, + ) + } + } + hasFocus = nowFocused + } +} + +/** Section-top anchor shared by Cast, Details and More Like This. */ +private fun Modifier.detailBodySectionAnchor(listState: LazyListState, scope: CoroutineScope): Modifier = + detailSectionAnchor(listState, scope) { _, viewport -> viewport * DetailSectionAnchorFraction } /** * Paced anchor scroll used for the return-to-hero jump. @@ -1780,3 +2055,41 @@ private suspend fun LazyListState.animateScrollToItemPaced(index: Int) { animateScrollToItem(index) } } + +/** + * Wall-clock budget for the More Like This rail to load a restore target. + * + * Deliberately short. Landing back on the card you came from is a nicety, and + * one that stops being welcome the moment the user has started doing something + * else — a restore that fires seconds later reads as the app yanking focus, not + * as helpfulness. Past this the ordinary hero fallback runs instead. + * + * Bounds the DATA wait only. Once the target resolves, focus attachment gets a + * further ~80 frames, so the whole restore can outlast this value. + */ +/** + * Frames the cast return-restore will keep trying for. Was an open-coded + * `for (attempt in 0 until 40)`; the number is preserved so the window is the + * same length it has always been. + */ +private const val CAST_RESTORE_MAX_ATTEMPTS = 40 + +private const val RESTORE_DATA_TIMEOUT_MS = 1_500L + +/** + * Wall-clock ceiling on the focus-attachment retries. + * + * The retry budget is a frame count because attachment is a composition + * concern, but a frame count is not a duration — at 24Hz eighty frames is over + * three seconds, and with frame production paused it is unbounded. This caps + * how long the viewer can be fighting a restore for. + */ +private const val RESTORE_ATTACH_TIMEOUT_MS = 2_000L + +/** Direction keys that count as the viewer steering for themselves. */ +private val tvDirectionalKeys = setOf( + Key.DirectionUp, + Key.DirectionDown, + Key.DirectionLeft, + Key.DirectionRight, +) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt index 706200a41..72eed29be 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt @@ -2,7 +2,15 @@ package org.prairieserver.prairie.tv.ui.screens.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode +import org.prairieserver.prairie.common.player.video.captureEpisodeSourceIntent +import org.prairieserver.prairie.common.player.video.captureEpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.resolveEpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.resolveEpisodeSourceIntent import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.settings.ProfileSettingsController import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.catalog.CastMember import org.prairieserver.prairie.model.catalog.EpisodeListItem @@ -11,8 +19,9 @@ import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.LeafItemUserData import org.prairieserver.prairie.model.catalog.Season import org.prairieserver.prairie.model.catalog.isAudiobookItemType -import org.prairieserver.prairie.model.catalog.sortedForDisplay +import org.prairieserver.prairie.model.catalog.initialSeasonDisplayPlan import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes +import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices import org.prairieserver.prairie.playback.SUBTITLE_OFF_FINGERPRINT import org.prairieserver.prairie.playback.audioTrackFingerprint import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal @@ -20,6 +29,9 @@ import org.prairieserver.prairie.playback.resolveSubtitleTrackOrdinal import org.prairieserver.prairie.playback.subtitleTrackFingerprint import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionPhase +import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.ProfileRepository @@ -39,6 +51,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit data class TvItemDetailUiState( val isLoading: Boolean = true, @@ -66,6 +80,15 @@ data class TvItemDetailUiState( // index -1 means "Off". Reset whenever the version changes since each file // has its own track lists. val selectedAudioIndex: Int? = null, + /** + * True only when the viewer picked the audio here, this session. + * + * A restored durable value and a fresh pick both land in + * [selectedAudioIndex], and the player cannot tell them apart from the + * ordinal alone — so a restore would masquerade as a new decision and pin + * itself onto every later episode. + */ + val audioPickedThisSession: Boolean = false, val selectedSubtitleIndex: Int? = null, // Catalog-backed related shelf. This is a same-type / same-primary-genre // browse query until the server exposes an item-specific related endpoint. @@ -73,7 +96,7 @@ data class TvItemDetailUiState( val moreLikeThisLoading: Boolean = false, // --- Next-up episode (series / season detail only) --- // The episode the hero Play button targets: an in-progress episode if one - // exists, else the first unwatched, else the first. Mirrors prairie-apple's + // exists, else the first unwatched, else the first. Mirrors silo-apple's // `nextUpEpisode`. val nextUpEpisode: EpisodeListItem? = null, // The next-up episode's loaded playback detail (versions / tracks). Loaded @@ -86,14 +109,17 @@ data class TvItemDetailUiState( // selectedFileId/audio/subtitle, which series/season detail does not use). val selectedNextUpFileId: Int? = null, val selectedNextUpAudioIndex: Int? = null, + /** As [audioPickedThisSession], for the series/season next-up selector. */ + val nextUpAudioPickedThisSession: Boolean = false, val selectedNextUpSubtitleIndex: Int? = null, val preferredQuality: String = "auto", // Cascaded subtitle preferences that annotate the selector row's Auto // preview ("Auto - " / "Auto - None") so it previews the SAME track - // the player would auto-select. ItemDetail (unlike WatchDetail) carries no - // per-item effective_* fields, so these are sourced from the active profile - // (matching the profile fallback in TvVideoPlaybackStarter); showForced - // defaults to true when unset, as the player state does. + // the player would auto-select. Resolved canonically — see + // [loadSubtitlePreferences]; the profile columns are only the fallback for + // a server that cannot resolve. `preferredSubtitleLanguage` null is "no + // preference" and "" is "no subtitles"; showForced defaults to true when + // unset, as the player state does. val preferredSubtitleLanguage: String? = null, val subtitleMode: String? = null, val showForcedSubtitles: Boolean = true, @@ -185,6 +211,103 @@ internal fun shouldApplyNextUpTrackRestore( ): Boolean = currentContentId == requestedContentId && currentSelectedFileId == requestedSelectedFileId +/** + * How many `GET /favorites/{id}` probes may be in flight at once. + * + * A season is commonly 10-25 episodes and every one needs its own probe, so + * unbounded parallelism put a whole season on the wire in one burst — measured + * at 150-520 ms each, with the slowest arriving well after the rail had been + * drawn. A small window keeps the first rows filling promptly without the + * burst. + */ +internal const val EPISODE_FAVORITE_PROBE_CONCURRENCY = 6 + +/** + * Whether a revalidation pass answered everything it was asked to. + * + * Only ids this season actually shows count: the signal is process-wide, so it + * routinely names episodes from a season that is not on screen, and waiting for + * those would mean never catching up. + * + * [requested] of null means the signal could not produce a delta, so every + * visible episode had to answer. A caller that records itself caught up while + * one of its probes failed leaves that row stale with nothing left to retry it, + * which is why this is separate from "the reload succeeded". + */ +/** + * Cached favourite answers that must be forgotten because they changed + * elsewhere and are not on screen to be re-probed. + * + * The cache spans seasons but a refresh only probes the visible one, so a + * changed episode belonging to another season would otherwise keep its stale + * answer for the life of this screen — the visible season's refresh would + * record the change as handled and move on. Dropping the entry instead means + * the season that does show it probes it when it next loads. + * + * Only OFF-SCREEN entries are dropped. A visible one is revalidated in place, + * because removing it would render that row as "not a favourite" until its + * probe answered. + * + * [requested] of null means the change list could not be produced, so every + * cached answer that is not on screen is suspect. + */ +internal fun staleOffScreenFavorites( + requested: Set?, + cachedIds: Set, + visibleIds: Set, +): Set = when (requested) { + null -> cachedIds - visibleIds + else -> requested.intersect(cachedIds) - visibleIds +} + +internal fun revalidationSatisfied( + requested: Set?, + visibleIds: List, + answered: Set, +): Boolean { + val visible = visibleIds.toSet() + val required = requested?.intersect(visible) ?: visible + return required.all { it in answered } +} + +/** + * Resolves the favourite flag for the episodes whose state is not already + * known, at most [concurrency] probes in flight at once. + * + * [onResolved] fires for each episode the moment its own probe answers, so the + * rail fills as results arrive. Waiting for the whole set would mean one slow + * probe holding back every answer that already landed — and bounding the + * requests makes that wait longer, not shorter, because the work is now spread + * over several waves instead of one burst. + * + * Reports only episodes that answered successfully: a failed probe is left out + * entirely rather than reported as `false`, so a transient error does not stick + * as a cached "not a favourite" for the rest of the visit. The returned list is + * every pair that resolved, for callers that want the whole outcome. + */ +internal suspend fun probeEpisodeFavorites( + episodeIds: List, + knownIds: Set, + concurrency: Int = EPISODE_FAVORITE_PROBE_CONCURRENCY, + onResolved: (String, Boolean) -> Unit = { _, _ -> }, + probe: suspend (String) -> ApiResult, +): List> { + val unknown = episodeIds.filterNot { it in knownIds } + if (unknown.isEmpty()) return emptyList() + val gate = Semaphore(concurrency) + return coroutineScope { + unknown.map { id -> + async { + val favorite = gate.withPermit { probe(id) } + (favorite as? ApiResult.Success)?.let { success -> + onResolved(id, success.data) + id to success.data + } + } + }.awaitAll() + }.filterNotNull() +} + /** * Drives the enhanced TV item detail screen. Loads the full [ItemDetail] plus * the current user's favorite/watchlist state in parallel. For series, pulls @@ -199,10 +322,13 @@ class TvItemDetailViewModel( private val personalDataRepository: PersonalDataRepository, private val playerSettingsStore: PlayerSettingsStore, private val profileRepository: ProfileRepository, + private val profileSettings: ProfileSettingsController, metadataAiRepository: org.prairieserver.prairie.repository.MetadataAiRepository, private val contentId: String, private val userItemState: UserItemStatePort = NoOpUserItemStatePort, private val recommendationRepository: org.prairieserver.prairie.repository.RecommendationRepository? = null, + private val tokenManager: TokenManager, + private val identityTransitions: IdentityTransitionBarrier, ) : ViewModel() { private val _uiState = MutableStateFlow(TvItemDetailUiState()) @@ -217,6 +343,13 @@ class TvItemDetailViewModel( descriptionTranslation.phase init { + viewModelScope.launch { + identityTransitions.transitions.collect { transition -> + if (transition.phase == IdentityTransitionPhase.WILL_CHANGE) { + pendingNextUpSelectionHandoff = null + } + } + } observePreferredQuality() if (contentId.isNotBlank()) { // Restore this title's pre-play track choices (QA 2026-07-08: a @@ -237,16 +370,38 @@ class TvItemDetailViewModel( /** * Loads the cascaded subtitle preferences that annotate the selector row's - * Auto preview. This screen loads an [ItemDetail], which carries no per-item - * `effective_*` subtitle fields (only [org.prairieserver.prairie.model.catalog.WatchDetail] - * does), so — unlike [TvVideoPlaybackStarter], which reads the WatchDetail - * effective fields first — these come purely from the active profile - * (`subtitle_language` / `subtitle_mode` / `show_forced_subtitles`), the - * same fallback the starter drops to. showForced defaults to true when unset, - * matching the player state and the starter's `?: true`. + * Auto preview. + * + * These resolve canonically, through the same [ProfileSettingsController] + * the settings screen writes with. The `user_profiles` columns + * `GET /profiles` serves are NOT equivalent: the settings screen writes + * `playback.subtitle_language` / `subtitle_mode` / `show_forced_subtitles` + * at `scope=profile` and the server does not mirror a canonical write back + * into those columns, so reading them here previewed the preference the + * user had *before* their last edit while + * [org.prairieserver.prairie.tv.ui.screens.player.TvVideoPlaybackStarter] — which + * reads WatchDetail's server-resolved `effective_*` fields — played the new + * one. The columns stay as the fallback for a server that cannot resolve + * canonically. showForced defaults to true when unset, matching the player + * state and the starter's `?: true`. */ private fun loadSubtitlePreferences() { viewModelScope.launch { + val resolved = runCatching { profileSettings.load() }.getOrNull()?.snapshot + if (resolved != null) { + _uiState.update { + it.copy( + // The snapshot spells "no preference" as "", the Auto + // preview spells it as null (it reads "" as "no subs", + // matching the profile column, which the server omits + // when empty). Translate rather than leak the wrong one. + preferredSubtitleLanguage = resolved.subtitleLanguage.ifBlank { null }, + subtitleMode = resolved.subtitleMode, + showForcedSubtitles = resolved.showForcedSubtitles, + ) + } + return@launch + } val profile = runCatching { profileRepository.getActiveProfile() }.getOrNull() _uiState.update { it.copy( @@ -345,7 +500,7 @@ class TvItemDetailViewModel( -> detail.seriesId?.takeIf { it.isNotBlank() }?.let { seriesId -> loadSeasons( seriesContentId = seriesId, - preferredSeasonNumber = detail.seasonNumber?.takeIf { it > 0 }, + preferredSeasonNumber = detail.seasonNumber, ) } } @@ -388,16 +543,29 @@ class TvItemDetailViewModel( */ fun refreshOnReturn() { val current = _uiState.value.detail ?: return + val playbackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) + playbackReturn?.let { saved -> + _uiState.update { + it.copy( + detail = it.detail?.withPlaybackReturn(saved), + selectedFileId = saved.fileId, + selectedAudioIndex = saved.audio, + selectedSubtitleIndex = saved.subtitle, + ) + } + } viewModelScope.launch { // Local overlay first: the player's final position write is already // on disk, so the label corrects before the server round-trip. val overlaid = withLocalProgress(current) + .let { refreshed -> playbackReturn?.let(refreshed::withPlaybackReturn) ?: refreshed } if (overlaid != current) { _uiState.update { it.copy(detail = overlaid) } } when (val result = catalogRepository.getItemDetail(contentId)) { is ApiResult.Success -> { val detail = withLocalProgress(result.data) + .let { refreshed -> playbackReturn?.let(refreshed::withPlaybackReturn) ?: refreshed } if (!isTvHiddenMediaType(detail.type)) { _uiState.update { it.copy( @@ -419,7 +587,22 @@ class TvItemDetailViewModel( else -> null } val season = _uiState.value.selectedSeason - if (seriesId != null && season != null) loadEpisodes(seriesId, season, quiet = true) + // Coming back from an episode's own screen: the favourite may have been + // toggled in there, and this view model still holds the old answer. Only + // the items actually changed are re-asked about. + val favoritesVersion = TvFavoriteRevalidationSession.currentVersion() + // Null: too far behind to be given a delta, so re-check the lot. + val favoritesToRecheck = + TvFavoriteRevalidationSession.changedSince(favoritesRevalidatedThrough) + if (seriesId != null && season != null) { + loadEpisodes( + seriesId, + season, + quiet = true, + revalidateFavorites = favoritesToRecheck, + favoritesVersion = favoritesVersion, + ) + } } fun onToggleFavorite() { @@ -436,6 +619,10 @@ class TvItemDetailViewModel( } } else { _uiState.update { it.copy(isTogglingFavorite = false) } + // A series rail one screen up may be holding a stale answer for + // this item. Tell it exactly which one changed rather than + // making it re-ask about the whole season. + TvFavoriteRevalidationSession.markChanged(contentId) } } } @@ -530,7 +717,12 @@ class TvItemDetailViewModel( // Track indexes are file-specific; clear them so a stale index can't // carry over to a different version's track list. _uiState.update { - it.copy(selectedFileId = fileId, selectedAudioIndex = null, selectedSubtitleIndex = null) + it.copy( + selectedFileId = fileId, + selectedAudioIndex = null, + audioPickedThisSession = false, + selectedSubtitleIndex = null, + ) } TvDetailTrackSelectionSession.remember(contentId, fileId, audio = null, subtitle = null) // Do NOT persist here: a version switch resets the indexes to null, and @@ -542,7 +734,7 @@ class TvItemDetailViewModel( /** Pre-select an audio track for the next Play (index into the version's audioTracks). */ fun onAudioTrackSelected(index: Int?) { - _uiState.update { it.copy(selectedAudioIndex = index) } + _uiState.update { it.copy(selectedAudioIndex = index, audioPickedThisSession = index != null) } val state = _uiState.value TvDetailTrackSelectionSession.remember(contentId, state.selectedFileId, index, state.selectedSubtitleIndex) persistTrackSelection() @@ -657,20 +849,17 @@ class TvItemDetailViewModel( _uiState.update { it.copy(seasonsLoading = true) } when (val r = catalogRepository.getSeasons(seriesContentId)) { is ApiResult.Success -> { - val seasons = r.data.seasons.sortedForDisplay() - val selectedSeason = preferredSeasonNumber - ?.let { seasonNumber -> seasons.firstOrNull { it.seasonNumber == seasonNumber } } - val firstRegular = selectedSeason - ?: seasons.firstOrNull { !it.isSpecials } - ?: seasons.firstOrNull() + val plan = r.data.seasons.initialSeasonDisplayPlan(preferredSeasonNumber) _uiState.update { it.copy( seasonsLoading = false, - seasons = seasons, - selectedSeason = firstRegular?.seasonNumber, + seasons = plan.seasons, + selectedSeason = plan.selectedSeasonNumber, ) } - if (firstRegular != null) loadEpisodes(seriesContentId, firstRegular.seasonNumber) + plan.episodeRequestSeasonNumber?.let { seasonNumber -> + loadEpisodes(seriesContentId, seasonNumber) + } } else -> _uiState.update { it.copy(seasonsLoading = false) } } @@ -678,7 +867,15 @@ class TvItemDetailViewModel( } private var episodeLoadJob: kotlinx.coroutines.Job? = null + + /** + * How far through [TvFavoriteRevalidationSession] this screen has caught up. + * Starts at the current version: anything toggled before this screen existed + * is already reflected in the data it is about to load. + */ + private var favoritesRevalidatedThrough: Long = TvFavoriteRevalidationSession.currentVersion() private var moreLikeThisJob: Job? = null + private var nextUpDetailJob: Job? = null // The season number the currently-shown episodes/next-up actually belong to. // Lets a failed load revert the optimistic season selection so the chips and // the rail stay consistent (T15). @@ -688,17 +885,46 @@ class TvItemDetailViewModel( private val episodeWatchMutationGenerations = mutableMapOf() private var nextEpisodeFavoriteMutationGeneration: Long = 0 private val episodeFavoriteMutationGenerations = mutableMapOf() + private var nextUpPlaybackDetailGeneration: Long = 0 + private var nextUpSelectorRevision: Long = 0 + private var pendingNextUpSelectionHandoff: PendingNextUpSelectionHandoff? = null + + private data class NextUpIdentity( + val serverId: String?, + val profileId: String?, + val generation: Long, + ) + + private data class PendingNextUpSelectionHandoff( + val targetContentId: String, + val refreshGeneration: Long, + val selectorRevision: Long, + val identity: NextUpIdentity, + val handoff: EpisodeSelectionHandoff, + ) + + private data class ResolvedNextUpTrackSelection( + val fileId: Int?, + val audioIndex: Int?, + val subtitleIndex: Int?, + ) /** * Loads a season's episodes. [quiet] suppresses the loading spinner and is * used by [refreshOnReturn], whose contract is a no-flash background refresh * of the season already on screen. */ - private fun loadEpisodes(seriesContentId: String, seasonNumber: Int, quiet: Boolean = false) { + private fun loadEpisodes( + seriesContentId: String, + seasonNumber: Int, + quiet: Boolean = false, + revalidateFavorites: Set? = emptySet(), + favoritesVersion: Long? = null, + ) { // Cancel any in-flight episode load so a slower response for a // previously-selected season can't overwrite episodes/next-up for the // season the user is now on (rapid season switches / the initial - // firstRegular load racing a route-driven season load). + // selected-season load racing a route-driven season load). episodeLoadJob?.cancel() episodeLoadJob = viewModelScope.launch { if (!quiet) _uiState.update { it.copy(episodesLoading = true) } @@ -709,7 +935,16 @@ class TvItemDetailViewModel( episodeListGeneration += 1 _uiState.update { it.copy(episodesLoading = false, episodes = episodes) } refreshNextUp(episodes) - refreshEpisodeFavoriteStates(episodes) + val revalidationComplete = + refreshEpisodeFavoriteStates(episodes, revalidate = revalidateFavorites) + // Caught up only now, and only if every id we were asked to + // re-check actually answered. Advancing on read would drop + // the signal when the reload failed; advancing after a + // FAILED probe would drop it just as permanently, leaving + // that one episode stale with nothing left to retry it. + if (revalidationComplete) { + favoritesVersion?.let { favoritesRevalidatedThrough = it } + } } else -> { // Quiet-failure contract (T15): a failed season load must NOT @@ -729,27 +964,86 @@ class TvItemDetailViewModel( } } - private suspend fun refreshEpisodeFavoriteStates(episodes: List) { - if (episodes.isEmpty()) { - _uiState.update { it.copy(episodeFavoriteStates = emptyMap()) } - return + /** + * Fills in the favourite flag for episodes whose state this screen does not + * already know. + * + * There is no favourite field on an episode payload, so each one has to be + * asked for individually — `GET /favorites/{id}`, answering 404 for "not a + * favourite". Two things made that expensive enough to see in the field: + * every episode was asked on every season load even when the answer was + * already on screen, and all of them were asked at once. One series on a + * tester's Fire TV produced 116 such 404s at 150-520 ms each. + * + * So: ask only about episodes with no answer yet, and ask a few at a time. + * The map accumulates for the life of this view model, which is one visit + * to one item — leaving the screen and coming back still re-reads, so a + * favourite toggled on another device is picked up on the next visit + * rather than being cached indefinitely. + */ + /** + * @param revalidate ids to re-ask about even though an answer is already + * held, because they were toggled on a screen further down. This view model + * is retained across that trip, so its answer for them is stale but + * present. Deliberately a targeted SET rather than a blanket flag: + * ON_RESUME also fires for returning from playback and for foregrounding + * the app, and re-probing a whole season on each of those would restore the + * request volume this window exists to prevent. + * + * Existing entries stay until a fresh answer replaces them — clearing first + * would render every episode as "not a favourite" for the length of a round + * trip, and permanently so for any probe that fails. + */ + private suspend fun refreshEpisodeFavoriteStates( + episodes: List, + revalidate: Set? = emptySet(), + ): Boolean { + // An empty season leaves the accumulated answers alone: rendering is + // keyed by the visible episode ids, so nothing stale can show, and + // clearing would make returning to a populated season re-probe it. + val episodeIds = episodes.map { it.contentId } + val visibleIds = episodeIds.toSet() + + // Apply the change list to entries this screen holds but is not showing, + // before deciding anything else. Recording those as handled without + // acting on them is how a stale answer survives a season switch. + val stale = staleOffScreenFavorites( + requested = revalidate, + cachedIds = _uiState.value.episodeFavoriteStates.keys, + visibleIds = visibleIds, + ) + if (stale.isNotEmpty()) { + _uiState.update { it.copy(episodeFavoriteStates = it.episodeFavoriteStates - stale) } } - val knownStates = _uiState.value.episodeFavoriteStates - val states = coroutineScope { - episodes.map { episode -> - async { - val favorite = personalDataRepository.isFavorite(episode.contentId) - episode.contentId to when (favorite) { - is ApiResult.Success -> favorite.data - else -> knownStates[episode.contentId] ?: false + + // Now safe: the only changes left to account for are visible ones, and + // an empty season has none. + if (episodes.isEmpty()) return true + val generation = episodeListGeneration + // A null delta means the signal could not tell us what changed, so + // nothing is treated as already known. + val knownIds = + if (revalidate == null) emptySet() else _uiState.value.episodeFavoriteStates.keys - revalidate + val resolved = probeEpisodeFavorites( + episodeIds = episodeIds, + knownIds = knownIds, + onResolved = { id, favorite -> + // Publish per answer rather than per batch. Guarded by the + // generation the probes were started for, so a season the + // viewer has already left cannot write into the one on screen. + if (episodeListGeneration == generation) { + _uiState.update { + it.copy(episodeFavoriteStates = it.episodeFavoriteStates + (id to favorite)) } } - }.awaitAll().toMap() - } - val currentIds = _uiState.value.episodes.mapTo(mutableSetOf()) { it.contentId } - if (currentIds == episodes.mapTo(mutableSetOf()) { it.contentId }) { - _uiState.update { it.copy(episodeFavoriteStates = states) } - } + }, + ) { personalDataRepository.isFavorite(it) } + + return revalidationSatisfied( + requested = revalidate, + visibleIds = episodeIds, + answered = resolved.mapTo(mutableSetOf()) { it.first }, + ) } fun onSetEpisodeWatched(episodeContentId: String, watched: Boolean) { @@ -849,13 +1143,15 @@ class TvItemDetailViewModel( /** * Resolves the next-up episode for the selected season (series/season detail * only) and kicks off its playback-detail load when it changes. Mirrors - * prairie-apple's `nextUpEpisode` + the `.task(id:)`-driven + * silo-apple's `nextUpEpisode` + the `.task(id:)`-driven * `loadSeriesNextUpPlaybackDetail` / `loadSeasonNextUpPlaybackDetail`. */ private fun refreshNextUp(episodes: List) { - val detail = _uiState.value.detail + val oldState = _uiState.value + val detail = oldState.detail val type = detail?.type?.lowercase() if (detail == null || (type != "series" && type != "season")) { + invalidateNextUpPlaybackDetailRequest() // Movie / episode detail does not drive next-up; clear any state. if (_uiState.value.nextUpEpisode != null || _uiState.value.nextUpPlaybackDetail != null) { _uiState.update { @@ -866,6 +1162,7 @@ class TvItemDetailViewModel( didLoadNextUpPlaybackDetail = false, selectedNextUpFileId = null, selectedNextUpAudioIndex = null, + nextUpAudioPickedThisSession = false, selectedNextUpSubtitleIndex = null, ) } @@ -883,6 +1180,7 @@ class TvItemDetailViewModel( } if (nextUp == null) { + invalidateNextUpPlaybackDetailRequest() _uiState.update { it.copy( nextUpEpisode = null, @@ -891,12 +1189,21 @@ class TvItemDetailViewModel( didLoadNextUpPlaybackDetail = false, selectedNextUpFileId = null, selectedNextUpAudioIndex = null, + nextUpAudioPickedThisSession = false, selectedNextUpSubtitleIndex = null, ) } return } + // Capture portable intent while the old target's selected version and + // combined subtitle index are still available. Raw file IDs and indexes + // never cross the episode boundary. + val handoff = captureNextUpSelectionHandoff(oldState) + val refreshGeneration = ++nextUpPlaybackDetailGeneration + val selectorRevision = nextUpSelectorRevision + val identityGeneration = identityTransitions.generation.value + pendingNextUpSelectionHandoff = null _uiState.update { it.copy( nextUpEpisode = nextUp, @@ -905,10 +1212,17 @@ class TvItemDetailViewModel( didLoadNextUpPlaybackDetail = false, selectedNextUpFileId = null, selectedNextUpAudioIndex = null, + nextUpAudioPickedThisSession = false, selectedNextUpSubtitleIndex = null, ) } - loadNextUpPlaybackDetail(nextUp.contentId) + loadNextUpPlaybackDetail( + episodeContentId = nextUp.contentId, + refreshGeneration = refreshGeneration, + selectorRevision = selectorRevision, + identityGeneration = identityGeneration, + handoff = handoff, + ) } private fun resolveNextUpEpisode(episodes: List): EpisodeListItem? { @@ -917,34 +1231,229 @@ class TvItemDetailViewModel( return episodes.firstOrNull() } - private fun loadNextUpPlaybackDetail(episodeContentId: String) { - viewModelScope.launch { + private fun loadNextUpPlaybackDetail( + episodeContentId: String, + refreshGeneration: Long, + selectorRevision: Long, + identityGeneration: Long, + handoff: EpisodeSelectionHandoff?, + ) { + nextUpDetailJob?.cancel() + nextUpDetailJob = viewModelScope.launch { + val requestIdentity = captureNextUpIdentity(identityGeneration) + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration) || requestIdentity == null) { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + _uiState.update { + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration)) it else it.copy( + nextUpPlaybackDetail = null, + isLoadingNextUpPlaybackDetail = false, + didLoadNextUpPlaybackDetail = true, + ) + } + return@launch + } + if (handoff != null && nextUpSelectorRevision == selectorRevision) { + pendingNextUpSelectionHandoff = PendingNextUpSelectionHandoff( + targetContentId = episodeContentId, + refreshGeneration = refreshGeneration, + selectorRevision = selectorRevision, + identity = requestIdentity, + handoff = handoff, + ) + } val result = catalogRepository.getItemDetail(episodeContentId) - // Ignore a late result if the next-up target moved on. - if (_uiState.value.nextUpEpisode?.contentId != episodeContentId) return@launch + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration)) { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + return@launch + } + val completionIdentity = captureNextUpIdentity(identityGeneration) + if (completionIdentity != requestIdentity) { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + _uiState.update { + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration)) it else it.copy( + nextUpPlaybackDetail = null, + isLoadingNextUpPlaybackDetail = false, + didLoadNextUpPlaybackDetail = true, + ) + } + return@launch + } when (result) { is ApiResult.Success -> { val playbackDetail = withLocalProgress(result.data) + if ( + !ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration) || + captureNextUpIdentity(identityGeneration) != requestIdentity + ) { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + return@launch + } + val pending = pendingNextUpSelectionHandoff?.takeIf { + it.targetContentId == episodeContentId && + it.refreshGeneration == refreshGeneration && + it.selectorRevision == nextUpSelectorRevision && + it.identity == requestIdentity + } + val selectionRevision = nextUpSelectorRevision + val selection = resolveNextUpTrackSelection( + episodeContentId = episodeContentId, + detail = playbackDetail, + handoff = pending?.handoff, + preferredQuality = _uiState.value.preferredQuality, + ) + if ( + !ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration) || + captureNextUpIdentity(identityGeneration) != requestIdentity + ) { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + return@launch + } _uiState.update { - it.copy( - nextUpPlaybackDetail = playbackDetail, + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration)) { + it + } else if (nextUpSelectorRevision != selectionRevision) { + // An explicit selector callback ran while a durable + // read was suspended. Finish loading the target but + // leave that newer explicit choice untouched. + it.copy( + nextUpPlaybackDetail = playbackDetail, + isLoadingNextUpPlaybackDetail = false, + didLoadNextUpPlaybackDetail = true, + ) + } else { + it.copy( + nextUpPlaybackDetail = playbackDetail, + isLoadingNextUpPlaybackDetail = false, + didLoadNextUpPlaybackDetail = true, + selectedNextUpFileId = selection.fileId, + selectedNextUpAudioIndex = selection.audioIndex, + nextUpAudioPickedThisSession = false, + selectedNextUpSubtitleIndex = selection.subtitleIndex, + ) + } + } + // This transition resolution is display/session input only. + // Selector callbacks remain the sole writers to session/Room. + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + } + else -> { + clearPendingNextUpHandoff(episodeContentId, refreshGeneration) + _uiState.update { + if (!ownsNextUpPlaybackDetailRequest(episodeContentId, refreshGeneration)) it else it.copy( + nextUpPlaybackDetail = null, isLoadingNextUpPlaybackDetail = false, didLoadNextUpPlaybackDetail = true, ) } - restoreNextUpTrackSelection(episodeContentId, playbackDetail) - } - else -> _uiState.update { - it.copy( - nextUpPlaybackDetail = null, - isLoadingNextUpPlaybackDetail = false, - didLoadNextUpPlaybackDetail = true, - ) } } } } + private fun invalidateNextUpPlaybackDetailRequest() { + nextUpDetailJob?.cancel() + nextUpDetailJob = null + nextUpPlaybackDetailGeneration += 1 + pendingNextUpSelectionHandoff = null + } + + private fun ownsNextUpPlaybackDetailRequest(episodeContentId: String, refreshGeneration: Long): Boolean = + nextUpPlaybackDetailGeneration == refreshGeneration && + _uiState.value.nextUpEpisode?.contentId == episodeContentId + + private fun clearPendingNextUpHandoff(episodeContentId: String, refreshGeneration: Long) { + pendingNextUpSelectionHandoff = pendingNextUpSelectionHandoff?.takeUnless { + it.targetContentId == episodeContentId && it.refreshGeneration == refreshGeneration + } + } + + private suspend fun captureNextUpIdentity(expectedGeneration: Long): NextUpIdentity? { + if (identityTransitions.generation.value != expectedGeneration) return null + // A snapshot is the only internally-consistent server/profile read. + // Null means this TokenManager cannot pin the active identity; fail + // closed instead of composing separately-timed getters. + val scope = tokenManager.snapshotCurrentScope() ?: return null + if (identityTransitions.generation.value != expectedGeneration) return null + return NextUpIdentity(scope.serverId, scope.profileId, expectedGeneration) + } + + private fun captureNextUpSelectionHandoff(state: TvItemDetailUiState): EpisodeSelectionHandoff? { + val detail = state.nextUpPlaybackDetail ?: return null + val selectedVersion = state.selectedNextUpFileId + ?.let { fileId -> detail.versions.firstOrNull { it.fileId == fileId } } + val activeVersion = selectTvDetailDisplayVersion( + versions = detail.versions, + selectedFileId = state.selectedNextUpFileId, + lastFileId = detail.userData?.lastFileId, + preferredQuality = state.preferredQuality, + ) + val subtitleChoices = buildPlaybackSubtitleChoices( + catalogTracks = activeVersion?.subtitleTracks.orEmpty(), + plannedTracks = emptyList(), + ) + // Source intent is carried only for an explicit version choice; an + // automatic last-file/quality choice must remain automatic on the + // target. Subtitle intent uses the displayed version because its + // combined index belongs to that version's track list. + val handoff = EpisodeSelectionHandoff( + source = selectedVersion?.let(::captureEpisodeSourceIntent), + subtitle = captureEpisodeSubtitleIntent(state.selectedNextUpSubtitleIndex, subtitleChoices), + ) + return handoff.takeIf { + it.source != null || it.subtitle.mode != EpisodeSubtitleMode.AUTO + } + } + + private suspend fun resolveNextUpTrackSelection( + episodeContentId: String, + detail: ItemDetail, + handoff: EpisodeSelectionHandoff?, + preferredQuality: String, + ): ResolvedNextUpTrackSelection { + val session = TvDetailTrackSelectionSession.recall(episodeContentId) + val sourceSpecified = handoff?.source != null + val carriedFileId = resolveEpisodeSourceIntent(handoff?.source, detail.versions) + val sessionFileId = session?.fileId?.takeIf { fileId -> detail.versions.any { it.fileId == fileId } } + val selectedFileId = if (sourceSpecified) carriedFileId else sessionFileId + val selectedVersion = selectTvDetailDisplayVersion( + versions = detail.versions, + selectedFileId = selectedFileId, + lastFileId = detail.userData?.lastFileId, + preferredQuality = preferredQuality, + ) + ?: return ResolvedNextUpTrackSelection(selectedFileId, null, null) + + val sessionVersionId = selectTvDetailDisplayVersion( + versions = detail.versions, + selectedFileId = sessionFileId, + lastFileId = detail.userData?.lastFileId, + preferredQuality = preferredQuality, + )?.fileId + val sessionMatchesSelectedVersion = session != null && sessionVersionId == selectedVersion.fileId + val targetSubtitleChoices = buildPlaybackSubtitleChoices( + catalogTracks = selectedVersion.subtitleTracks.orEmpty(), + plannedTracks = emptyList(), + ) + val carriedSubtitle = resolveEpisodeSubtitleIntent( + intent = handoff?.subtitle ?: EpisodeSubtitleIntent.auto(), + targetSubtitles = targetSubtitleChoices, + ) + val durable = userItemState.localTrackSelection(episodeContentId, selectedVersion.fileId) + ?.let { restoreTrackSelection(selectedVersion, it) } + + val sessionAudio = session?.audio.takeIf { sessionMatchesSelectedVersion } + val sessionSubtitle = session?.subtitle.takeIf { sessionMatchesSelectedVersion } + return ResolvedNextUpTrackSelection( + fileId = selectedFileId, + audioIndex = sessionAudio ?: durable?.audioIndex, + subtitleIndex = if (carriedSubtitle.intentSpecified) { + carriedSubtitle.trackIndex + } else { + sessionSubtitle ?: durable?.subtitleIndex + }, + ) + } + /** Mirror of the phone flow: fire (auto = once per content+language), then * poll detail until `pending_translation_language` clears. */ @@ -976,10 +1485,12 @@ class TvItemDetailViewModel( } fun onNextUpVersionSelected(fileId: Int?) { + markNextUpSelectorInput() _uiState.update { it.copy( selectedNextUpFileId = fileId, selectedNextUpAudioIndex = null, + nextUpAudioPickedThisSession = false, selectedNextUpSubtitleIndex = null, ) } @@ -988,17 +1499,29 @@ class TvItemDetailViewModel( } fun onNextUpAudioTrackSelected(index: Int?) { - _uiState.update { it.copy(selectedNextUpAudioIndex = index) } + markNextUpSelectorInput() + _uiState.update { + it.copy( + selectedNextUpAudioIndex = index, + nextUpAudioPickedThisSession = index != null, + ) + } rememberNextUpTrackSelection() persistNextUpTrackSelection() } fun onNextUpSubtitleTrackSelected(index: Int?) { + markNextUpSelectorInput() _uiState.update { it.copy(selectedNextUpSubtitleIndex = index) } rememberNextUpTrackSelection() persistNextUpTrackSelection() } + private fun markNextUpSelectorInput() { + nextUpSelectorRevision += 1 + pendingNextUpSelectionHandoff = null + } + private fun rememberNextUpTrackSelection() { val state = _uiState.value val nextUpContentId = state.nextUpEpisode?.contentId ?: return @@ -1023,24 +1546,6 @@ class TvItemDetailViewModel( ) } - private fun restoreNextUpTrackSelection(episodeContentId: String, detail: ItemDetail) { - val session = TvDetailTrackSelectionSession.recall(episodeContentId) - if (session != null) { - _uiState.update { - if (it.nextUpEpisode?.contentId != episodeContentId) it else it.copy( - selectedNextUpFileId = session.fileId, - selectedNextUpAudioIndex = session.audio, - selectedNextUpSubtitleIndex = session.subtitle, - ) - } - } - // A session can intentionally select a file before its durable track - // fingerprints have loaded (file B / null / null). Always merge the - // selected file's durable dimensions after applying session state; - // the guarded update below preserves any non-null session choices. - seedPersistedNextUpTrackSelection(episodeContentId, detail) - } - private fun seedPersistedNextUpTrackSelection( episodeContentId: String? = _uiState.value.nextUpEpisode?.contentId, detail: ItemDetail? = _uiState.value.nextUpPlaybackDetail, @@ -1048,6 +1553,8 @@ class TvItemDetailViewModel( val targetContentId = episodeContentId ?: return val playbackDetail = detail ?: return val selectedFileId = _uiState.value.selectedNextUpFileId + val selectorRevision = nextUpSelectorRevision + val refreshGeneration = nextUpPlaybackDetailGeneration val version = selectedFileId ?.let { fileId -> playbackDetail.versions.firstOrNull { it.fileId == fileId } } ?: playbackDetail.versions.firstOrNull() @@ -1055,28 +1562,50 @@ class TvItemDetailViewModel( viewModelScope.launch { val saved = userItemState.localTrackSelection(targetContentId, version.fileId) ?: return@launch val restored = restoreTrackSelection(version, saved) - _uiState.update { - if (!shouldApplyNextUpTrackRestore( - currentContentId = it.nextUpEpisode?.contentId, + var remembered: TvDetailTrackSelectionSession.Saved? = null + while (true) { + val current = _uiState.value + if ( + nextUpPlaybackDetailGeneration != refreshGeneration || + nextUpSelectorRevision != selectorRevision || + !shouldApplyNextUpTrackRestore( + currentContentId = current.nextUpEpisode?.contentId, requestedContentId = targetContentId, - currentSelectedFileId = it.selectedNextUpFileId, + currentSelectedFileId = current.selectedNextUpFileId, requestedSelectedFileId = selectedFileId, ) ) { - it - } else { - val merged = mergeTrackSelection( - currentAudioIndex = it.selectedNextUpAudioIndex, - currentSubtitleIndex = it.selectedNextUpSubtitleIndex, - durable = restored, - ) - it.copy( - selectedNextUpSubtitleIndex = merged.subtitleIndex, - selectedNextUpAudioIndex = merged.audioIndex, + break + } + val merged = mergeTrackSelection( + currentAudioIndex = current.selectedNextUpAudioIndex, + currentSubtitleIndex = current.selectedNextUpSubtitleIndex, + durable = restored, + ) + val updated = current.copy( + selectedNextUpSubtitleIndex = merged.subtitleIndex, + selectedNextUpAudioIndex = merged.audioIndex, + ) + if (_uiState.compareAndSet(current, updated)) { + remembered = TvDetailTrackSelectionSession.Saved( + fileId = updated.selectedNextUpFileId, + audio = updated.selectedNextUpAudioIndex, + subtitle = updated.selectedNextUpSubtitleIndex, ) + break } } - rememberNextUpTrackSelection() + // Remember exactly the successfully-owned target snapshot. Never + // reread the now-current UI after a suspension: it may be a carried + // selection for a different episode. + remembered?.let { selection -> + TvDetailTrackSelectionSession.remember( + contentId = targetContentId, + fileId = selection.fileId, + audio = selection.audio, + subtitle = selection.subtitle, + ) + } } } @@ -1184,8 +1713,88 @@ private fun BrowseItem.toSectionItem(): SectionItem = SectionItem( * manual audio/subtitle pre-selection (QA 2026-07-08). In-memory on purpose: * durable per-playback preferences are recorded by the player itself. */ +/** + * Favourites toggled on one detail screen that other retained detail screens + * may still be showing the old answer for. + * + * Versioned rather than consume-once. Consume-once loses the signal whenever + * more than one screen can read it, and more than one always can: every detail + * screen refreshes on resume, so an episode screen returning from playback + * would swallow the marker meant for the series rail behind it. It also loses + * the signal when the read succeeds but the reload meant to act on it fails. + * + * So nothing is consumed. Each change gets a monotonically increasing version, + * and each reader remembers the version it has caught up to, advancing that + * mark only once a revalidation has actually succeeded. Any number of readers + * each see every change, and a failed reload simply tries again next resume. + * + * A targeted set remains the point: re-asking about every visible episode on + * every resume would restore the request volume the probe window exists to + * prevent, and ON_RESUME also fires for returning from playback and for + * foregrounding the app. + */ +internal object TvFavoriteRevalidationSession { + private val lock = Any() + private var version = 0L + private val changedAt = LinkedHashMap() + + /** + * Highest version dropped by the cap. A reader behind this cannot be told + * what it missed, so it is told to re-check everything instead of being + * silently handed an incomplete delta. + */ + private var evictedThrough = 0L + + /** Ample for any one visit; oldest entries fall off rather than grow forever. */ + private const val MAX_TRACKED = 256 + + fun markChanged(contentId: String) { + if (contentId.isBlank()) return + synchronized(lock) { + version += 1 + changedAt.remove(contentId) + changedAt[contentId] = version + while (changedAt.size > MAX_TRACKED) { + val oldest = changedAt.entries.first() + evictedThrough = maxOf(evictedThrough, oldest.value) + changedAt.remove(oldest.key) + } + } + } + + /** The mark a reader stores once it has caught up. */ + fun currentVersion(): Long = synchronized(lock) { version } + + /** + * Ids changed after [sinceVersion]; readers pass the mark they last stored. + * + * Null means "cannot say": this reader is behind entries the cap has since + * dropped, so a delta would be incomplete. Callers re-check everything + * visible rather than trusting a partial answer — being slow must cost a + * round of extra probes, never a silently missed change. + */ + fun changedSince(sinceVersion: Long): Set? = synchronized(lock) { + if (sinceVersion < evictedThrough) return null + changedAt.entries + .filter { it.value > sinceVersion } + .mapTo(LinkedHashSet()) { it.key } + } + + fun reset() = synchronized(lock) { + version = 0L + evictedThrough = 0L + changedAt.clear() + } +} + internal object TvDetailTrackSelectionSession { - internal data class Saved(val fileId: Int?, val audio: Int?, val subtitle: Int?) + internal data class Saved( + val fileId: Int?, + val audio: Int?, + val subtitle: Int?, + val positionSeconds: Double? = null, + val durationSeconds: Double? = null, + ) private val byContent = HashMap() @@ -1194,5 +1803,59 @@ internal object TvDetailTrackSelectionSession { byContent[contentId] = Saved(fileId, audio, subtitle) } + fun rememberPlaybackReturn( + contentId: String, + fileId: Int?, + audio: Int?, + subtitle: Int?, + positionSeconds: Double, + durationSeconds: Double?, + ) { + if (contentId.isBlank() || !positionSeconds.isFinite() || positionSeconds < 0.0) return + val previous = byContent[contentId] + byContent[contentId] = Saved( + // Exit can race teardown before either player file identifier is + // available. Keep the detail page's selected version in that case. + fileId = fileId ?: previous?.fileId, + // The player currently reports subtitle selection on exit but not + // audio selection. Keep the detail page's explicit audio choice + // instead of replacing it with an unknown/null value. + audio = audio ?: previous?.audio, + // A null player result means the mounted track could not be + // resolved to a stable server index (keep current), not Off. + subtitle = subtitle ?: previous?.subtitle, + positionSeconds = positionSeconds, + durationSeconds = durationSeconds?.takeIf { it.isFinite() && it > 0.0 }, + ) + } + fun recall(contentId: String): Saved? = byContent[contentId] + + /** + * Returns the pending player-exit progress once, while retaining the + * session's file and track choices for later detail-screen recreation. + */ + fun consumePlaybackReturn(contentId: String): Saved? { + val saved = byContent[contentId] + ?.takeIf { it.positionSeconds != null } + ?: return null + byContent[contentId] = saved.copy( + positionSeconds = null, + durationSeconds = null, + ) + return saved + } +} + +private fun ItemDetail.withPlaybackReturn(saved: TvDetailTrackSelectionSession.Saved): ItemDetail { + val position = saved.positionSeconds?.takeIf { it.isFinite() && it >= 0.0 } ?: return this + val current = userData ?: LeafItemUserData() + return copy( + userData = current.copy( + isInProgress = position > 0.0, + positionSeconds = position.takeIf { it > 0.0 }, + durationSeconds = saved.durationSeconds ?: current.durationSeconds, + lastFileId = saved.fileId ?: current.lastFileId, + ), + ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt index 54028aae4..fe3894c65 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoDialog.kt @@ -42,6 +42,7 @@ import androidx.tv.material3.Text import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus import java.util.Locale import kotlinx.coroutines.launch @@ -73,7 +74,7 @@ internal fun tvMediaInfoSubtitleSummary(track: SubtitleTrack): TvMediaInfoTrackS codecLabel(track.codec), if (track.isDefault) "Default" else null, if (track.forced) "Forced" else null, - if (rawTitle.hasSdhHint()) "SDH" else null, + if (subtitleLabelIndicatesHearingImpaired(rawTitle)) "SDH" else null, if (track.external) "External" else null, ).distinct().joinToString(" · ").ifBlank { null } return TvMediaInfoTrackSummary(primary = primary, secondary = secondary) @@ -326,10 +327,6 @@ private fun looksLikeReleaseFileName(value: String): Boolean { .any { it in lower } && ('[' in value || '-' in value || '.' in value) } -private fun String?.hasSdhHint(): Boolean = - this?.contains(Regex("(^|[._\\-\\s(])sdh([._\\-\\s)]|$)", RegexOption.IGNORE_CASE)) == true || - this?.contains("hearing", ignoreCase = true) == true - private fun languageDisplayName(value: String?): String? { val normalized = value?.trim()?.lowercase(Locale.ROOT)?.takeIf { it.isNotBlank() && it != "und" } ?: return null diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt index 381a7ef0e..904620434 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormatting.kt @@ -3,8 +3,14 @@ package org.prairieserver.prairie.tv.ui.screens.detail import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.playback.AutoSubtitleContext +import org.prairieserver.prairie.model.playback.catalogAutoSubtitleCandidates import org.prairieserver.prairie.model.playback.combinedSubtitleSelectionIndexes +import org.prairieserver.prairie.model.playback.resolveAutoSubtitle +import org.prairieserver.prairie.model.playback.selectedCandidate import org.prairieserver.prairie.player.DolbyVisionDetection +import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import java.util.Locale internal fun automaticTrackLabel(resolvedLabel: String?): String = @@ -12,7 +18,7 @@ internal fun automaticTrackLabel(resolvedLabel: String?): String = /** * Pure formatting helpers for the TV detail playback selector row (Version / - * Audio / Subtitles / Edition). Mirrors prairie-apple's + * Audio / Subtitles / Edition). Mirrors silo-apple's * `Screens/Detail/DetailPlaybackFormatting.swift` + `PlaybackEditions.swift`, * adapted to the real Android [FileVersion] / [AudioTrack] / [SubtitleTrack] * field names. @@ -83,19 +89,81 @@ object TvPlaybackFormatting { return if (tokens.isEmpty()) "Auto" else tokens.joinToString(" · ") } - /** "4K · HDR" / "1080P" / "Auto" (null or no usable tokens → "Auto"). */ + /** + * "4K · HEVC · DV · TrueHD" / "1080P · H.264 · AAC" / "Auto" (null or no + * usable tokens → "Auto"). Same token set as tvOS's + * `DetailPlaybackFormatting.versionShortLabel` (resolution · video codec · + * dynamic range · audio codec) so the Version pill tells the user which + * audio the file carries, not just its resolution. + */ fun versionShortLabel(version: FileVersion?): String { if (version == null) return "Auto" val tokens = buildList { displayResolution(version.resolution)?.let { add(it) } + resolvedVideoCodec(version)?.let { add(it) } when { isDolbyVision(version) -> add("DV") isHdr(version) -> add("HDR") } + resolvedAudioCodec(version)?.let { add(it) } } return if (tokens.isEmpty()) "Auto" else tokens.joinToString(" · ") } + /** + * Picker labels for a whole version list, disambiguated against each other. + * + * [versionShortLabel] is built from resolution / codecs / HDR-DV alone, so a + * title holding two 4K HEVC Dolby Vision TrueHD files (a remux and an + * encode, say) renders two identical rows and the user cannot tell them + * apart. Selection still works (the option id is the unique fileId) — the + * list is just unreadable. + * + * Only colliding labels get a suffix, so the common single-version-per-tier + * case is untouched. Attributes are accumulated until the group's labels + * are actually distinct: no single attribute need be unique on its own. + * Size usually does the work when a remux and an encode share a codec; + * container is the last resort. + */ + fun versionPickerLabels(versions: List): List { + val base = versions.map { versionShortLabel(it) } + val colliding = base.groupingBy { it }.eachCount().filterValues { it > 1 }.keys + if (colliding.isEmpty()) return base + + val suffixes = MutableList(versions.size) { "" } + for (label in colliding) { + val indexes = base.indices.filter { base[it] == label } + // Widen the attribute tuple until this group is separated, or until + // we run out of attributes and accept an honest duplicate. + for (depth in 1..VERSION_DISCRIMINATORS.size) { + val attempt = indexes.associateWith { index -> + VERSION_DISCRIMINATORS.take(depth) + .mapNotNull { it(versions[index]) } + .joinToString(" · ") + } + val distinct = attempt.values.toSet().size + // Only keep a tuple that actually separates something; two + // identical versions would otherwise both gain the same suffix + // — a fabricated difference that distinguishes nothing. + if (distinct > 1) indexes.forEach { suffixes[it] = attempt.getValue(it) } + if (distinct == indexes.size) break + } + } + return base.mapIndexed { index, label -> + val suffix = suffixes[index] + if (suffix.isBlank()) label else "$label · $suffix" + } + } + + /** + * Attributes tried, in order, when version labels collide. Codecs are + * already part of [versionShortLabel], so they never need to be appended. + */ + private val VERSION_DISCRIMINATORS: List<(FileVersion) -> String?> = listOf( + { v -> formatFileSize(v.fileSize) }, + { v -> v.container?.takeIf { it.isNotBlank() }?.uppercase(Locale.ROOT) }, + ) + fun isDolbyVision(version: FileVersion): Boolean = DolbyVisionDetection.isDolbyVision(videoCodec = version.codecVideo) || version.videoTracks.orEmpty().any { track -> @@ -179,7 +247,7 @@ object TvPlaybackFormatting { /** * Language of the track [audioValueLabel] would display for Auto — feeds * the subtitle auto-resolver, whose "auto" mode hides subs when the audio - * is already in the preferred subtitle language. Mirrors prairie-apple's + * is already in the preferred subtitle language. Mirrors silo-apple's * `DetailPlaybackFormatting.resolvedAudioLanguage`. */ fun resolvedAudioLanguage(version: FileVersion?, selectedAudioTrackIndex: Int?): String? { @@ -211,6 +279,64 @@ object TvPlaybackFormatting { /** Pill-value summary. Mirrors tvOS `audioSummary`: * "English · EAC3 · 5.1". */ + /** + * Source-identity summary for the audio the playback plan selected, keyed + * by ORDINAL into `audio_tracks` — the server's contract for audio. + * + * Deliberately NOT keyed on [AudioTrack.index]: the server sends no index + * for audio tracks (subtitles do get one), so that field is `0` for every + * row and cannot identify anything. + * + * The player HUD used to label this row from the mounted Media3 track, + * which describes what was *delivered*: a DTS 5.1 source transcoded to + * stereo AAC rendered as "UND AAC Stereo" while every other surface + * correctly said "English · DTS · 5.1". + */ + fun audioSummaryForOrdinal( + version: FileVersion?, + ordinal: Int?, + tracks: List? = null, + ): String? { + if (ordinal == null) return null + val rows = tracks?.takeIf { it.isNotEmpty() } ?: version?.audioTracks ?: return null + val track = rows.getOrNull(ordinal) ?: return null + return audioSummary(track, ordinal) + } + + /** + * Picker-row label. Keeps a meaningful title and the Default marker, which + * [audioSummaryForOrdinal] drops: two English AAC stereo tracks named "Main" + * and "Director Commentary" summarise identically, and the title is often + * the only thing that separates them. + */ + fun audioChoiceLabelForOrdinal(tracks: List, ordinal: Int): String? { + val track = tracks.getOrNull(ordinal) ?: return null + val summary = audioSummary(track, ordinal) + // Keyed off the RENDERED summary, not audioTitle: an untitled-language + // track falls back to its title for audioTitle, which then rejected the + // qualifier and dropped the only thing naming it ("Director Commentary" + // with no language rendered as bare "AAC · Stereo"). + val qualifier = usefulAudioTitle(track.title)?.takeIf { !summary.contains(it) } + return buildString { + append(summary) + if (qualifier != null) append(" · ").append(qualifier) + if (track.isDefault) append(" · Default") + } + } + + /** + * The catalog ordinal playback will actually use: the plan's selection when + * it is in range, else the server's effective ordinal, else the default + * flag, else the first row. Without this the HUD shows nothing checked + * whenever the plan carries no audio index. + */ + fun effectiveAudioOrdinal(tracks: List, planOrdinal: Int?, version: FileVersion? = null): Int? { + if (tracks.isEmpty()) return null + planOrdinal?.takeIf { it in tracks.indices }?.let { return it } + version?.effectiveAudioTrackIndex?.takeIf { it in tracks.indices }?.let { return it } + return tracks.indexOfFirst { it.isDefault }.takeIf { it >= 0 } ?: 0 + } + private fun audioSummary(track: AudioTrack, ordinal: Int): String { val tokens = listOfNotNull( languageDisplayName(track.language), @@ -337,7 +463,7 @@ object TvPlaybackFormatting { * Inputs needed to preview what the player's subtitle auto-resolver would * land on, so the row can annotate "Auto" with the concrete track (or * "None"). Mirrors the subset of [TvPlayerViewModel.resolveAutoSubtitleSelection]'s - * inputs the detail page can supply. Analogue of prairie-apple's + * inputs the detail page can supply. Analogue of silo-apple's * `DetailPlaybackFormatting.SubtitleAutoContext`. */ data class SubtitleAutoContext( @@ -412,130 +538,73 @@ object TvPlaybackFormatting { context: SubtitleAutoContext, ): Pair? { val tracks = version?.subtitleTracks ?: return null - if (tracks.isEmpty()) return null - - val mode = context.mode?.trim()?.lowercase(Locale.US)?.takeIf { it.isNotBlank() } ?: "auto" - if (mode == "off") return null - - val preferred = context.preferredLanguage - if (preferred != null && preferred.isBlank()) return null - - val targetLanguage = autoSubtitleLanguageKey(preferred) - if (targetLanguage == null) { - return if (mode == "always") { - bestAutoSubtitleTrack(tracks, targetLanguage = null, preferForced = context.showForced) - } else { - null - } - } - - val audioLanguage = autoSubtitleLanguageKey(context.audioLanguage) - if (mode == "auto" && audioLanguage != null && audioLanguage == targetLanguage) { - if (context.showForced) { - bestForcedAutoSubtitleTrack(tracks, targetLanguage)?.let { return it } - } - return null - } - - return bestAutoSubtitleTrack(tracks, targetLanguage, preferForced = context.showForced) - ?: if (context.showForced) { - tracks.withIndex().firstOrNull { it.value.forced }?.let { it.value to it.index } - } else { - null - } + val ordinal = autoResolvedSubtitleOrdinal(tracks, context) ?: return null + return tracks[ordinal] to ordinal } - /** Mirrors `TvPlayerViewModel.bestAutoSubtitleTrack` over catalog tracks. */ - private fun bestAutoSubtitleTrack( - tracks: List, - targetLanguage: String?, - preferForced: Boolean, - ): Pair? { - val pool = tracks.withIndex().filter { (_, t) -> - targetLanguage == null || autoSubtitleLanguageKey(t.language) == targetLanguage - } - if (pool.isEmpty()) return null - if (preferForced) { - pool.firstOrNull { (_, t) -> t.forced && !isHearingImpairedSubtitle(t) && !isBitmapSubtitle(t.codec) } - ?.let { return it.value to it.index } - } - pool.firstOrNull { (_, t) -> !t.forced && !isHearingImpairedSubtitle(t) && !isBitmapSubtitle(t.codec) } - ?.let { return it.value to it.index } - pool.firstOrNull { (_, t) -> !t.forced && !isBitmapSubtitle(t.codec) } - ?.let { return it.value to it.index } - pool.firstOrNull { (_, t) -> !isBitmapSubtitle(t.codec) } - ?.let { return it.value to it.index } - return pool.first().let { it.value to it.index } - } - - /** Mirrors `TvPlayerViewModel.bestForcedAutoSubtitleTrack` over catalog tracks. */ - private fun bestForcedAutoSubtitleTrack( + /** + * The catalog ordinal Auto resolves to, or null for "no subtitle". + * + * Ranking lives in the shared [resolveAutoSubtitle]; this only translates + * between catalog ordinals (what the pill renders) and the combined + * selection space the resolver addresses. + */ + internal fun autoResolvedSubtitleOrdinal( tracks: List, - targetLanguage: String?, - ): Pair? { - val pool = tracks.withIndex().filter { (_, t) -> - (targetLanguage == null || autoSubtitleLanguageKey(t.language) == targetLanguage) && t.forced - } - if (pool.isEmpty()) return null - pool.firstOrNull { (_, t) -> !isHearingImpairedSubtitle(t) && !isBitmapSubtitle(t.codec) } - ?.let { return it.value to it.index } - pool.firstOrNull { (_, t) -> !isHearingImpairedSubtitle(t) } - ?.let { return it.value to it.index } - return pool.first().let { it.value to it.index } + context: SubtitleAutoContext, + ): Int? { + if (tracks.isEmpty()) return null + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext( + preferredLanguage = context.preferredLanguage, + mode = context.mode, + showForced = context.showForced, + audioLanguage = context.audioLanguage, + ), + ).selectedCandidate() ?: return null + return combinedSubtitleSelectionIndexes(tracks) + .indexOf(selected.selectionIndex) + .takeIf { it >= 0 } } /** - * ISO-639 folding used by the RESOLVER (`TvPlayerViewModel.normalizedSubtitleLanguage`) - * — deliberately its own smaller alias table (not [languageDisplayName]'s), - * dropping `und`, so the preview matches the player's language comparison - * exactly rather than the row's display grouping. + * The subtitle decision the selector row is DISPLAYING, in combined + * selection space, ready to hand to playback. + * + * Auto used to hand over nothing at all, so the start request carried no + * `subtitle_track_index`, the initial plan mounted no sidecar, and the + * player re-derived Auto over Media3's mounted tracks — where an external + * SRT does not exist yet. The row's own answer travels instead, tagged + * [TvSubtitleLaunchSelection.autoResolved] so the player can apply it + * without recording it as a choice the viewer made. + * + * Null only when the row itself cannot say (no auto context): the player + * then falls back to its own resolution, as before. */ - private fun autoSubtitleLanguageKey(language: String?): String? { - val primary = language - ?.trim() - ?.takeUnless { it.isBlank() || it.equals("und", ignoreCase = true) } - ?.lowercase(Locale.US) - ?.replace('_', '-') - ?.substringBefore('-') - ?: return null - return when (primary) { - "eng" -> "en" - "spa" -> "es" - "fre", "fra" -> "fr" - "ger", "deu" -> "de" - "dut", "nld" -> "nl" - "jpn" -> "ja" - "dan" -> "da" - else -> primary - } + fun subtitleLaunchSelection( + version: FileVersion?, + selectedSubtitleTrackIndex: Int?, + autoContext: SubtitleAutoContext?, + ): TvSubtitleLaunchSelection? { + if (selectedSubtitleTrackIndex != null) { + return TvSubtitleLaunchSelection(selectedSubtitleTrackIndex, autoResolved = false) + } + val context = autoContext ?: return null + val tracks = version?.subtitleTracks.orEmpty() + val ordinal = autoResolvedSubtitleOrdinal(tracks, context) + // "Auto - None" is a decision too: start explicitly Off rather than + // letting the player re-derive something the row never showed. + ?: return TvSubtitleLaunchSelection(-1, autoResolved = true) + return TvSubtitleLaunchSelection( + selectionIndex = combinedSubtitleSelectionIndexes(tracks)[ordinal], + autoResolved = true, + ) } - private val hearingImpairedSubtitleTokenRegex = - Regex("""(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""", RegexOption.IGNORE_CASE) - - /** Title-based HI/CC/SDH detection mirroring the player's `indicatesHearingImpairedSubtitle`. */ + /** Title-based CC/SDH detection shared with player identity and auto-selection. */ private fun isHearingImpairedSubtitle(track: SubtitleTrack): Boolean { - val title = track.title ?: return false - val lower = title.lowercase(Locale.US) - return lower.contains("closed caption") || - lower.contains("hearing impaired") || - lower.contains("hearing-impaired") || - lower.contains("hearing") || - hearingImpairedSubtitleTokenRegex.containsMatchIn(title) - } - - /** - * Mirrors `isBitmapSubtitleCodecOrMime` (PGS / VobSub / DVB / HDMV). - * Normalization strips ALL non-alphanumerics so ffprobe names - * ("dvb_subtitle", "hdmv_pgs_subtitle"), short names ("dvbsub"/"dvbsubs") - * and Media3 mimes classify identically — Apple parity with - * `ApplePlaybackRoutePlanner`'s bitmap token set. - */ - private fun isBitmapSubtitle(codec: String?): Boolean { - val n = codec?.filter { it.isLetterOrDigit() }?.lowercase(Locale.US) - ?.takeIf { it.isNotEmpty() } ?: return false - return n.contains("pgs") || n.contains("hdmv") || n.contains("dvd") || - n.contains("dvbsub") || n.contains("vobsub") + return subtitleLabelIndicatesHearingImpaired(track.title) } /** Menu-row title. Mirrors tvOS `subtitleTitle`: language → meaningful @@ -587,7 +656,7 @@ object TvPlaybackFormatting { if (title.length > 28 || '[' in title || SUBTITLE_FILENAME_SUFFIXES.any(lowered::endsWith)) { return null } - if (lowered == "forced" || lowered in listOf("sdh", "cc", "hi", "hearing impaired")) { + if (lowered == "forced" || lowered in listOf("sdh", "cc", "hearing impaired")) { return null } return displayTitle(title) @@ -604,10 +673,7 @@ object TvPlaybackFormatting { /** Mirrors tvOS `containsAccessibilityMarker` — keeps the pill from * doubling up markers a custom title already carries. */ private fun containsAccessibilityMarker(value: String): Boolean { - val lowered = value.lowercase(Locale.US) - val words = lowered.split(Regex("[^a-z]+")).filter { it.isNotEmpty() } - return "sdh" in words || "cc" in words || "hi" in words || - lowered.contains("hearing impaired") + return subtitleLabelIndicatesHearingImpaired(value) } // --- Editions (Android model has no edition data) -------------------- diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt index c8e7a1b8d..e5c04a728 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt @@ -41,7 +41,18 @@ internal fun isAudioSelectorOptionSelected( selectedAudioTrackIndex: Int?, ): Boolean = optionIndex == selectedAudioTrackIndex -internal fun selectorIsInteractive(optionCount: Int): Boolean = optionCount > 1 +/** + * Apple's `DetailPlaybackFormatting.shouldEnable*Selector`: a selector opens a + * menu only when there is more than one REAL choice — scoped versions, audio + * tracks, subtitle tracks or editions. + * + * The "Auto" and "Off" rows the menus prepend are pseudo-entries, not choices, + * so they are deliberately NOT counted. Counting them (the previous rule, which + * counted enabled menu rows) made every single-track file's Audio pill and every + * single-version file's Version pill open a dropdown whose only real outcome was + * the value already printed on the pill. + */ +internal fun selectorIsInteractive(realChoiceCount: Int): Boolean = realChoiceCount > 1 @Composable fun TvPlaybackSelectorRow( @@ -74,6 +85,114 @@ fun TvPlaybackSelectorRow( } else { versions } + val editionOptions = editions.map { edition -> + val count = edition.versions.size + TvSelectorOption( + key = "edition:${edition.id}", + title = edition.label, + detail = "$count version${if (count == 1) "" else "s"}", + selected = currentEdition?.id == edition.id, + onSelect = { onSelectVersion(edition.versions.firstOrNull()?.fileId) }, + ) + } + val versionOptions = buildList { + add( + TvSelectorOption( + key = "version:auto", + title = "Auto", + detail = "Best match for this device", + selected = selectedVersionFileId == null, + onSelect = { onSelectVersion(null) }, + ), + ) + scopedVersions.forEach { version -> + add( + TvSelectorOption( + key = "version:${version.fileId}", + title = TvPlaybackFormatting.versionShortLabel(version), + detail = TvPlaybackFormatting.versionDetailLabel(version), + selected = selectedVersionFileId == version.fileId, + onSelect = { onSelectVersion(version.fileId) }, + ), + ) + } + } + // Hoisted out of the buildList blocks below: these are the REAL choices, and + // their counts — not the assembled menu row counts, which carry Auto/Off — + // decide whether each pill is interactive. + val formattedAudioOptions = + TvPlaybackFormatting.audioOptions(currentVersion, selectedAudioTrackIndex) + val formattedSubtitleOptions = TvPlaybackFormatting.subtitleOptions( + currentVersion, + selectedSubtitleTrackIndex, + preferredLanguage = preferredSubtitleLanguage, + ) + val audioSelectorOptions = buildList { + add( + TvSelectorOption( + key = "audio:auto", + title = "Auto", + detail = "Use the file default track", + selected = isAudioSelectorOptionSelected(null, selectedAudioTrackIndex), + onSelect = { onSelectAudioTrack(null) }, + ), + ) + if (formattedAudioOptions.isEmpty()) { + add( + TvSelectorOption( + key = "audio:unknown", + title = "Unknown", + detail = "", + selected = false, + onSelect = {}, + enabled = false, + ), + ) + } else { + formattedAudioOptions.forEach { option -> + add( + TvSelectorOption( + key = "audio:${option.ordinal}", + title = option.title, + detail = option.detail, + selected = option.isSelected, + onSelect = { onSelectAudioTrack(option.ordinal) }, + ), + ) + } + } + } + val subtitleSelectorOptions = buildList { + add( + TvSelectorOption( + key = "subtitle:auto", + title = "Auto", + detail = "Use your subtitle preferences", + selected = selectedSubtitleTrackIndex == null, + onSelect = { onSelectSubtitleTrack(null) }, + ), + ) + add( + TvSelectorOption( + key = "subtitle:off", + title = "Off", + detail = "Start without subtitles", + selected = selectedSubtitleTrackIndex == -1, + onSelect = { onSelectSubtitleTrack(-1) }, + ), + ) + formattedSubtitleOptions.forEach { option -> + add( + TvSelectorOption( + key = "subtitle:${option.stableId}", + title = option.title, + detail = option.detail, + selected = option.isSelected, + onSelect = { onSelectSubtitleTrack(option.selectionIndex) }, + ), + ) + } + } // fillMaxWidth + a focus container so a Down press from any top-row control // (including the far-right circle toggles) lands on the nearest selector @@ -92,18 +211,7 @@ fun TvPlaybackSelectorRow( icon = Icons.Filled.Layers, label = "Edition", value = currentEdition?.label ?: "Standard", - options = editions.map { edition -> - val count = edition.versions.size - TvSelectorOption( - title = edition.label, - detail = "$count version${if (count == 1) "" else "s"}", - selected = currentEdition?.id == edition.id, - onSelect = { - // Select the best version of that edition. - onSelectVersion(edition.versions.firstOrNull()?.fileId) - }, - ) - }, + options = editionOptions, interactive = selectorIsInteractive(editions.size), ) } @@ -114,26 +222,7 @@ fun TvPlaybackSelectorRow( icon = Icons.Filled.Tv, label = "Version", value = TvPlaybackFormatting.versionShortLabel(currentVersion), - options = buildList { - add( - TvSelectorOption( - title = "Auto", - detail = "Best match for this device", - selected = selectedVersionFileId == null, - onSelect = { onSelectVersion(null) }, - ), - ) - scopedVersions.forEach { version -> - add( - TvSelectorOption( - title = TvPlaybackFormatting.versionShortLabel(version), - detail = TvPlaybackFormatting.versionDetailLabel(version), - selected = selectedVersionFileId == version.fileId, - onSelect = { onSelectVersion(version.fileId) }, - ), - ) - } - }, + options = versionOptions, interactive = selectorIsInteractive(scopedVersions.size), ) @@ -143,41 +232,8 @@ fun TvPlaybackSelectorRow( icon = Icons.AutoMirrored.Filled.VolumeUp, label = "Audio", value = TvPlaybackFormatting.audioValueLabel(currentVersion, selectedAudioTrackIndex), - options = buildList { - add( - TvSelectorOption( - title = "Auto", - detail = "Use the file default track", - selected = isAudioSelectorOptionSelected(null, selectedAudioTrackIndex), - onSelect = { onSelectAudioTrack(null) }, - ), - ) - val audioOptions = - TvPlaybackFormatting.audioOptions(currentVersion, selectedAudioTrackIndex) - if (audioOptions.isEmpty()) { - add( - TvSelectorOption( - title = "Unknown", - detail = "", - selected = false, - onSelect = {}, - enabled = false, - ), - ) - } else { - audioOptions.forEach { option -> - add( - TvSelectorOption( - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectAudioTrack(option.ordinal) }, - ), - ) - } - } - }, - interactive = selectorIsInteractive(currentVersion.audioTracks.orEmpty().size), + options = audioSelectorOptions, + interactive = selectorIsInteractive(formattedAudioOptions.size), ) // Subtitles — tvOS uses `captions.bubble`; Chat (bubble with text @@ -200,41 +256,8 @@ fun TvPlaybackSelectorRow( ), ), ), - options = buildList { - add( - TvSelectorOption( - title = "Auto", - detail = "Use your subtitle preferences", - selected = selectedSubtitleTrackIndex == null, - onSelect = { onSelectSubtitleTrack(null) }, - ), - ) - add( - TvSelectorOption( - title = "Off", - detail = "Start without subtitles", - selected = selectedSubtitleTrackIndex == -1, - onSelect = { onSelectSubtitleTrack(-1) }, - ), - ) - TvPlaybackFormatting - .subtitleOptions( - currentVersion, - selectedSubtitleTrackIndex, - preferredLanguage = preferredSubtitleLanguage, - ) - .forEach { option -> - add( - TvSelectorOption( - title = option.title, - detail = option.detail, - selected = option.isSelected, - onSelect = { onSelectSubtitleTrack(option.selectionIndex) }, - ), - ) - } - }, - interactive = selectorIsInteractive(currentVersion.subtitleTracks.orEmpty().size), + options = subtitleSelectorOptions, + interactive = selectorIsInteractive(formattedSubtitleOptions.size), ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt index 0c78dd9b1..1231e4a3b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPicker.kt @@ -39,12 +39,14 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.model.catalog.Season +import org.prairieserver.prairie.model.catalog.isSpecialsForDisplay import org.prairieserver.prairie.tv.ui.theme.TvControlCorner /** @@ -59,6 +61,7 @@ fun TvSeasonPicker( selectedSeason: Int?, onSeasonSelected: (Season) -> Unit, modifier: Modifier = Modifier, + horizontalContentPadding: Dp = 0.dp, onDirectionUp: (() -> Boolean)? = null, ) { if (seasons.isEmpty()) return @@ -102,7 +105,10 @@ fun TvSeasonPicker( .focusGroup(), state = listState, horizontalArrangement = Arrangement.spacedBy(7.dp), - contentPadding = PaddingValues(vertical = 6.dp), + // Horizontal inset lives inside the scroll viewport (contentPadding), + // not on the row, so the leftmost chip's focus scale isn't clipped + // at the row's left edge. + contentPadding = PaddingValues(horizontal = horizontalContentPadding, vertical = 6.dp), ) { items( seasons, @@ -182,7 +188,7 @@ private fun TvSeasonChip( contentAlignment = Alignment.Center, ) { Text( - text = season.displayLabel(), + text = tvSeasonPickerLabel(season), style = MaterialTheme.typography.titleLarge.copy( fontSize = 16.sp, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -192,7 +198,7 @@ private fun TvSeasonChip( } } -private fun Season.displayLabel(): String { - if (isSpecials) return "Specials" - return title?.takeIf { it.isNotBlank() } ?: "Season $seasonNumber" +internal fun tvSeasonPickerLabel(season: Season): String { + if (season.isSpecialsForDisplay()) return "Specials" + return season.title?.takeIf { it.isNotBlank() } ?: "Season ${season.seasonNumber}" } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestoration.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestoration.kt new file mode 100644 index 000000000..b86ee783a --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestoration.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import kotlinx.coroutines.withTimeoutOrNull + +internal enum class TvSimilarFocusRestoreResult { + Restored, + Fallback, + Revoked, +} + +/** + * Drives a pending More Like This return without tying its policy to Compose. + * + * Data and attachment use separate deadlines because they wait for different + * things. Once the card exists, [maxFocusAttempts] bounds how often focus can + * be requested while [attachmentTimeoutMillis] bounds the real time spent if + * frame production stalls. Ownership is checked between attempts and after + * every suspension so an obsolete restore neither keeps requesting focus nor + * moves focus to the fallback on the viewer's behalf. + */ +internal suspend fun restoreMoreLikeThisFocus( + awaitTarget: suspend () -> Unit, + stillOwned: () -> Boolean, + onTargetResolved: () -> Unit, + isTargetFocused: () -> Boolean, + requestTargetFocus: () -> Unit, + awaitFocusAttempt: suspend () -> Unit, + scrollToFallback: suspend () -> Unit, + requestFallbackFocus: () -> Unit, + dataTimeoutMillis: Long, + attachmentTimeoutMillis: Long, + maxFocusAttempts: Int = 40, +): TvSimilarFocusRestoreResult { + val resolved = withTimeoutOrNull(dataTimeoutMillis) { + awaitTarget() + true + } ?: false + + var restored = false + if (resolved && stillOwned()) { + onTargetResolved() + var revoked = false + withTimeoutOrNull(attachmentTimeoutMillis) { + repeat(maxFocusAttempts) { + if (isTargetFocused()) { + restored = true + return@withTimeoutOrNull + } + if (!stillOwned()) { + revoked = true + return@withTimeoutOrNull + } + requestTargetFocus() + awaitFocusAttempt() + } + } + if (revoked || !stillOwned()) return TvSimilarFocusRestoreResult.Revoked + // A request made by the final attempt can be observed just after that + // attempt completes, so check the callback-owned state once more. + if (!restored) restored = isTargetFocused() + } + + if (!stillOwned()) return TvSimilarFocusRestoreResult.Revoked + if (restored) return TvSimilarFocusRestoreResult.Restored + + scrollToFallback() + if (!stillOwned()) return TvSimilarFocusRestoreResult.Revoked + // Scrolling suspends and can compose the target card; do not let fallback + // focus steal a restore that completed while the scroll was in flight. + if (isTargetFocused()) return TvSimilarFocusRestoreResult.Restored + + requestFallbackFocus() + return TvSimilarFocusRestoreResult.Fallback +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt index abd6d5cbb..0813583d6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt @@ -10,31 +10,34 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.TvMediaCardActions import org.prairieserver.prairie.tv.ui.components.TvSkylineSectionFeed import org.prairieserver.prairie.tv.ui.components.isTvProgressRow +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.viewmodel.HomeViewModel -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Refresh -import androidx.tv.material3.Icon -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.Spacer internal fun shouldShowHomeEmptyState( isLoading: Boolean, @@ -59,7 +62,7 @@ fun TvHomeScreen( firstRowFocusRequester: FocusRequester? = null, firstRowContainerFocusRequester: FocusRequester? = null, shouldRefreshOnResume: () -> Boolean = { true }, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: HomeViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() @@ -99,6 +102,7 @@ fun TvHomeScreen( ) else -> TvHomeContent( sections = visibleSections, + sectionsFullyResolved = state.sectionsFullyResolved, onItemClick = onItemClick, onSeeAll = onSeeAll, onOpenForYou = onOpenForYou, @@ -126,13 +130,20 @@ private fun TvHomeEmptyState( onInitialContentFocus: () -> Unit, ) { val refreshFocusRequester = focusRequester ?: remember { FocusRequester() } + var homeContentHasFocus by remember { mutableStateOf(false) } LaunchedEffect(focusRequest) { - runCatching { refreshFocusRequester.requestFocus() } - onInitialContentFocus() + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = refreshFocusRequester::requestFocus, + isFocused = { homeContentHasFocus }, + ) + if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() } Box( modifier = Modifier .fillMaxSize() + .onFocusChanged { homeContentHasFocus = it.hasFocus } .background(MaterialTheme.colorScheme.background) .padding(48.dp), contentAlignment = Alignment.Center, @@ -156,12 +167,6 @@ private fun TvHomeEmptyState( modifier = Modifier.focusRequester(refreshFocusRequester), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Refresh", style = MaterialTheme.typography.labelLarge) } } @@ -180,15 +185,18 @@ private fun TvHomeContent( detailReturnCardFocusRequester: FocusRequester?, firstRowFocusRequester: FocusRequester?, firstRowContainerFocusRequester: FocusRequester?, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)?, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)?, onSetWatched: (String, Boolean) -> Unit = { _, _ -> }, onToggleFavorite: (String, Boolean) -> Unit = { _, _ -> }, onToggleWatchlist: (String, Boolean) -> Unit = { _, _ -> }, onDismissContinueWatching: (String, String) -> Unit = { _, _ -> }, onDismissNextUp: (String, String) -> Unit = { _, _ -> }, + sectionsFullyResolved: Boolean = true, ) { TvSkylineSectionFeed( + surfaceKey = "home", sections = sections, + sectionsComplete = sectionsFullyResolved, onItemClick = onItemClick, focusRequest = focusRequest, detailReturnFocusRequest = detailReturnFocusRequest, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt index 085024a04..5e41ebd47 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/libraries/TvLibrariesScreen.kt @@ -16,7 +16,12 @@ import org.koin.compose.viewmodel.koinViewModel @Composable fun TvLibrariesScreen( onItemClick: (contentId: String) -> Unit, - onLibraryCollectionClick: (libraryId: Int, collectionId: String, title: String) -> Unit, + onLibraryCollectionClick: ( + libraryId: Int, + collectionId: String, + title: String, + libraryType: String, + ) -> Unit, // User-created collections resolve via a different catalog source, so they // route to the user-collection detail rather than the library one (#69). onUserCollectionClick: (collectionId: String, title: String) -> Unit, @@ -51,7 +56,12 @@ fun TvLibrariesScreen( if (isUserCollection) { onUserCollectionClick(collectionId, title) } else { - onLibraryCollectionClick(selectedLibrary.id, collectionId, title) + onLibraryCollectionClick( + selectedLibrary.id, + collectionId, + title, + selectedLibrary.type, + ) } }, onInitialContentFocus = onInitialContentFocus, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt index 782ae5805..bf7e5f54d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryBrowseControls.kt @@ -37,9 +37,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key @@ -89,6 +94,11 @@ fun TvBrowseControlRow( onFilter: () -> Unit, onClearFilters: () -> Unit = {}, modifier: Modifier = Modifier, + /** + * Lets a caller point its page-entry focus claim at the Sort pill when the + * grid below has no card to give it to (an empty or fully-filtered list). + */ + sortPillFocusRequester: FocusRequester? = null, ) { // Clearing removes the Clear pill from composition; focus must hop to the // Filter pill first or it would snap away to the nearest surviving scope. @@ -99,7 +109,14 @@ fun TvBrowseControlRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - BrowseControlPill(onClick = onSort) { foreground -> + BrowseControlPill( + onClick = onSort, + modifier = if (sortPillFocusRequester != null) { + Modifier.focusRequester(sortPillFocusRequester) + } else { + Modifier + }, + ) { foreground -> Icon( imageVector = Icons.Filled.SwapVert, contentDescription = null, @@ -165,7 +182,13 @@ fun TvBrowseControlRow( if (filterCount > 0) { BrowseControlPill( onClick = { - runCatching { filterPillFocusRequester.requestFocus() } + // Clearing filters removes this pill from composition, so + // focus is moved off it first. A click handler has no + // suspend point, so the claim is single-shot and reported. + filterPillFocusRequester.claimFocusOrReport( + target = "library_filter_pill", + action = "clear_filters", + ) onClearFilters() }, ) { foreground -> @@ -253,14 +276,21 @@ fun TvBrowseSortPanel( onClose: () -> Unit, ) { val currentFocusRequester = remember { FocusRequester() } + var sortPanelHasFocus by remember { mutableStateOf(false) } LaunchedEffect(Unit) { kotlinx.coroutines.delay(50) - runCatching { currentFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = currentFocusRequester::requestFocus, + isFocused = { sortPanelHasFocus }, + ) } BrowsePanelScrim(onClose = onClose) { Column( modifier = Modifier + .onFocusChanged { sortPanelHasFocus = it.hasFocus } .width(260.dp) .tvSkylinePanelChrome() .padding(10.dp), @@ -291,7 +321,8 @@ fun TvBrowseSortPanel( maxLines = 1, ) Spacer(modifier = Modifier.weight(1f)) - if (isCurrent) { + // Source-order entries have no asc/desc to show or flip. + if (isCurrent && option.hasDirection) { Text( text = option.directionLabel(order), style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), @@ -346,14 +377,24 @@ fun TvBrowseFilterPanel( } val screenFocusRequester = remember { FocusRequester() } + var facetPanelHasFocus by remember { mutableStateOf(false) } LaunchedEffect(openFacet) { kotlinx.coroutines.delay(50) - runCatching { screenFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = screenFocusRequester::requestFocus, + isFocused = { facetPanelHasFocus }, + ) } - BrowsePanelScrim(onClose = handleBack, dismissOnBack = false) { + // Popup's dismissOnBackPress uses the supported system callback on Android + // 16. onDismissRequest still runs this two-stage values -> filters -> close + // handler, while the key handler below remains an Escape/legacy fallback. + BrowsePanelScrim(onClose = handleBack) { Column( modifier = Modifier + .onFocusChanged { facetPanelHasFocus = it.hasFocus } .width(360.dp) .height(400.dp) .tvSkylinePanelChrome() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt index 34d15c808..5f0c07eb1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailScreen.kt @@ -2,8 +2,13 @@ package org.prairieserver.prairie.tv.ui.screens.library import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -12,24 +17,32 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.prairieserver.prairie.tv.ui.components.TvCatalogEmptyState import org.prairieserver.prairie.tv.ui.components.TvCatalogGrid import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.theme.Spacing import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +/** Which overlay panel is open over the collection grid (mirrors Browse). */ +private enum class TvCollectionPanel { Sort, Filter } + @Composable fun TvLibraryCollectionDetailScreen( libraryId: Int, collectionId: String, title: String, + libraryType: String, onItemClick: (contentId: String) -> Unit, onBack: () -> Unit, viewModel: TvLibraryCollectionDetailViewModel = koinViewModel( @@ -41,58 +54,167 @@ fun TvLibraryCollectionDetailScreen( BackHandler(onBack = onBack) + var openPanel by remember { mutableStateOf(null) } + // Without an explicit focus target, the user lands on this screen with - // nothing focused and has to mash D-pad before anything responds. + // nothing focused and has to mash D-pad before anything responds. This + // fires once per visit: a sort/filter reload replaces the items, and + // re-requesting then would yank focus off whatever pill the user is on. + // + // The landing check watches the first CARD, not the page: the Sort pill is + // the grid's first focusable (header row), so Compose's default entry + // parks there before the card is composed, and a page-level hasFocus + // would report that as success and leave the user on the pill. val firstItemFocusRequester = remember { FocusRequester() } + var firstCardHasFocus by remember { mutableStateOf(false) } var initialFocusRequested by remember { mutableStateOf(false) } - LaunchedEffect(state.items.firstOrNull()?.contentId) { + LaunchedEffect(state.items.isNotEmpty()) { if (initialFocusRequested || state.items.isEmpty()) return@LaunchedEffect - runCatching { firstItemFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = firstItemFocusRequester::requestFocus, + isFocused = { firstCardHasFocus }, + ) initialFocusRequested = true } - Column( + Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - Text( - text = viewModel.title.ifBlank { title }, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding( + // The title, pills, and count live INSIDE the grid as its header + // row rather than above it. A header stacked over the grid shrinks + // the grid's viewport, so bringing row 2 into view scrolled row 1 + // half under the pills — the "cards cut off" look. As a grid row + // the header scrolls away with the content, and rows leaving the + // top go under the screen edge like any scrolling list. The grid + // also stays mounted across sort/filter reloads (spinner row), so + // the header never blinks out. + // + // Failures render in the grid's empty slot rather than replacing + // the whole surface: load() clears the items before a sort/filter + // reload, so a whole-surface error would take the Sort/Filter/Clear + // pills away exactly when the viewer needs them to undo the query + // that is failing — Retry only repeats it (Codex). + TvCatalogGrid( + items = state.items, + isLoading = state.isLoading || state.isLoadingMore, + hasMore = state.hasMore, + onItemClick = onItemClick, + onLoadMore = viewModel::loadMore, + fixedColumnCount = 6, + contentPadding = PaddingValues( start = Spacing.safeArea, top = Spacing.xxl, end = Spacing.safeArea, - bottom = Spacing.lg, + bottom = Spacing.xxxl, ), + horizontalSpacing = 20.dp, + verticalSpacing = 30.dp, + firstItemFocusRequester = firstItemFocusRequester, + firstItemCardModifier = Modifier.onFocusChanged { firstCardHasFocus = it.isFocused }, + header = { + CollectionHeader( + title = viewModel.title.ifBlank { title }, + state = state, + onSort = { openPanel = TvCollectionPanel.Sort }, + onFilter = { openPanel = TvCollectionPanel.Filter }, + onClearFilters = viewModel::clearFilters, + ) + }, + emptyState = { + val error = state.error + if (error != null) { + TvErrorScreen(message = error, onRetry = viewModel::retry) + } else { + TvCatalogEmptyState( + message = if (state.facetSelection.hasActiveFilters) { + "No titles match the current filters." + } else { + "This collection is empty." + }, + ) + } + }, ) + } - when { - state.isLoading && state.items.isEmpty() -> TvLoadingScreen() - state.error != null && state.items.isEmpty() -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::retry, - ) - else -> TvCatalogGrid( - items = state.items, - isLoading = state.isLoadingMore, - hasMore = state.hasMore, - onItemClick = onItemClick, - onLoadMore = viewModel::loadMore, - fixedColumnCount = 6, - contentPadding = androidx.compose.foundation.layout.PaddingValues( - start = Spacing.safeArea, - top = Spacing.lg, - end = Spacing.safeArea, - bottom = Spacing.xxxl, - ), - horizontalSpacing = 12.dp, - firstItemFocusRequester = firstItemFocusRequester, - emptyState = { - TvCatalogEmptyState(message = "This collection is empty.") - }, + when (openPanel) { + TvCollectionPanel.Sort -> TvBrowseSortPanel( + options = TvLibrarySortOption.availableForCollection(libraryType), + currentSort = state.sort, + order = state.order, + onSelect = { option -> + viewModel.onSortSelected(option) + openPanel = null + }, + onClose = { openPanel = null }, + ) + TvCollectionPanel.Filter -> TvBrowseFilterPanel( + libraryType = libraryType, + facetOptions = state.facetOptions, + initial = state.facetSelection, + onApply = viewModel::onFacetSelectionApplied, + onClose = { openPanel = null }, + ) + null -> Unit + } +} + +/** Title, Sort/Filter pills, and item count — the grid's spanning header row. */ +@Composable +private fun CollectionHeader( + title: String, + state: TvLibraryCollectionDetailViewModel.UiState, + onSort: () -> Unit, + onFilter: () -> Unit, + onClearFilters: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + val sortOption = state.sortOption + TvBrowseControlRow( + sortLabel = sortOption.label, + sortDirection = sortOption.directionLabel(state.order), + filterCount = state.facetSelection.activeFacetCount, + onSort = onSort, + onFilter = onFilter, + onClearFilters = onClearFilters, ) + Spacer(modifier = Modifier.weight(1f)) + itemCountLabel(state)?.let { label -> + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } } } } + +/** + * "24 items" beside the controls, counted from the cards this client actually + * shows. The server's collection total includes reading items TV hides + * (`visibleOnTv`), so reporting it would claim a count the grid can never + * reach — and would leak the excluded ebook membership. Hidden until paging + * has exhausted, which is the first moment a TV-visible count is knowable. + */ +private fun itemCountLabel(state: TvLibraryCollectionDetailViewModel.UiState): String? { + if (state.isLoading || state.isLoadingMore || state.hasMore) return null + val total = state.items.size + if (total == 0) return null + return if (total == 1) "1 item" else "$total items" +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt index 0c5bb583c..47a7426bd 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryCollectionDetailViewModel.kt @@ -3,7 +3,10 @@ package org.prairieserver.prairie.tv.ui.screens.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.CatalogEffectiveSort +import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.tv.ui.util.visibleOnTv import kotlinx.coroutines.flow.MutableStateFlow @@ -14,6 +17,7 @@ import kotlinx.coroutines.launch class TvLibraryCollectionDetailViewModel( private val sectionRepository: SectionRepository, + private val catalogRepository: CatalogRepository, private val libraryId: Int, private val collectionId: String, val title: String, @@ -25,29 +29,107 @@ class TvLibraryCollectionDetailViewModel( val items: List = emptyList(), val hasMore: Boolean = false, val error: String? = null, - ) + /** Empty = send no sort, i.e. keep the collection's own order. */ + val sort: String = TvLibrarySortOption.CollectionOrder.wireValue, + val order: String = "desc", + val facetSelection: TvCatalogFacetSelection = TvCatalogFacetSelection(), + val facetOptions: CatalogFiltersResponse? = null, + /** What the server says it sorted by (see [CatalogEffectiveSort]). */ + val effectiveSort: CatalogEffectiveSort? = null, + ) { + val sortOption: TvLibrarySortOption + get() = TvLibrarySortOption.entries.firstOrNull { it.wireValue == sort } + ?: TvLibrarySortOption.CollectionOrder + } private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + // Bumped on every reload-from-zero so a slow in-flight page from the + // previous sort/filter cannot land on top of the new one. + private var loadGeneration = 0 + init { load() + loadFacetOptions() } fun retry() { load() } + /** + * Sort panel behavior, matching Browse ([TvLibraryDetailViewModel.onSortKeySelected]): + * re-picking the active key flips direction, a new key arrives at its + * natural order. Collection order has no direction, so re-picking it is a + * no-op rather than a flip. + */ + fun onSortSelected(option: TvLibrarySortOption) { + val state = _uiState.value + val isCurrent = state.sort == option.wireValue + if (isCurrent && option == TvLibrarySortOption.CollectionOrder) return + val nextOrder = if (isCurrent) { + if (state.order == "asc") "desc" else "asc" + } else { + option.defaultOrder + } + _uiState.update { it.copy(sort = option.wireValue, order = nextOrder) } + load() + } + + fun onFacetSelectionApplied(selection: TvCatalogFacetSelection) { + if (_uiState.value.facetSelection == selection) return + _uiState.update { it.copy(facetSelection = selection) } + load() + } + + fun clearFilters() { + onFacetSelectionApplied(TvCatalogFacetSelection()) + } + + /** + * Facet vocabulary scoped to this collection, so the panel only offers + * values its members actually have. Non-fatal: without it the filter + * panel simply reports that no filters are available. + */ + private fun loadFacetOptions() { + viewModelScope.launch { + val result = catalogRepository.getFilters( + includeTechnical = true, + source = "library_collection", + collectionId = collectionId, + ) + if (result is ApiResult.Success) { + _uiState.update { it.copy(facetOptions = result.data) } + } + } + } + private fun load() { + loadGeneration += 1 + val generation = loadGeneration viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetchedCount = 0 - when (val result = fetchVisiblePage(fromOffset = 0)) { + _uiState.update { + it.copy( + isLoading = true, + isLoadingMore = false, + items = emptyList(), + hasMore = false, + error = null, + ) + } + val result = fetchVisiblePage(fromOffset = 0) + if (generation != loadGeneration) return@launch + // Only the request that still owns the screen may move the cursor; + // see [fetchVisiblePage]. + fetchedCount = if (result is ApiResult.Success) result.data.fetchedCount else 0 + when (result) { is ApiResult.Success -> _uiState.update { it.copy( isLoading = false, items = result.data.items, hasMore = result.data.hasMore, + effectiveSort = result.data.effectiveSort, error = null, ) } @@ -67,7 +149,13 @@ class TvLibraryCollectionDetailViewModel( } } - private data class VisiblePage(val items: List, val hasMore: Boolean) + private data class VisiblePage( + val items: List, + val hasMore: Boolean, + /** RAW offset this request drained to; see [fetchedCount]. */ + val fetchedCount: Int, + val effectiveSort: CatalogEffectiveSort?, + ) /** * Fetches pages starting at [fromOffset] until one yields at least one @@ -76,24 +164,54 @@ class TvLibraryCollectionDetailViewModel( * that filters to empty with `hasMore=true` would strand the grid — * TvCatalogGrid skips pagination while its list is empty, so a * book-fronted collection would wrongly render as empty (Codex). - * Advances [fetchedCount] by RAW page sizes as it goes. + * + * The raw cursor it drained to is RETURNED rather than written to + * [fetchedCount]: a request superseded by a sort/filter reload must not + * move the live query's paging offset, and only the caller — after its + * generation check — knows whether this request still owns the screen + * (Codex). */ private suspend fun fetchVisiblePage(fromOffset: Int): ApiResult { + val state = _uiState.value + val facetGroups = state.facetSelection.toQueryGroups() + // Describes the whole result set, so it comes from the first response + // of the drain, not whichever page happened to be visible. + var effectiveSort: CatalogEffectiveSort? = null + var isFirstResponse = true var offset = fromOffset while (true) { when (val result = sectionRepository.getLibraryCollectionItems( collectionId, offset = offset, limit = PAGE_SIZE, + sort = state.sort.ifBlank { null }, + order = state.order, + queryGroups = facetGroups, + match = if (facetGroups.isNotEmpty()) { + if (state.facetSelection.matchAll) "all" else "any" + } else { + null + }, )) { is ApiResult.Success -> { - fetchedCount = offset + result.data.items.size + if (isFirstResponse) { + isFirstResponse = false + effectiveSort = result.data.effectiveSort + } + val drainedTo = offset + result.data.items.size val visible = result.data.items.visibleOnTv() val hasMore = result.data.hasMore && result.data.items.isNotEmpty() if (visible.isNotEmpty() || !hasMore) { - return ApiResult.Success(VisiblePage(visible, hasMore)) + return ApiResult.Success( + VisiblePage( + items = visible, + hasMore = hasMore, + fetchedCount = drainedTo, + effectiveSort = effectiveSort, + ), + ) } - offset = fetchedCount + offset = drainedTo } is ApiResult.Error -> return ApiResult.Error(result.code, result.error, result.message) is ApiResult.NetworkError -> return ApiResult.NetworkError(result.exception) @@ -112,15 +230,22 @@ class TvLibraryCollectionDetailViewModel( fun loadMore() { val current = _uiState.value if (current.isLoading || current.isLoadingMore || !current.hasMore) return + val generation = loadGeneration viewModelScope.launch { _uiState.update { it.copy(isLoadingMore = true) } - when (val result = fetchVisiblePage(fromOffset = fetchedCount)) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoadingMore = false, - items = it.items + result.data.items, - hasMore = result.data.hasMore, - ) + val result = fetchVisiblePage(fromOffset = fetchedCount) + if (generation != loadGeneration) return@launch + when (result) { + is ApiResult.Success -> { + fetchedCount = result.data.fetchedCount + _uiState.update { + it.copy( + isLoadingMore = false, + items = (it.items + result.data.items) + .distinctBy { item -> item.contentId }, + hasMore = result.data.hasMore, + ) + } } is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { it.copy(isLoadingMore = false) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt index 04c5e8e8c..51fb18498 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -38,16 +39,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -67,8 +75,13 @@ import org.prairieserver.prairie.tv.ui.components.TvSkylineSectionFeed import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.SubtleSurface -import org.prairieserver.prairie.tv.ui.theme.TvSmoothBringIntoViewSpec -import org.prairieserver.prairie.tv.ui.theme.monoGroupHeader +import org.prairieserver.prairie.tv.ui.theme.rememberTvGridBringIntoViewSpec +import org.prairieserver.prairie.tv.ui.theme.siloCardDefaults +import org.prairieserver.prairie.tv.ui.components.TvSectionHeader +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.unit.sp import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -94,11 +107,11 @@ fun TvLibraryDetailScreen( // Collections). Null leaves the ViewModel's default (Recommended) and any // user-driven tab changes alone. initialSection: TvLibraryTab? = null, - // Monotonic nonce bumped by the host on every cascade commit. Keying the - // section-apply effect on it (not just initialSection) makes re-committing - // the SAME pill re-apply the section instead of being a silent no-op. + // Monotonic nonce bumped by the host on every cascade commit, so the + // section-apply effect below re-runs when the SAME pill is committed + // again rather than being keyed on the section value alone. sectionRequestNonce: Int = 0, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, viewModel: TvLibraryDetailViewModel = koinViewModel( key = "library-$libraryId", parameters = { parametersOf(libraryId, libraryTitle, libraryType) }, @@ -108,9 +121,10 @@ fun TvLibraryDetailScreen( // Apply the committed cascade section on entry / whenever the commit // changes it. Keyed on sectionRequestNonce (bumped on every commit) AND the - // section value, so re-committing the SAME pill re-applies the section - // rather than being a silent no-op, while a non-commit recomposition leaves - // manual in-screen tab moves untouched. + // section value, so a non-commit recomposition leaves manual in-screen tab + // moves untouched. This fires again on every re-entry — backing out of item + // detail returns to a surviving ViewModel — so onTabSelected treats the + // already-active section as a no-op and keeps the viewer's sort/filters. LaunchedEffect(sectionRequestNonce, initialSection) { initialSection?.let(viewModel::onTabSelected) } @@ -122,6 +136,7 @@ fun TvLibraryDetailScreen( ) { when (state.selectedTab) { TvLibraryTab.Recommended -> RecommendedTab( + surfaceKey = "library-$libraryId", state = state, onItemClick = onItemClick, onRetry = viewModel::retryRecommended, @@ -164,6 +179,7 @@ fun TvLibraryDetailScreen( onRetry = viewModel::retryBrowse, onInitialContentFocus = onInitialContentFocus, showAlphabetRail = true, + onContentUpFallbackChanged = onContentUpFallbackChanged, ) TvLibraryTab.RecentlyAdded -> LibraryTab( state = state, @@ -236,12 +252,14 @@ fun TvLibraryDetailScreen( @Composable private fun RecommendedTab( + /** Distinguishes this feed's saveable slots from other surfaces'. */ + surfaceKey: String, state: TvLibraryDetailViewModel.UiState, onItemClick: (String) -> Unit, onRetry: () -> Unit, onInitialContentFocus: () -> Unit, focusRequest: Int, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)?, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)?, ) { val rows = remember(state.sections) { state.sections.filter { !it.featured && it.items.isNotEmpty() } @@ -272,6 +290,7 @@ private fun RecommendedTab( } else -> { TvSkylineSectionFeed( + surfaceKey = surfaceKey, sections = rows, onItemClick = onItemClick, focusRequest = focusRequest, @@ -285,14 +304,14 @@ private fun RecommendedTab( /** Which browse overlay panel is open over the grid (tvOS `TVBrowsePanel`). */ private enum class TvBrowsePanel { Sort, Filter } -internal fun restoredLibraryFocusIndex(savedIndex: Int, itemCount: Int): Int? = - if (itemCount <= 0) null else savedIndex.coerceIn(0, itemCount - 1) - -internal fun restoredLibraryLazyGridIndex( - savedIndex: Int, - itemCount: Int, - headerCount: Int, -): Int? = restoredLibraryFocusIndex(savedIndex, itemCount)?.plus(headerCount.coerceAtLeast(0)) +/** + * The LazyGrid position of the [itemIndex]th card. + * + * Headers occupy full-span slots ahead of the cards, so the grid's own index + * runs ahead of the item index by however many are showing. + */ +internal fun libraryLazyGridIndex(itemIndex: Int, headerCount: Int): Int = + itemIndex.coerceAtLeast(0) + headerCount.coerceAtLeast(0) @Composable private fun LibraryTab( @@ -309,13 +328,11 @@ private fun LibraryTab( onSortKeySelected: (TvLibrarySortOption) -> Unit = {}, onFacetSelectionApplied: (TvCatalogFacetSelection) -> Unit = {}, /** Shell hook for overriding D-pad Up while the A–Z rail holds focus. */ - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, onClearAudiobookGroup: (() -> Unit)? = null, ) { val restoredGridItemFocusRequester = remember { FocusRequester() } val gridState = rememberLazyGridState() - var lastFocusedItemIndex by rememberSaveable(state.selectedTab) { mutableStateOf(0) } - var initialFocusRequested by remember { mutableStateOf(false) } var openPanel by remember { mutableStateOf(null) } val gridHeaderCount = listOf( showBrowseControls, @@ -323,20 +340,23 @@ private fun LibraryTab( state.selectedAudiobookGroup != null && onClearAudiobookGroup != null, ).count { it } - LaunchedEffect(state.selectedTab, state.browseItems.isNotEmpty(), gridHeaderCount) { - if (initialFocusRequested || state.browseItems.isEmpty()) return@LaunchedEffect - kotlinx.coroutines.delay(120) - val restoreIndex = restoredLibraryFocusIndex(lastFocusedItemIndex, state.browseItems.size) ?: 0 - val lazyGridIndex = restoredLibraryLazyGridIndex( - savedIndex = restoreIndex, - itemCount = state.browseItems.size, - headerCount = gridHeaderCount, - ) ?: 0 - gridState.scrollToItem(lazyGridIndex) - runCatching { restoredGridItemFocusRequester.requestFocus() } - onInitialContentFocus() - initialFocusRequested = true - } + val restoration = rememberTvFlatReturnRestoration( + itemIds = state.browseItems.map { it.contentId }, + hasMore = state.browseHasMore, + isLoadingMore = state.browseLoadingMore, + errorMessage = state.browseError, + surfaceKey = state.selectedTab.name, + onLoadMore = onLoadMore, + // Headers occupy full-span slots ahead of the cards, so the grid's own + // index runs ahead of the item index by however many are showing. + scrollToItem = { itemIndex -> + gridState.scrollToItem( + libraryLazyGridIndex(itemIndex = itemIndex, headerCount = gridHeaderCount), + ) + }, + requestFocus = restoredGridItemFocusRequester::requestFocus, + onRestored = onInitialContentFocus, + ) if (state.browseError != null && state.browseItems.isEmpty()) { TvErrorScreen( @@ -357,12 +377,27 @@ private fun LibraryTab( Box(modifier = Modifier.weight(1f)) { LibraryGrid( state = state, - onItemClick = onItemClick, + onItemClick = { contentId -> + restoration.onItemClicked( + itemId = contentId, + index = state.browseItems.indexOfFirst { it.contentId == contentId }, + ) + onItemClick(contentId) + }, onLoadMore = onLoadMore, gridState = gridState, restoredItemFocusRequester = restoredGridItemFocusRequester, - restoredItemIndex = restoredLibraryFocusIndex(lastFocusedItemIndex, state.browseItems.size), - onItemFocused = { lastFocusedItemIndex = it }, + restoredItemIndex = restoration.requesterItemIndex, + onRestoreRequesterAttached = restoration::onRequesterAttached, + onItemFocused = { index, focused -> + state.browseItems.getOrNull(index)?.let { item -> + if (focused) { + restoration.onItemFocused(item.contentId, index) + } else { + restoration.onItemFocusLost(item.contentId) + } + } + }, showGenreChips = showGenreChips, onGenreChanged = onGenreChanged, onClearAudiobookGroup = onClearAudiobookGroup, @@ -407,7 +442,7 @@ private fun LibraryTab( } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable private fun LibraryGrid( state: TvLibraryDetailViewModel.UiState, @@ -416,7 +451,12 @@ private fun LibraryGrid( gridState: LazyGridState, restoredItemFocusRequester: FocusRequester, restoredItemIndex: Int?, - onItemFocused: (Int) -> Unit, + onRestoreRequesterAttached: (String?) -> Unit, + /** + * Both edges. Gain alone makes the caller's record of what holds focus + * sticky, and the restoration reads that record as CURRENT focus. + */ + onItemFocused: (index: Int, focused: Boolean) -> Unit, showGenreChips: Boolean, onGenreChanged: (String?) -> Unit, onClearAudiobookGroup: (() -> Unit)?, @@ -425,6 +465,7 @@ private fun LibraryGrid( onOpenFilterPanel: () -> Unit = {}, onClearFilters: () -> Unit = {}, ) { + var attachedRestoreItemId by remember { mutableStateOf(null) } val nearEnd by remember( gridState, state.browseHasMore, @@ -457,18 +498,33 @@ private fun LibraryGrid( } - CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + val browseTopInset = if (showBrowseControls) LibraryBrowseContentTopInset else TvTopMenuLayout.contentTopInset + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberTvGridBringIntoViewSpec(browseTopInset), + ) { LazyVerticalGrid( state = gridState, columns = GridCells.Fixed(LibraryBrowseGridColumns), - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + // Entry lands on the return-target card while its requester is + // attached (the grid state restores the scroll, so the card the + // viewer opened is composed on the way back). Without this the + // shell's return-resume claim entered at the first focusable — + // the Sort button — and the restoration then visibly walked + // focus down to the card. + .focusProperties { + enter = { + if (attachedRestoreItemId != null) restoredItemFocusRequester else FocusRequester.Default + } + }, horizontalArrangement = Arrangement.spacedBy(LibraryGridColumnSpacing), verticalArrangement = Arrangement.spacedBy(LibraryGridRowSpacing), contentPadding = PaddingValues( start = Spacing.safeArea, // The control-row embed uses the taller tvOS library inset // (`ContinuumTheme.Skyline.libraryContentTopInset`, 216pt → 108dp). - top = if (showBrowseControls) LibraryBrowseContentTopInset else TvTopMenuLayout.contentTopInset, + top = browseTopInset, end = Spacing.md, bottom = Spacing.xxxl, ), @@ -542,6 +598,30 @@ private fun LibraryGrid( contentType = { _, item -> item.type }, ) { index, item -> val (actions, userState) = org.prairieserver.prairie.tv.ui.components.rememberTvBrowseItemCardActions(item) + if (index == restoredItemIndex) { + // Report which identity the restore requester is + // actually bound to, once composition has applied. + // "The slot is visible" does not prove that: a card can + // be laid out while the modifier still carries the + // previous binding, so a restoration gated on layout + // alone can request focus at the wrong card. + DisposableEffect(item.contentId) { + attachedRestoreItemId = item.contentId + onRestoreRequesterAttached(item.contentId) + onDispose { + // Only when this card is still the owner. When + // the requester moves, the new card attaches + // before the old one disposes, so an + // unconditional clear wipes the live attachment + // and the restoration is reported NotReady + // against a requester that is in fact bound. + if (attachedRestoreItemId == item.contentId) { + attachedRestoreItemId = null + onRestoreRequesterAttached(null) + } + } + } + } TvMediaCard( title = item.title, posterUrl = item.posterUrl, @@ -555,7 +635,7 @@ private fun LibraryGrid( focusRequester = restoredItemFocusRequester.takeIf { index == restoredItemIndex }, modifier = Modifier .fillMaxWidth() - .onFocusChanged { if (it.hasFocus) onItemFocused(index) }, + .onFocusChanged { onItemFocused(index, it.hasFocus) }, overlay = org.prairieserver.prairie.overlays.OverlayDataExtractor.fromBrowseItem(item), actions = actions, ) @@ -583,6 +663,7 @@ private fun AudiobookGroupsTab( ) { val gridState: LazyGridState = rememberLazyGridState() val firstGroupFocusRequester = remember { FocusRequester() } + var groupGridHasFocus by remember { mutableStateOf(false) } var initialFocusRequested by remember { mutableStateOf(false) } val nearEnd by remember( @@ -612,16 +693,29 @@ private fun AudiobookGroupsTab( LaunchedEffect(state.selectedTab, state.audiobookGroups.isNotEmpty()) { if (initialFocusRequested || state.audiobookGroups.isEmpty()) return@LaunchedEffect kotlinx.coroutines.delay(120) - runCatching { firstGroupFocusRequester.requestFocus() } - onInitialContentFocus() + // onInitialContentFocus() hands content focus over to the shell. Firing + // it after an unobserved claim tells the shell focus landed when it may + // not have, which is how a screen ends up with no focus owner at all — + // so it now fires only on observed acquisition. + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = firstGroupFocusRequester::requestFocus, + isFocused = { groupGridHasFocus }, + ) + if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() initialFocusRequested = true } - CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberTvGridBringIntoViewSpec(TvTopMenuLayout.contentTopInset), + ) { LazyVerticalGrid( state = gridState, columns = GridCells.Fixed(LibraryGridColumns), - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onFocusChanged { groupGridHasFocus = it.hasFocus }, horizontalArrangement = Arrangement.spacedBy(LibraryGridColumnSpacing), verticalArrangement = Arrangement.spacedBy(LibraryGridRowSpacing), contentPadding = PaddingValues( @@ -821,7 +915,7 @@ private fun GenreChipCloud( } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable private fun CollectionsTab( state: TvLibraryDetailViewModel.UiState, @@ -829,26 +923,88 @@ private fun CollectionsTab( onRetry: () -> Unit, onInitialContentFocus: () -> Unit, ) { - val firstCollectionFocusRequester = remember { FocusRequester() } + val entryFocusRequester = remember { FocusRequester() } + var collectionGridHasFocus by remember { mutableStateOf(false) } var initialFocusRequested by remember { mutableStateOf(false) } + val gridState = rememberLazyGridState() - // First collection of the first non-empty group claims initial focus. - val firstCollectionId = state.collectionSections - .firstOrNull { it.collections.isNotEmpty() } - ?.collections?.firstOrNull()?.id + // The card focus should come back to. Saveable: opening a collection is an + // outer route that takes the shell (and this tab) out of composition, so a + // plain remember forgot the card and re-entry landed on the first one. + var lastFocusedCollectionId by rememberSaveable { mutableStateOf(null) } - LaunchedEffect(firstCollectionId) { - if (initialFocusRequested || firstCollectionId == null) return@LaunchedEffect + // Entry target: the remembered card when it still exists, else the first + // collection of the first non-empty group. + val allCollectionIds = remember(state.collectionSections) { + state.collectionSections.flatMap { section -> section.collections.map { it.id } } + } + val firstCollectionId = allCollectionIds.firstOrNull() + val entryCollectionId = lastFocusedCollectionId?.takeIf { it in allCollectionIds } ?: firstCollectionId + + // Flat grid index of each collection (group headers occupy a slot each), so + // a remembered card deep in the grid can be scrolled into composition + // before its requester is asked to take focus. + val gridIndexById = remember(state.collectionSections) { + buildMap { + var index = 0 + state.collectionSections.forEach { section -> + if (section.collections.isEmpty()) return@forEach + if (section.name.isNotEmpty()) index++ + section.collections.forEach { put(it.id, index++) } + } + } + } + + LaunchedEffect(entryCollectionId) { + if (initialFocusRequested || entryCollectionId == null) return@LaunchedEffect + // Only when nothing has focus yet: the shell's return-resume claim may + // already have entered the grid via focusProperties.enter below. + if (collectionGridHasFocus) { + initialFocusRequested = true + return@LaunchedEffect + } kotlinx.coroutines.delay(120) - runCatching { firstCollectionFocusRequester.requestFocus() } - onInitialContentFocus() + if (collectionGridHasFocus) { + initialFocusRequested = true + return@LaunchedEffect + } + gridIndexById[entryCollectionId]?.let { index -> + if (gridState.layoutInfo.visibleItemsInfo.none { it.index == index }) { + gridState.scrollToItem(index) + withFrameNanos { } + } + } + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = entryFocusRequester::requestFocus, + isFocused = { collectionGridHasFocus }, + ) + if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() initialFocusRequested = true } - CompositionLocalProvider(LocalBringIntoViewSpec provides TvSmoothBringIntoViewSpec) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberTvGridBringIntoViewSpec(TvTopMenuLayout.contentTopInset), + ) { LazyVerticalGrid( + state = gridState, columns = GridCells.Fixed(LibraryGridColumns), - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onFocusChanged { collectionGridHasFocus = it.hasFocus } + // Any entry into the grid (the shell's content claim on a + // return, D-pad down from the bar) lands on the remembered + // card rather than the first one. With no collection to land on + // — loading, empty, or the initial-load error — nothing holds + // that requester, so entry has to fall back to an ordinary + // focus search or the error state's Retry button is unreachable + // (Codex). + .focusProperties { + enter = { + if (entryCollectionId != null) entryFocusRequester else FocusRequester.Default + } + }, horizontalArrangement = Arrangement.spacedBy(LibraryGridColumnSpacing), verticalArrangement = Arrangement.spacedBy(LibraryGridRowSpacing), contentPadding = PaddingValues( @@ -874,10 +1030,10 @@ private fun CollectionsTab( TvCatalogEmptyState(message = "No collections in this library.") } } - // Grouped collections (tvOS `TVLibraryCollectionsView`): a mono - // uppercase group header, then a grid of 2:3 poster cards. A - // section with an empty name (flat / ungrouped bucket) renders no - // header. + // Grouped collections (tvOS `TVLibraryCollectionsView`): the + // shared row-style section header, then a grid of 2:3 poster + // cards. A section with an empty name (flat / ungrouped bucket) + // renders no header. else -> state.collectionSections.forEachIndexed { sectionIndex, section -> if (section.collections.isEmpty()) return@forEachIndexed if (section.name.isNotEmpty()) { @@ -896,14 +1052,18 @@ private fun CollectionsTab( TvCollectionCard( collection = collection, onClick = { + lastFocusedCollectionId = collection.id onCollectionClick( collection.id, collection.name, section.kind == "user_collections", ) }, - focusRequester = firstCollectionFocusRequester - .takeIf { collection.id == firstCollectionId }, + focusRequester = entryFocusRequester + .takeIf { collection.id == entryCollectionId }, + modifier = Modifier.onFocusChanged { + if (it.isFocused) lastFocusedCollectionId = collection.id + }, ) } } @@ -912,15 +1072,19 @@ private fun CollectionsTab( } } -/** Mono uppercase group header for the grouped collections grid (tvOS §6.3). */ +/** + * Group header for the grouped collections grid — the same header the Home / + * Recommended rows use, so the page reads like the rest of the app. The grid's + * row gap ([LibraryGridRowSpacing]) sits both above and below a header slot, + * which reads loose between a header and its own cards; nudging the header + * down (draw offset only, no layout change) tucks it against its group and + * widens the gap to the previous group's captions instead. + */ @Composable private fun CollectionsGroupHeader(name: String) { - Text( - text = name.uppercase(), - style = monoGroupHeader, - color = Color.White.copy(alpha = 0.38f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + TvSectionHeader( + title = name, + modifier = Modifier.offset(y = CollectionsGroupHeaderNudge), ) } @@ -934,12 +1098,24 @@ private fun TvCollectionCard( collection: LibraryCollection, onClick: () -> Unit, focusRequester: FocusRequester? = null, + modifier: Modifier = Modifier, ) { + // Same focus treatment (scale + accent border + glow) and caption metrics + // as `TvMediaCard`, so collection posters sit alongside Browse posters + // without reading as a different card family. + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val cardFocus = siloCardDefaults(shape = TvCollectionCardShape) + Column(modifier = Modifier.fillMaxWidth()) { Card( onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp)), - modifier = Modifier + interactionSource = interactionSource, + shape = CardDefaults.shape(shape = TvCollectionCardShape), + scale = cardFocus.scale, + border = cardFocus.border, + glow = cardFocus.glow, + modifier = modifier .let { if (focusRequester != null) it.focusRequester(focusRequester) else it } .fillMaxWidth() .aspectRatio(2f / 3f), @@ -968,46 +1144,27 @@ private fun TvCollectionCard( } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(11.dp)) - // Centered caption with a caps count noun ("12 MOVIES"), matching - // tvOS `TVCollectionPosterCard`. + // Title-only caption, start-aligned like every other poster caption in + // the app. The item count was dropped: it doubled the caption height + // and made the rows read differently from Browse. Text( text = collection.name, - style = MaterialTheme.typography.titleSmall, - color = Color.White.copy(alpha = 0.92f), + style = MaterialTheme.typography.titleSmall.copy( + fontSize = 15.5.sp, + lineHeight = 18.5.sp, + ), + color = if (isFocused) Color.White else Color.White.copy(alpha = 0.78f), maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) - collectionCountText(collection)?.let { countText -> - Text( - text = countText, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.7f), - maxLines = 1, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } } } -/** `12 MOVIES`-style caps count, deriving the noun from the collection type. */ -private fun collectionCountText(collection: LibraryCollection): String? { - val count = collection.itemCount ?: return null - if (count <= 0) return null - val plural = count != 1 - val noun = when (collection.collectionType?.lowercase()) { - "movie", "movies" -> if (plural) "movies" else "movie" - "series", "show", "shows", "tvshows" -> if (plural) "shows" else "show" - "album", "albums" -> if (plural) "albums" else "album" - "audiobook", "audiobooks", "book", "books" -> if (plural) "books" else "book" - else -> if (plural) "items" else "item" - } - return "$count $noun".uppercase() -} +private val TvCollectionCardShape = RoundedCornerShape(8.dp) +private val CollectionsGroupHeaderNudge = 10.dp private fun audiobookGroupSubtitle(group: AudiobookGroup): String? { val parts = mutableListOf() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt index f88db2fe1..eb33262a1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailViewModel.kt @@ -60,6 +60,22 @@ data class TvCollectionSection( * direction hints). Wire values are the canonical server sort fields. */ enum class TvLibrarySortOption(val label: String, val wireValue: String) { + /** + * "Send no sort at all" — the server then keeps the source's intrinsic + * order (a library collection's manual / MDBList / smart order). Only + * offered where such an order exists ([availableForCollection]); the + * Browse grid has none, so it never lists this. + */ + CollectionOrder("Collection Order", ""), + /** + * The same "send no sort" behaviour for personal lists (favorites / + * watchlist), where the stored order is most-recently-saved-first. It is a + * distinct entry with its own wire value rather than a relabelled + * [CollectionOrder]: two entries sharing the empty wire value would make + * [fromWire] and the panel's current-selection lookup ambiguous. The + * personal query builder maps it back to "no sort". + */ + ListOrder("Recently Saved", "__list_order"), Title("Title", "title"), DateAdded("Date Added", "added_at"), // Server expects "year" for release-date sort (matches phone); the old @@ -72,8 +88,16 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { Narrator("Narrator", "narrator"), SeriesName("Series", "series"); + /** + * False for the "keep the source's own order" entries: they send no sort, + * so there is no asc/desc to show, flip, or arrow. + */ + val hasDirection: Boolean get() = this != CollectionOrder && this != ListOrder + /** Short hint for the active direction (tvOS `directionLabel`). */ fun directionLabel(order: String): String = when (this) { + // No direction to report — the order is whatever the source defines. + CollectionOrder, ListOrder -> "Default" Title, Author, Narrator, SeriesName -> if (order == "asc") "A–Z" else "Z–A" ReleaseDate, DateAdded -> if (order == "asc") "Oldest" else "Newest" Runtime -> if (order == "asc") "Shortest" else "Longest" @@ -91,6 +115,25 @@ enum class TvLibrarySortOption(val label: String, val wireValue: String) { } else { listOf(Title, DateAdded, ReleaseDate, Rating, Runtime, Resolution) } + + /** + * Sort keys for a library collection's detail grid. Leads with + * [CollectionOrder] because that is the collection's own curation and + * the state the page opens in; the rest follow the owning library's + * media type, so an audiobook collection offers Author/Narrator/Series + * rather than the video-only Year/Rating/Resolution keys (Codex). + */ + fun availableForCollection(libraryType: String): List = + listOf(CollectionOrder) + availableFor(libraryType) + + /** + * Sort keys for a personal list (favorites / watchlist). Leads with + * [ListOrder] — the stored order the list opens in. [DateAdded] here + * means "date added to the list", which is what the server sorts + * `added_at` by for these sources. + */ + fun availableForPersonalList(): List = + listOf(ListOrder, Title, DateAdded, ReleaseDate, Rating, Runtime) } } @@ -205,7 +248,14 @@ class TvLibraryDetailViewModel( val nextFilter = state.browseFilter.forTab(tab) val filterChanged = nextFilter != state.browseFilter val audiobookGroupBy = tab.audiobookGroupBy - if (state.selectedTab == tab && !filterChanged) return + // Re-selecting the section that is already active is a no-op. The + // screen re-issues the committed section every time it re-enters + // composition — backing out of item detail / the player returns to a + // surviving ViewModel and fires the section-apply effect again — so + // re-applying `forTab` here would reset the viewer's customised sort + // and facets back to the tab's defaults (Title A–Z). Only a genuine + // tab CHANGE applies the new tab's defaults. + if (state.selectedTab == tab) return _uiState.update { it.copy( selectedTab = tab, @@ -804,8 +854,11 @@ private val TvLibraryTab.audiobookCatalogField: String? // Mirrors the server's per-field natural direction (tvOS `defaultOrder`): // name-like fields ascend, magnitude/recency fields descend. -private val TvLibrarySortOption.defaultOrder: String +internal val TvLibrarySortOption.defaultOrder: String get() = when (this) { + // Unused — these send no sort, so no order goes with them. + TvLibrarySortOption.CollectionOrder, + TvLibrarySortOption.ListOrder, TvLibrarySortOption.Title, TvLibrarySortOption.Author, TvLibrarySortOption.Narrator, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/notifications/TvInboxScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/notifications/TvInboxScreen.kt index e71d6ab77..415bf927c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/notifications/TvInboxScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/notifications/TvInboxScreen.kt @@ -30,10 +30,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -137,20 +142,41 @@ fun TvInboxScreen( // hasUnread true→false transition — so an incoming notification being read // elsewhere can't yank focus mid-browse. var pendingMarkAllRefocus by remember { mutableStateOf(false) } + var firstRowHasFocus by remember { mutableStateOf(false) } + + // Both claims are observed rather than fire-and-forget. The first is + // acquisition — the inbox has just populated and nothing is focused yet, so + // dropping it leaves a dead D-pad on a full screen of notifications. The + // second is a relocation after Mark-all removed the focused card from + // composition, where focus is already gone and a short budget is right. LaunchedEffect(cards.isNotEmpty(), hasUnread) { if (cards.isNotEmpty() && !initialFocusRequested) { - runCatching { firstRowFocusRequester.requestFocus() } initialFocusRequested = true + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = firstRowFocusRequester::requestFocus, + isFocused = { firstRowHasFocus }, + ) } if (pendingMarkAllRefocus && !hasUnread && cards.isNotEmpty()) { pendingMarkAllRefocus = false - runCatching { firstRowFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = firstRowFocusRequester::requestFocus, + isFocused = { firstRowHasFocus }, + ) } } Column( modifier = modifier .fillMaxSize() + // Success is "focus is inside the inbox", not "the first row + // specifically" — a claim that lands anywhere in the list leaves a + // working D-pad, which is what the retry is protecting. + .onFocusChanged { firstRowHasFocus = it.hasFocus } .background(MaterialTheme.colorScheme.background), ) { Column( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt index 7dd39ab54..0392440bc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailScreen.kt @@ -31,14 +31,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -73,10 +81,6 @@ import java.time.LocalDate import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import kotlinx.coroutines.launch -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Refresh -import androidx.tv.material3.Icon -import androidx.compose.foundation.layout.Spacer /** * Android TV person detail surface — the cast/crew profile plus their @@ -137,7 +141,9 @@ private fun TvPersonDetailContent( onRetryItems: () -> Unit, onOpenItemDetail: (contentId: String) -> Unit, ) { - val firstFilterFocusRequester = remember { FocusRequester() } + val selectedFilterFocusRequester = remember { FocusRequester() } + var filterRowHasFocus by remember { mutableStateOf(false) } + var lastRefocusedFilter by remember { mutableStateOf(state.selectedFilter) } val bioFocusRequester = remember { FocusRequester() } val gridState = rememberLazyGridState() val scope = rememberCoroutineScope() @@ -152,86 +158,164 @@ private fun TvPersonDetailContent( // otherwise it just re-anchors the header like before. val hasBio = remember(person.bio) { cleanPersonBio(person.bio) != null } val focusBio = { - runCatching { bioFocusRequester.requestFocus() } + bioFocusRequester.claimFocusOrReport(target = "person_bio", action = "focus_bio") scope.launch { gridState.animateScrollToItem(0) } Unit } + val restoreItemFocusRequester = remember { FocusRequester() } + // Returns land on the poster the viewer opened. Entry does not: this page + // opens on the filter row so the identity header stays visible, so on a + // fresh arrival the restoration stands down and the effect below is + // untouched. + val restoration = rememberTvFlatReturnRestoration( + itemIds = state.items.map { it.contentId }, + hasMore = state.hasMore, + isLoadingMore = state.isLoadingItems, + errorMessage = state.pagingError, + surfaceKey = "person-${person.id}-${state.selectedFilter.name}", + onLoadMore = onLoadMore, + // The header is one full-span slot ahead of the posters, so the grid's + // own index runs one past the item index. + scrollToItem = { itemIndex -> gridState.scrollToItem(itemIndex + PersonGridHeaderSlots) }, + requestFocus = restoreItemFocusRequester::requestFocus, + onRestored = {}, + focusFirstItemWithoutTarget = false, + ) + // Enter on the filter row so the full identity header remains visible. // Moving down into the posters then scrolls the whole header away naturally. + // A return owns entry instead, so this stands aside for one. LaunchedEffect(state.availableFilters.isNotEmpty()) { if (initialFocusRequested || state.availableFilters.isEmpty()) return@LaunchedEffect + if (restoration.isReturning) return@LaunchedEffect kotlinx.coroutines.delay(120) - runCatching { firstFilterFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = selectedFilterFocusRequester::requestFocus, + isFocused = { filterRowHasFocus }, + ) initialFocusRequested = true } - TvCatalogGrid( - items = state.items, - isLoading = state.isLoadingItems, - hasMore = state.hasMore, - onItemClick = onOpenItemDetail, - onLoadMore = onLoadMore, - modifier = Modifier.fillMaxSize(), - gridState = gridState, - fixedColumnCount = PersonGridColumns, - // tvOS `TVPersonDetailContent`: 48pt page top, 72pt bottom, 40pt grid - // column spacing, 48pt header → filmography gap (all halved to dp). - contentPadding = PaddingValues( - start = Spacing.safeArea, - top = 24.dp, - end = Spacing.safeArea, - bottom = 36.dp, - ), - horizontalSpacing = PersonGridItemSpacing, - verticalSpacing = Spacing.sectionSpacing, - artworkAspectRatioForItem = ::personWorkCardAspectRatio, - header = { - Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - PersonHeader(person = person, bioFocusRequester = bioFocusRequester) - FilmographyHeader( - selected = state.selectedFilter, - availableFilters = state.availableFilters, - totalLoaded = state.items.size, - totalItems = state.totalItems, - hasMore = state.hasMore, - firstFilterFocusRequester = firstFilterFocusRequester, - onMoveUp = if (hasBio) focusBio else restoreHeaderTop, - onSelect = onFilterSelected, + // key() below disposes the whole grid on a filter change, filter row and + // all — including the chip the viewer just pressed. Nothing else would put + // focus back on it. + // + // Every change refocuses, whether a press caused it or an async gate fell + // back to All on its own. Distinguishing the two was the earlier design and + // it was wrong: it existed to avoid yanking focus off a poster the viewer + // was reading, but the key change has already destroyed that poster by the + // time this runs. There is no focus left to preserve — only focus to lose. + // + // The first composition is not a change. Seeding from the current value is + // what makes that true even on a return, where entry belongs to the + // restoration rather than to the chips. + LaunchedEffect(state.selectedFilter) { + if (state.selectedFilter == lastRefocusedFilter) return@LaunchedEffect + lastRefocusedFilter = state.selectedFilter + // key() disposes the whole grid on a filter change, including the chip + // the viewer just pressed, so this is a relocation onto a node being + // recreated — short budget, and observed rather than assumed. + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = selectedFilterFocusRequester::requestFocus, + isFocused = { filterRowHasFocus }, + ) + } + + // The surface key includes the filter, and the helper requires item + // content to be recreated when it changes. applyFilter usually empties + // the list first, which disposes the cards anyway — but not on every + // path: an asynchronous filter gate can fall back to All without + // clearing. Keying it here makes the precondition hold by construction + // rather than by timing. + key(state.selectedFilter) { + TvCatalogGrid( + items = state.items, + isLoading = state.isLoadingItems, + hasMore = state.hasMore, + onItemClick = { contentId -> + restoration.onItemClicked( + itemId = contentId, + index = state.items.indexOfFirst { it.contentId == contentId }, ) - state.pagingError?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - lineHeight = 17.sp, - ), - color = Color.White.copy(alpha = 0.62f), + onOpenItemDetail(contentId) + }, + onLoadMore = onLoadMore, + restoreItemIndex = restoration.requesterItemIndex, + restoreItemFocusRequester = restoreItemFocusRequester, + onRestoreRequesterAttached = restoration::onRequesterAttached, + onItemFocusedAtIndex = { item, index, focused -> + if (focused) { + restoration.onItemFocused(item.contentId, index) + } else { + restoration.onItemFocusLost(item.contentId) + } + }, + modifier = Modifier.fillMaxSize(), + gridState = gridState, + fixedColumnCount = PersonGridColumns, + // tvOS `TVPersonDetailContent`: 48pt page top, 72pt bottom, 40pt grid + // column spacing, 48pt header → filmography gap (all halved to dp). + contentPadding = PaddingValues( + start = Spacing.safeArea, + top = 24.dp, + end = Spacing.safeArea, + bottom = 36.dp, + ), + horizontalSpacing = PersonGridItemSpacing, + verticalSpacing = Spacing.sectionSpacing, + artworkAspectRatioForItem = ::personWorkCardAspectRatio, + header = { + // The chips live in this header and the flag belongs to the + // screen, so observe here: "focus is in the header" is the + // criterion the retries above are actually protecting. + Column( + modifier = Modifier.onFocusChanged { filterRowHasFocus = it.hasFocus }, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + PersonHeader(person = person, bioFocusRequester = bioFocusRequester) + FilmographyHeader( + selected = state.selectedFilter, + availableFilters = state.availableFilters, + totalLoaded = state.items.size, + totalItems = state.totalItems, + hasMore = state.hasMore, + selectedFilterFocusRequester = selectedFilterFocusRequester, + onMoveUp = if (hasBio) focusBio else restoreHeaderTop, + onSelect = onFilterSelected, ) - // A failed page-0 load leaves the grid with nothing - // focusable below the chips. Keep retry in the scrolling - // header instead of dead-ending on the empty state. - if (state.items.isEmpty()) { - Button( - onClick = onRetryItems, - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Retry", style = MaterialTheme.typography.labelLarge) + state.pagingError?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 17.sp, + ), + color = Color.White.copy(alpha = 0.62f), + ) + // A failed page-0 load leaves the grid with nothing + // focusable below the chips. Keep retry in the scrolling + // header instead of dead-ending on the empty state. + if (state.items.isEmpty()) { + Button( + onClick = onRetryItems, + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), + ) { + Text("Retry", style = MaterialTheme.typography.labelLarge) + } } } } - } - }, - emptyState = { - TvCatalogEmptyState(message = "No titles found.") - }, - ) + }, + emptyState = { + TvCatalogEmptyState(message = "No titles found.") + }, + ) + } } // ============================================================================ @@ -340,7 +424,14 @@ private fun TvExpandablePersonBio( // Dismissing the focusable Popup drops window focus back on the page // with no saved target; put it back on the bio the user launched from. DisposableEffect(Unit) { - onDispose { runCatching { focusRequester.requestFocus() } } + onDispose { + // Teardown: no scope left to retry in, but a dropped claim here + // leaves the page with nothing focused after the popup closes. + focusRequester.claimFocusOrReport( + target = "person_bio", + action = "popup_dismissed", + ) + } } } } @@ -356,11 +447,17 @@ private fun TvPersonBioDialog( onDismiss: () -> Unit, ) { val focus = remember { FocusRequester() } + var bioModalHasFocus by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val scrollScope = rememberCoroutineScope() LaunchedEffect(Unit) { kotlinx.coroutines.delay(50) - runCatching { focus.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = focus::requestFocus, + isFocused = { bioModalHasFocus }, + ) } Popup( alignment = Alignment.Center, @@ -411,6 +508,7 @@ private fun TvPersonBioDialog( .fillMaxSize() .verticalScroll(scrollState) .focusRequester(focus) + .onFocusChanged { bioModalHasFocus = it.hasFocus } .focusable() .padding(horizontal = 32.dp, vertical = 28.dp), ) { @@ -507,7 +605,11 @@ private fun FilmographyHeader( totalLoaded: Int, totalItems: Int, hasMore: Boolean, - firstFilterFocusRequester: FocusRequester, + /** + * Attaches to the SELECTED chip — the entry point on a fresh arrival, and + * the chip to restore after a filter change recreates this row. + */ + selectedFilterFocusRequester: FocusRequester, onMoveUp: () -> Unit, onSelect: (TvPersonMediaFilter) -> Unit, ) { @@ -567,8 +669,14 @@ private fun FilmographyHeader( label = filter.title, selected = filter == selected, onClick = { onSelect(filter) }, - modifier = if (index == 0) { - Modifier.focusRequester(firstFilterFocusRequester) + // Falls back to the first chip when the selection is not + // among the available filters, so the requester is never + // left unattached. + modifier = if ( + filter == selected || + (index == 0 && selected !in availableFilters) + ) { + Modifier.focusRequester(selectedFilterFocusRequester) } else { Modifier }, @@ -700,3 +808,6 @@ private fun personWorkCardAspectRatio(item: BrowseItem): Float? = } else { null } + +/** The person header is a single full-span slot ahead of the poster grid. */ +private const val PersonGridHeaderSlots: Int = 1 diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalListControlsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalListControlsViewModel.kt new file mode 100644 index 000000000..4d83c8b31 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalListControlsViewModel.kt @@ -0,0 +1,115 @@ +package org.prairieserver.prairie.tv.ui.screens.personal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.prairieserver.prairie.model.catalog.CatalogFiltersResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.tv.ui.screens.library.TvCatalogFacetSelection +import org.prairieserver.prairie.tv.ui.screens.library.TvLibrarySortOption +import org.prairieserver.prairie.tv.ui.screens.library.defaultOrder +import org.prairieserver.prairie.viewmodel.PersonalListQuery + +/** + * Sort/filter state for a TV personal list (favorites or watchlist). + * + * Kept out of the shared [org.prairieserver.prairie.viewmodel.PersonalListViewModel] + * deliberately: the phone clients have no such controls, and the sort keys and + * facet vocabulary are TV Browse concepts. Holding it in a Koin-scoped + * ViewModel rather than composition state is what lets a viewer leave the For + * You saved list and come back to the same sort — the same reason the shared + * list ViewModels are scoped that way. + * + * [source] is the catalog source ("favorites" / "watchlist") and is also the + * Koin key, so the two lists keep independent selections. + */ +class TvPersonalListControlsViewModel( + private val catalogRepository: CatalogRepository, + private val source: String, +) : ViewModel() { + + data class UiState( + /** [TvLibrarySortOption.ListOrder] = stored list order, i.e. no sort. */ + val sort: String = TvLibrarySortOption.ListOrder.wireValue, + val order: String = "desc", + val facetSelection: TvCatalogFacetSelection = TvCatalogFacetSelection(), + val facetOptions: CatalogFiltersResponse? = null, + ) { + val sortOption: TvLibrarySortOption + get() = TvLibrarySortOption.entries.firstOrNull { it.wireValue == sort } + ?: TvLibrarySortOption.ListOrder + + /** What the shared list ViewModel should fetch under. */ + val query: PersonalListQuery + get() { + val groups = facetSelection.toQueryGroups() + val sorted = sortOption != TvLibrarySortOption.ListOrder + return PersonalListQuery( + sort = sort.takeIf { sorted }, + order = order.takeIf { sorted }, + queryGroups = groups, + match = if (groups.isEmpty()) { + null + } else if (facetSelection.matchAll) { + "all" + } else { + "any" + }, + ) + } + } + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadFacetOptions() + } + + /** + * Matches Browse and the collection page: re-picking the active key flips + * direction, a new key arrives at its natural order, and the source-order + * entry has no direction to flip. + */ + fun onSortSelected(option: TvLibrarySortOption) { + val state = _uiState.value + val isCurrent = state.sort == option.wireValue + if (isCurrent && !option.hasDirection) return + val nextOrder = if (isCurrent) { + if (state.order == "asc") "desc" else "asc" + } else { + option.defaultOrder + } + _uiState.update { it.copy(sort = option.wireValue, order = nextOrder) } + } + + fun onFacetSelectionApplied(selection: TvCatalogFacetSelection) { + _uiState.update { it.copy(facetSelection = selection) } + } + + fun clearFilters() { + onFacetSelectionApplied(TvCatalogFacetSelection()) + } + + /** + * Facet vocabulary scoped to this list, so the panel only offers values the + * saved titles actually have. Non-fatal: without it the panel simply + * reports that no filters are available. + */ + private fun loadFacetOptions() { + viewModelScope.launch { + val result = catalogRepository.getFilters( + includeTechnical = true, + source = source, + ) + if (result is ApiResult.Success) { + _uiState.update { it.copy(facetOptions = result.data) } + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalScreens.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalScreens.kt index 693433d50..6dda82306 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalScreens.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalScreens.kt @@ -1,12 +1,15 @@ package org.prairieserver.prairie.tv.ui.screens.personal +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -24,16 +27,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.ui.focus.FocusRequester +import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.koin.core.parameter.parametersOf import org.prairieserver.prairie.tv.ui.components.TvCatalogGrid import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen +import org.prairieserver.prairie.tv.ui.screens.library.TvBrowseControlRow +import org.prairieserver.prairie.tv.ui.screens.library.TvBrowseFilterPanel +import org.prairieserver.prairie.tv.ui.screens.library.TvBrowseSortPanel +import org.prairieserver.prairie.tv.ui.screens.library.TvLibrarySortOption import org.prairieserver.prairie.tv.ui.theme.PrairieBlue import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow @@ -53,10 +65,30 @@ import org.koin.compose.viewmodel.koinViewModel * the title, and the ViewModel they read from. This file has one composable * per screen that forwards to a shared [PersonalGrid] helper. * + * Favorites and Watchlist additionally carry the Browse Sort/Filter controls, + * rendered as the grid's header row (the library collection page idiom) so + * they scroll with the content and never sit over the grid's viewport. + * History has no controls — it is a chronological log, and re-sorting it is + * not a thing the list means. + * * Navigated to from Settings → Library shortcuts (Phase F). None of the * three appears directly on the navigation rail, matching tvOS. */ +/** Which overlay panel is open over a personal grid (mirrors Browse). */ +private enum class TvPersonalPanel { Sort, Filter } + +/** Catalog sources for the two lists that support sort/filter. */ +private const val FavoritesSource = "favorites" +private const val WatchlistSource = "watchlist" + +/** + * The facet vocabulary these lists filter on. They are cross-library by + * nature, so there is no one library type to ask about; "mixed" is any + * non-audiobook-like value and selects the video facet set. + */ +private const val PersonalListFacetType = "mixed" + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvFavoritesScreen( @@ -65,12 +97,15 @@ fun TvFavoritesScreen( viewModel: FavoritesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val controls = rememberPersonalListControls(FavoritesSource, viewModel) PersonalListResumeRefresh(viewModel) PersonalGrid( title = "Favorites", + surfaceKey = "personal-favorites", icon = Icons.Filled.Favorite, emptyMessage = "No favorites yet", state = state, + controls = controls, onItemClick = onItemClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry, @@ -86,12 +121,15 @@ fun TvWatchlistScreen( viewModel: WatchlistViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val controls = rememberPersonalListControls(WatchlistSource, viewModel) PersonalListResumeRefresh(viewModel) PersonalGrid( title = "Watchlist", + surfaceKey = "personal-watchlist", icon = Icons.Outlined.BookmarkBorder, emptyMessage = "Your watchlist is empty", state = state, + controls = controls, onItemClick = onItemClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry, @@ -104,17 +142,21 @@ fun TvWatchlistScreen( fun TvFavoritesInline( onItemClick: (contentId: String) -> Unit, modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, viewModel: FavoritesViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val controls = rememberPersonalListControls(FavoritesSource, viewModel) PersonalListResumeRefresh(viewModel) PersonalInlineGrid( state = state, + controls = controls, emptyMessage = "No favorites yet", emptyIcon = Icons.Filled.Favorite, onItemClick = onItemClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry, + firstItemFocusRequester = firstItemFocusRequester, modifier = modifier, ) } @@ -124,17 +166,21 @@ fun TvFavoritesInline( fun TvWatchlistInline( onItemClick: (contentId: String) -> Unit, modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, viewModel: WatchlistViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() + val controls = rememberPersonalListControls(WatchlistSource, viewModel) PersonalListResumeRefresh(viewModel) PersonalInlineGrid( state = state, + controls = controls, emptyMessage = "Your watchlist is empty", emptyIcon = Icons.Outlined.BookmarkBorder, onItemClick = onItemClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry, + firstItemFocusRequester = firstItemFocusRequester, modifier = modifier, ) } @@ -150,9 +196,11 @@ fun TvHistoryScreen( PersonalListResumeRefresh(viewModel) PersonalGrid( title = "Watch History", + surfaceKey = "personal-history", icon = Icons.Filled.History, emptyMessage = "No watch history yet", state = state, + controls = null, onItemClick = onItemClick, onLoadMore = viewModel::loadMore, onRetry = viewModel::retry, @@ -160,6 +208,41 @@ fun TvHistoryScreen( ) } +/** + * Binds a list's sort/filter holder to the shared list ViewModel that fetches + * under it. The holder is keyed by source, so the standalone page and the For + * You inline variant of the same list share one selection, and leaving and + * returning within the session keeps it. + * + * It is resolved against the ACTIVITY's ViewModel store rather than the current + * owner: inside the nav host the current owner is the destination's back stack + * entry, so the inline For You surface and the standalone Favorites/Watchlist + * destination have different stores and the key alone would hand each its own + * holder — a sort chosen on one would not reach the other (Codex). The activity + * is the nearest store both entries share. + */ +@Composable +private fun rememberPersonalListControls( + source: String, + listViewModel: PersonalListViewModel, +): TvPersonalListControlsViewModel { + val sharedOwner = LocalActivity.current as? ViewModelStoreOwner + ?: LocalViewModelStoreOwner.current + ?: error("No ViewModelStoreOwner for personal list controls") + val controls: TvPersonalListControlsViewModel = koinViewModel( + viewModelStoreOwner = sharedOwner, + key = "personal-controls-$source", + parameters = { parametersOf(source) }, + ) + val controlsState by controls.uiState.collectAsState() + // applyQuery no-ops on an unchanged query, so this is safe to re-run on + // recomposition and on re-entry to the composition. + LaunchedEffect(controlsState.query) { + listViewModel.applyQuery(controlsState.query) + } + return controls +} + /** * Re-pull a personal list when the screen returns to the foreground. TV has no * pull-to-refresh, and these lists load once in `init` and never re-fetch on @@ -195,25 +278,49 @@ private fun PersonalGrid( title: String, icon: ImageVector, emptyMessage: String, + surfaceKey: String, state: PersonalListUiState, + controls: TvPersonalListControlsViewModel?, onItemClick: (contentId: String) -> Unit, onLoadMore: () -> Unit, onRetry: () -> Unit, onInitialContentFocus: () -> Unit, ) { val startPadding = tvPageStartPadding() - val firstItemFocusRequester = remember { FocusRequester() } - val firstItemId = state.items.firstOrNull()?.contentId + val gridState = rememberLazyGridState() + val restoreItemFocusRequester = remember { FocusRequester() } + var openPanel by remember { mutableStateOf(null) } - // One-shot guard so pagination, retries, or any other ViewModel re-emission - // doesn't yank focus back to the first card after the user has scrolled. - var initialFocusRequested by remember { mutableStateOf(false) } - LaunchedEffect(firstItemId) { - if (initialFocusRequested || firstItemId == null) return@LaunchedEffect - runCatching { firstItemFocusRequester.requestFocus() } - onInitialContentFocus() - initialFocusRequested = true - } + val restoration = rememberTvFlatReturnRestoration( + itemIds = state.items.map { it.contentId }, + hasMore = state.hasMore, + isLoadingMore = state.isLoadingMore, + // These lists refresh on every resume — exactly when a viewer comes + // back from a detail page — and a refresh REPLACES the items with page + // one rather than appending. Folding it into isLoadingMore was not + // enough: a stale multi-page list still contains the target, so it + // resolves before that flag is ever consulted. isLoading covers the + // same shape for a sort/filter change, which reorders in place: the + // outgoing list still contains the target at a position the incoming + // one will not agree with. + isReplacingContent = state.isRefreshing || state.isLoading, + errorMessage = state.error, + surfaceKey = surfaceKey, + onLoadMore = onLoadMore, + // The controls occupy a spanning grid item ahead of the cards, so an + // ITEM index is one row-slot short of the grid index. + scrollToItem = { itemIndex -> + gridState.scrollToItem(itemIndex + if (controls != null) 1 else 0) + }, + requestFocus = restoreItemFocusRequester::requestFocus, + onRestored = onInitialContentFocus, + ) + + // No separate first-entry path. On a fresh arrival the restoration already + // targets index zero, so the grid gives that slot to the restore requester + // and this one was never attached — it requested focus on nothing and then + // told the shell content had taken focus. One claimant, reporting only + // once focus is confirmed. Column( modifier = Modifier @@ -225,7 +332,7 @@ private fun PersonalGrid( start = startPadding, end = Spacing.safeArea, // Clear the floating top bar — Spacing.xxl left the header - // underneath the Prairie wordmark (QA 2026-07-08). + // underneath the Silo wordmark (QA 2026-07-08). top = TvTopMenuLayout.contentTopInset, bottom = Spacing.sm, ), @@ -236,7 +343,7 @@ private fun PersonalGrid( style = sectionEyebrow, color = PrairieBlue.copy(alpha = 0.92f), ) - androidx.compose.foundation.layout.Row( + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { @@ -254,81 +361,270 @@ private fun PersonalGrid( } } + // History has no controls to keep on screen, so it keeps the + // whole-surface loading and empty states it always had. The controlled + // lists never swap the grid out: the pills have to stay reachable, and + // a reload that hid them would strand a viewer mid-filter. + val historyWholeSurfaceState = controls == null && state.items.isEmpty() when { - state.isLoading && state.items.isEmpty() -> TvLoadingScreen() - state.error != null && state.items.isEmpty() -> TvErrorScreen( + historyWholeSurfaceState && state.isLoading -> TvLoadingScreen() + // Errors too: a failed sort/filter reload leaves the list empty, and + // a whole-surface error would take the pills away exactly when the + // viewer needs them to undo the query that is failing — Retry only + // repeats it. The controlled lists render the failure inside the + // grid instead (Codex). + historyWholeSurfaceState && state.error != null -> TvErrorScreen( message = state.error ?: "", onRetry = onRetry, ) - state.items.isEmpty() -> EmptyState( - message = emptyMessage, - icon = icon, - ) - else -> TvCatalogGrid( - items = state.items, - isLoading = state.isLoadingMore, - hasMore = state.hasMore, - onItemClick = onItemClick, - onLoadMore = onLoadMore, - contentPadding = tvPageContentPadding(top = Spacing.lg), - // Match every other catalog grid (browse/person/collections): - // the adaptive default rendered ~5 oversized columns here - // (QA 2026-07-08). - fixedColumnCount = 6, - firstItemFocusRequester = firstItemFocusRequester, - ) + historyWholeSurfaceState -> EmptyState(message = emptyMessage, icon = icon) + else -> { + // Null for History, which has no controls. Stable per call site — + // a screen either has a controls holder for its whole life or not. + val controlsState = controls?.uiState?.collectAsState()?.value + TvCatalogGrid( + items = state.items, + // A restored deep scroll position sits at the paging threshold, + // so the grid would ask for the next page the moment it lands. + // During a refresh that page is fetched at an offset the + // refresh is about to invalidate — it either gets discarded or + // lands after page one and leaves a hole. isLoading rather than + // a loading SCREEN so the header survives a sort/filter reload. + isLoading = state.isLoading || state.isLoadingMore || state.isRefreshing, + hasMore = state.hasMore, + onItemClick = { contentId -> + restoration.onItemClicked( + itemId = contentId, + index = state.items.indexOfFirst { it.contentId == contentId }, + ) + onItemClick(contentId) + }, + onLoadMore = onLoadMore, + contentPadding = tvPageContentPadding(top = Spacing.lg), + // Match every other catalog grid (browse/person/collections): + // the adaptive default rendered ~5 oversized columns here + // (QA 2026-07-08). + fixedColumnCount = 6, + gridState = gridState, + restoreItemIndex = restoration.requesterItemIndex, + restoreItemFocusRequester = restoreItemFocusRequester, + onRestoreRequesterAttached = restoration::onRequesterAttached, + onItemFocusedAtIndex = { item, index, focused -> + if (focused) { + restoration.onItemFocused(item.contentId, index) + } else { + restoration.onItemFocusLost(item.contentId) + } + }, + header = controlsState?.let { cs -> + { + PersonalControlHeader( + controlsState = cs, + total = state.total, + isLoading = state.isLoading, + onSort = { openPanel = TvPersonalPanel.Sort }, + onFilter = { openPanel = TvPersonalPanel.Filter }, + onClearFilters = { controls?.clearFilters() }, + ) + } + }, + emptyState = { + val error = state.error + if (error != null) { + TvErrorScreen(message = error, onRetry = onRetry) + } else { + EmptyState( + message = if (controlsState?.facetSelection?.hasActiveFilters == true) { + "No titles match the current filters." + } else { + emptyMessage + }, + icon = icon, + ) + } + }, + ) + } } } + + PersonalControlPanels( + controls = controls, + openPanel = openPanel, + onClose = { openPanel = null }, + ) } @Composable private fun PersonalInlineGrid( state: PersonalListUiState, + controls: TvPersonalListControlsViewModel, emptyMessage: String, emptyIcon: ImageVector, onItemClick: (contentId: String) -> Unit, onLoadMore: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, ) { + val controlsState by controls.uiState.collectAsState() + var openPanel by remember { mutableStateOf(null) } + Box( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { - when { - state.isLoading && state.items.isEmpty() -> TvLoadingScreen() - state.error != null && state.items.isEmpty() -> TvErrorScreen( - message = state.error ?: "", - onRetry = onRetry, - ) - state.items.isEmpty() -> EmptyState( - message = emptyMessage, - icon = emptyIcon, - ) - else -> TvCatalogGrid( - items = state.items, - isLoading = state.isLoadingMore, - hasMore = state.hasMore, - onItemClick = onItemClick, - onLoadMore = onLoadMore, - contentPadding = PaddingValues( - // For You's saved-list grid sits directly beneath its - // selector pills; share their exact leading edge. - start = Spacing.safeArea, - end = Spacing.safeArea, - top = Spacing.md, - bottom = Spacing.xl, - ), - fixedColumnCount = 6, + // For You hands this grid the page's focus claim, and with an empty + // list there is no card to give it to. The Sort pill takes it + // instead — without a focusable claimant the shell's handover fails + // and focus falls back to the menu bar. Only ever one holder: the + // pill takes the requester exactly when no first card exists. + // + // Not while the first page is still in flight, though: the header + // renders from frame one, so handing the pill the requester then + // would let the claim succeed on it and leave the viewer parked on + // Sort once the cards arrive. Unclaimed, the caller simply retries + // until a card exists — which is what it did before the header did. + val listIsEmpty = state.items.isEmpty() && !state.isLoading && !state.isRefreshing + TvCatalogGrid( + items = state.items, + // A restored deep scroll position sits at the paging threshold, + // so the grid would ask for the next page the moment it lands. + // During a refresh that page is fetched at an offset the + // refresh is about to invalidate — it either gets discarded or + // lands after page one and leaves a hole. isLoading keeps the + // grid (and its controls) mounted through a sort/filter reload. + isLoading = state.isLoading || state.isLoadingMore || state.isRefreshing, + hasMore = state.hasMore, + onItemClick = onItemClick, + onLoadMore = onLoadMore, + contentPadding = PaddingValues( + // For You's saved-list grid sits directly beneath its + // selector pills; share their exact leading edge. + start = Spacing.safeArea, + end = Spacing.safeArea, + top = Spacing.md, + bottom = Spacing.xl, + ), + fixedColumnCount = 6, + firstItemFocusRequester = firstItemFocusRequester.takeIf { !listIsEmpty }, + header = { + PersonalControlHeader( + controlsState = controlsState, + total = state.total, + isLoading = state.isLoading, + onSort = { openPanel = TvPersonalPanel.Sort }, + onFilter = { openPanel = TvPersonalPanel.Filter }, + onClearFilters = controls::clearFilters, + sortPillFocusRequester = firstItemFocusRequester.takeIf { listIsEmpty }, + ) + }, + emptyState = { + // Inside the grid, not over it: the pills have to stay + // reachable so a rejected filter can be changed (Codex). + val error = state.error + if (error != null) { + TvErrorScreen(message = error, onRetry = onRetry) + } else { + EmptyState( + message = if (controlsState.facetSelection.hasActiveFilters) { + "No titles match the current filters." + } else { + emptyMessage + }, + icon = emptyIcon, + ) + } + }, + ) + } + + PersonalControlPanels( + controls = controls, + openPanel = openPanel, + onClose = { openPanel = null }, + ) +} + +/** Sort/Filter pills on the left, item count on the right — the grid's header row. */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PersonalControlHeader( + controlsState: TvPersonalListControlsViewModel.UiState, + total: Int, + isLoading: Boolean, + onSort: () -> Unit, + onFilter: () -> Unit, + onClearFilters: () -> Unit, + sortPillFocusRequester: FocusRequester? = null, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val sortOption = controlsState.sortOption + TvBrowseControlRow( + sortLabel = sortOption.label, + sortDirection = sortOption.directionLabel(controlsState.order), + filterCount = controlsState.facetSelection.activeFacetCount, + onSort = onSort, + onFilter = onFilter, + onClearFilters = onClearFilters, + sortPillFocusRequester = sortPillFocusRequester, + ) + Spacer(modifier = Modifier.weight(1f)) + // Hidden until a page has landed, so the count never contradicts a + // list that is still being replaced. + if (!isLoading && total > 0) { + Text( + text = if (total == 1) "1 item" else "$total items", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), ) } } } +@Composable +private fun PersonalControlPanels( + controls: TvPersonalListControlsViewModel?, + openPanel: TvPersonalPanel?, + onClose: () -> Unit, +) { + if (controls == null) return + val controlsState by controls.uiState.collectAsState() + when (openPanel) { + TvPersonalPanel.Sort -> TvBrowseSortPanel( + options = TvLibrarySortOption.availableForPersonalList(), + currentSort = controlsState.sort, + order = controlsState.order, + onSelect = { option -> + controls.onSortSelected(option) + onClose() + }, + onClose = onClose, + ) + TvPersonalPanel.Filter -> TvBrowseFilterPanel( + libraryType = PersonalListFacetType, + facetOptions = controlsState.facetOptions, + initial = controlsState.facetSelection, + onApply = controls::onFacetSelectionApplied, + onClose = onClose, + ) + null -> Unit + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun EmptyState(message: String, icon: ImageVector) { +private fun EmptyState( + message: String, + icon: ImageVector, +) { + // No focusable claimant here any more: the controlled lists park the For + // You focus claim on the Sort pill instead, which is a real control rather + // than an invisible focus sink over a message. Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt index 11a4fdced..ab5062a06 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAiTranslateDialog.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -35,8 +34,8 @@ import androidx.compose.ui.window.PopupProperties import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import kotlinx.coroutines.delay import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus import org.prairieserver.prairie.tv.ui.theme.DarkBackground /** Which capture mode the dialog is in — availability comes from AiStatus. */ @@ -92,14 +91,42 @@ fun TvAiTranslateDialog( // when the Failed/Idle form (or the Running Cancel row) comes back — // otherwise the dialog is dead to the d-pad. Submitting itself has nothing // to focus, so it is skipped. - var overlayHasFocus by remember { mutableStateOf(false) } - LaunchedEffect(aiState.phase) { - if (aiState.phase is AiJobPhase.Submitting) return@LaunchedEffect - while (!overlayHasFocus) { - runCatching { firstRowFocus.requestFocus() } - delay(60) - } + // + // Bounded, via the shared adapter. The loop this replaces was `while + // (!overlayHasFocus)` with no exit: on the empty state, where nothing at all + // was focusable, it re-requested a target that was not in the tree every + // 60 ms for as long as the dialog stayed open. Bounding it alone would not + // have saved that state — with no focus target in the tree the traversal + // fallback has nothing to find either. The Close row below is what makes + // the empty state recoverable; the bound is what stops the spinning. + // + // Keyed on the shape of the focus graph, not on the phase value. Two + // separate reasons: + // - Running carries a progress percentage that changes several times a + // second, so keying on the phase itself would restart acquisition + // throughout the job. + // - Phase alone is not enough. Track availability is derived from the + // player's session tracks and can change under an open dialog, which + // swaps the empty state for the picker form (or back) without the phase + // moving at all. That removes the focused row and composes new ones, so + // it has to re-key or the dialog goes dead in place. + // Row *enablement* deliberately does not appear here: a quota-exhausted + // submit row stays focusable and only refuses to act, so it never strands + // focus. + val bodyKey = when { + aiState.phase is AiJobPhase.Running -> "running" + aiState.phase == AiJobPhase.Submitting -> "submitting" + !subtitlesAvailable && !audioAvailable -> "form-empty" + // Both modes available adds the Mode row, which is where firstRowFocus + // attaches; with one mode it moves to the source row instead. + subtitlesAvailable && audioAvailable -> "form-both-modes" + else -> "form-single-mode" } + val initialFocusModifier = rememberTvDialogInitialFocus( + target = firstRowFocus, + reacquireKey = bodyKey, + enabled = aiState.phase !is AiJobPhase.Submitting, + ) LaunchedEffect(aiState.completedNonce) { if (aiState.completedNonce != initialNonce) onDismiss() } @@ -127,7 +154,7 @@ fun TvAiTranslateDialog( .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) .padding(horizontal = 14.dp, vertical = 14.dp) - .onFocusChanged { overlayHasFocus = it.hasFocus }, + .then(initialFocusModifier), verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( @@ -170,6 +197,16 @@ fun TvAiTranslateDialog( color = Color.White.copy(alpha = 0.66f), modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), ) + // The empty state used to render explanatory text and + // nothing else: no focusable, so `firstRowFocus` was + // attached to nothing and the dialog opened with the + // d-pad dead and no visible way out. Back dismissed it, + // but nothing on screen said so. + TvDialogActionRow( + title = "Close", + onClick = onDismiss, + modifier = Modifier.focusRequester(firstRowFocus), + ) } else { if (subtitlesAvailable && audioAvailable) { TvDialogCyclerRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt index 5f993a452..110405982 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvIntroAutoSkipBanner.kt @@ -1,57 +1,56 @@ package org.prairieserver.prairie.tv.ui.screens.player import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.prairieserver.prairie.domain.player.IntroAutoSkipController import org.prairieserver.prairie.domain.player.IntroAutoSkipState +import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus /** - * TV variant of the phone's `IntroAutoSkipBanner`. Larger touch targets (TV - * scale), focus-driven instead of touch-driven, and a focus ring on the - * actionable controls. + * TV variant of the phone's `IntroAutoSkipBanner` — the single intro-skip pill. * - * The Cancel button auto-focuses the moment we enter [IntroAutoSkipState.CountingDown] - * so the user can press D-pad Select to cancel without first navigating to the - * banner. While Cancel is focused, scrubber / transport focus is unaffected - * because the banner participates in the same focus tree as the rest of the - * idle overlay — pressing arrow keys away will move focus back to scrubber / - * play-pause. + * Two copies, one treatment: "Skip Intro" while the `ask` offer is up, and a + * small "Intro skipped" caption over a "Watch Intro" button while `always`'s + * undo is — the confirmation and the action are separate lines so neither + * has to read as the other. The fill tracks the + * time left and lands full exactly as the timer ends. Select, Back and D-pad + * are handled by the player screen's root key handler, not here, because the + * pill is not reliably in the focus tree. * * The component itself never positions itself; the parent should anchor it * (typically bottom-end above the transport cluster). @@ -59,33 +58,97 @@ import org.prairieserver.prairie.domain.player.IntroAutoSkipState @Composable fun TvIntroAutoSkipBanner( state: IntroAutoSkipState, - onSkipNow: () -> Unit, - onCancelCountdown: () -> Unit, + onSelect: () -> Unit, modifier: Modifier = Modifier, - totalSeconds: Int = 5, + totalSeconds: Int = IntroAutoSkipController.DEFAULT_COUNTDOWN_SECONDS, + /** + * Bumped by the controller whenever the timer (re)starts — a fresh offer, + * or a resume after a pause froze it. The fill re-anchors its frame clock + * on it, since [state] alone cannot tell a tick from a restart. + */ + countdownRun: Int = 0, + /** + * False while the pill is up but the timer is frozen by a pause. The fill + * holds where it is rather than continuing to creep — it is a promise about + * when something happens, and while paused nothing is going to. + */ + timerRunning: Boolean = true, + /** + * False while something else owns focus for a reason the viewer would not + * want interrupted — a timeline scrub in particular. + * + * The scrubber treats losing focus as COMMIT, not cancel, so a prompt that + * appears mid-scrub and claims focus commits a seek the viewer never + * confirmed. The pill still appears and is still reachable; it simply + * does not take focus out from under them. + */ + mayTakeFocus: Boolean = true, ) { + // Keyed on the state kind so the per-second ticks recompose this slot rather + // than recreating the subtree, which would restart the fill. + val slot = when (state) { + IntroAutoSkipState.Hidden -> 0 + is IntroAutoSkipState.Asking -> 1 + is IntroAutoSkipState.Skipped -> 2 + } + // The fill shows time remaining, so it runs off the frame clock: Compose + // scales AnimationSpec durations by the device animation setting, which would + // let the bar disagree with the timer. Transitions below still honor it. + val fill = remember { mutableFloatStateOf(0f) } + val secondsRemaining = state.secondsRemainingOrNull + // Deliberately not keyed on `secondsRemaining`: a plain tick must not + // restart the sweep. `countdownRun` is what says the clock moved. + LaunchedEffect(countdownRun, timerRunning, totalSeconds, secondsRemaining == null) { + if (secondsRemaining == null || totalSeconds <= 0) { + fill.floatValue = 0f + return@LaunchedEffect + } + val remaining = secondsRemaining.coerceAtLeast(1) + val from = (1f - remaining.toFloat() / totalSeconds.toFloat()).coerceIn(0f, 1f) + fill.floatValue = from + // Frozen: the bar sits at the fraction the frozen number describes. + if (!timerRunning) return@LaunchedEffect + val durationMs = remaining * 1000f + val startedAt = withFrameMillis { it } + var progressed = 0f + while (progressed < 1f) { + val frameMs = withFrameMillis { it } + progressed = ((frameMs - startedAt) / durationMs).coerceIn(0f, 1f) + fill.floatValue = from + (1f - from) * progressed + } + } + AnimatedContent( - targetState = state, + targetState = slot, transitionSpec = { + // Instant exit, no SizeTransform: the default shrink reads as the + // button minimizing away after a skip press. fadeIn(animationSpec = tween(durationMillis = 200)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 200)) + ExitTransition.None using null }, label = "tvIntroAutoSkipBanner", modifier = modifier, - ) { current -> - when (current) { - IntroAutoSkipState.Hidden -> { + ) { currentSlot -> + when (currentSlot) { + 0 -> { // Render nothing but stay in the layout slot so AnimatedContent can fade in/out. Spacer(Modifier.size(0.dp)) } - IntroAutoSkipState.ShowingButton -> { - TvSkipIntroButton(onClick = onSkipNow) + 1 -> { + TvIntroPromptPill( + label = stringResource(R.string.intro_skip_pill_skip), + progress = fill.floatValue, + onSelect = onSelect, + autoFocus = mayTakeFocus, + ) } - is IntroAutoSkipState.CountingDown -> { - TvCountingDownPanel( - secondsRemaining = current.secondsRemaining, - totalSeconds = totalSeconds, - onCancel = onCancelCountdown, + else -> { + TvIntroPromptPill( + label = stringResource(R.string.intro_skip_pill_undo), + caption = stringResource(R.string.intro_skip_pill_undo_caption), + progress = fill.floatValue, + onSelect = onSelect, + autoFocus = mayTakeFocus, ) } } @@ -93,148 +156,79 @@ fun TvIntroAutoSkipBanner( } /** - * The "Skip Intro" pill — focusable, white background, black text. Focus ring: - * 2dp white border + 8% white scrim wash to read clearly against the dimmed - * gradient scrim of the player overlay. + * The pill both copies share: black capsule, white focus ring, and a fill that + * creeps left-to-right as the timer runs out. [progress] is driven by the + * banner so it tracks the live timer even if this pill composes late. Dimmed + * when unfocused, lit when focused. An optional [caption] sits above the + * capsule, end-aligned and outside the focusable, so it never competes with + * the action for the viewer's read. + * + * Select and Back live in the player screen's root key handler, because this + * pill is not reliably in the focus tree. */ @Composable -private fun TvSkipIntroButton(onClick: () -> Unit) { +private fun TvIntroPromptPill( + label: String, + progress: Float, + onSelect: () -> Unit, + autoFocus: Boolean, + caption: String? = null, +) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() + val focusRequester = remember { FocusRequester() } + // Captured once: a later recomposition must not re-claim focus the viewer + // has since moved elsewhere. + val shouldFocus = remember { autoFocus } + val initialFocusModifier = rememberTvContentInitialFocus( + target = focusRequester, + contentKey = if (shouldFocus) Unit else null, + ) val shape = RoundedCornerShape(28.dp) - val borderColor = if (isFocused) Color.White else Color.Transparent - val containerScrim = if (isFocused) Color.White.copy(alpha = 0.08f) else Color.Transparent - Box( - modifier = Modifier - .clip(shape) - .background(Color.White, shape) - .border(BorderStroke(2.dp, borderColor), shape) - .background(containerScrim, shape) - .focusable(enabled = true, interactionSource = interactionSource) - .clickable(interactionSource = interactionSource, indication = null) { onClick() } - .padding(horizontal = 28.dp, vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Skip Intro", - color = Color.Black, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -/** - * Countdown panel — countdown ring + label + focusable Cancel. Cancel auto-focuses - * on first emission so D-pad Select cancels immediately. Cancel uses a transparent - * background with a white border for contrast against the dimmed gradient scrim. - */ -@Composable -private fun TvCountingDownPanel( - secondsRemaining: Int, - totalSeconds: Int, - onCancel: () -> Unit, -) { - val cancelFocus = remember { FocusRequester() } - - // Auto-focus Cancel on the first frame this state is shown, so a D-pad - // Select press cancels without user navigation. Re-fires whenever the - // banner re-enters CountingDown after a cancel (the AnimatedContent - // recomposes with a fresh subtree on every state transition). - LaunchedEffect(Unit) { - runCatching { cancelFocus.requestFocus() } - } - - Surface( - color = Color.Black.copy(alpha = 0.65f), - shape = RoundedCornerShape(28.dp), - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(14.dp), - ) { - TvCountdownRing( - secondsRemaining = secondsRemaining, - totalSeconds = totalSeconds, - ) + val borderColor = if (isFocused) Color.White else Color.White.copy(alpha = 0.25f) + Column(horizontalAlignment = Alignment.End) { + if (caption != null) { Text( - text = "Skipping intro", - color = Color.White, - fontSize = 16.sp, - ) - TvCancelButton( - focusRequester = cancelFocus, - onClick = onCancel, + text = caption, + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(end = 12.dp, bottom = 6.dp), ) } - } -} - -@Composable -private fun TvCancelButton( - focusRequester: FocusRequester, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - - val shape = RoundedCornerShape(20.dp) - val borderColor = if (isFocused) Color.White else Color.White.copy(alpha = 0.6f) - val containerScrim = if (isFocused) Color.White.copy(alpha = 0.10f) else Color.Transparent - Box( - modifier = Modifier - .clip(shape) - .background(containerScrim, shape) - .border(BorderStroke(2.dp, borderColor), shape) - .focusRequester(focusRequester) - .focusable(enabled = true, interactionSource = interactionSource) - .clickable(interactionSource = interactionSource, indication = null) { onClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Cancel", - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - ) - } -} - -/** - * 32dp circular countdown ring with the remaining digit centered. Drawn from - * 0° (top) sweeping clockwise so the ring shrinks as the countdown progresses. - */ -@Composable -private fun TvCountdownRing( - secondsRemaining: Int, - totalSeconds: Int, -) { - Box( - modifier = Modifier.size(32.dp), - contentAlignment = Alignment.Center, - ) { - Canvas(modifier = Modifier.size(32.dp)) { - val strokeWidth = 3.dp.toPx() - val sweep = if (totalSeconds <= 0) 0f - else (secondsRemaining.coerceAtLeast(0).toFloat() / totalSeconds.toFloat()) * 360f - val inset = strokeWidth / 2f - drawArc( - color = Color.White, - startAngle = -90f, - sweepAngle = sweep, - useCenter = false, - topLeft = Offset(inset, inset), - size = Size(size.width - strokeWidth, size.height - strokeWidth), - style = Stroke(width = strokeWidth, cap = StrokeCap.Round), + Box( + modifier = Modifier + .then(initialFocusModifier) + .clip(shape) + .background(Color.Black.copy(alpha = 0.65f), shape) + .border(BorderStroke(2.dp, borderColor), shape) + .focusRequester(focusRequester) + .clickable(interactionSource = interactionSource, indication = null) { onSelect() }, + ) { + // Sized to the pill via matchParentSize; plain fillMaxWidth/Height would + // take the screen's constraints instead. + Box(Modifier.matchParentSize()) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .fillMaxHeight() + .background( + if (isFocused) { + Color.White.copy(alpha = 0.40f) + } else { + Color.White.copy(alpha = 0.14f) + }, + ), + ) + } + Text( + text = label, + color = if (isFocused) Color.White else Color.White.copy(alpha = 0.55f), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 18.dp), ) } - Text( - text = secondsRemaining.coerceAtLeast(0).toString(), - color = Color.White, - fontSize = 16.sp, - ) } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt index 6ee8c7bf4..f3addb670 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackRealtimeController.kt @@ -4,6 +4,7 @@ import org.prairieserver.prairie.network.PlaybackRealtimeClient import org.prairieserver.prairie.network.PlaybackRealtimeEvent import org.prairieserver.prairie.playback.PlaybackAction import org.prairieserver.prairie.playback.decodeMarkersUpdate +import org.prairieserver.prairie.playback.decodePlaybackSubtitleReady import org.prairieserver.prairie.playback.decidePlaybackAction import org.prairieserver.prairie.playback.isTransport import kotlinx.coroutines.CancellationException @@ -87,10 +88,10 @@ class TvPlaybackRealtimeController( private suspend fun handleServerEvent(event: PlaybackRealtimeEvent.ServerEvent) { when (event.name) { - "subtitle_ready" -> viewModel.refreshSubtitles(autoSelectSubtitleId = null) + "subtitle_ready" -> viewModel.applySubtitleReady(decodePlaybackSubtitleReady(event)) "markers_updated" -> { val markers = decodeMarkersUpdate(event) - viewModel.applyUpdatedMarkers(markers.intro, markers.credits) + viewModel.applyUpdatedMarkers(markers.intro, markers.credits, markers.recap, markers.preview) } // chapter_thumbnail_ready: no scrubber-thumbnail UI yet → nothing to update. else -> { /* ignore */ } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt index 0ada48362..5ec29d191 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt @@ -1,16 +1,16 @@ package org.prairieserver.prairie.tv.ui.screens.player +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -20,9 +20,11 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -31,8 +33,12 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight @@ -44,11 +50,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -66,13 +77,17 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import kotlinx.coroutines.launch import org.prairieserver.prairie.common.player.PlayerStatsSnapshot +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.common.player.SleepTimerState import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan @@ -81,17 +96,34 @@ import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated -private val HudMaxWidth = 680.dp -private val HudMinHeight = 290.dp -private val HudMaxHeight = 360.dp -private val HudPanelCorner = 18.dp -private val HudPanelPadding = 22.dp -private val HudContentGap = 14.dp -private val HudTabHeight = 40.dp -private val HudPaneBottomPadding = 14.dp -private val HudPaneColumnGap = 32.dp +// Geometry follows tvOS TVPlayerInfoHUD at the 0.5x point→dp map, adjusted for +// Android's larger body type: the card is WIDE and SHORT (tvOS 1100×380pt on a +// 1920×1080 canvas — 57% × 35%), with the tab rail floating above it on the +// video rather than inside it. The previous card was 51% × 67% as rendered — a +// portrait slab on a landscape screen, sitting on faces. +private val HudWidthFraction = 0.74f +private val HudMaxWidth = 720.dp +/** + * Card height wraps the pane between these bounds. A fixed 360dp height left + * Audio (two rows) and Stats (nine) as the same 60%-empty slab; wrapping lets + * a two-row pane be a two-row card. The max keeps long panes scrolling inside + * the card rather than growing it down over the transport. + */ +private val HudCardMinHeight = 156.dp +private val HudCardMaxHeight = 300.dp +private val HudPanelCorner = 16.dp +private val HudPanelPadding = 20.dp +private val HudTabCardGap = 12.dp +private val HudTabHeight = 38.dp +private val HudPaneBottomPadding = 4.dp +private val HudPaneColumnGap = 36.dp private val HudTitleTextSize = 21.sp private val HudTitleLineHeight = 25.sp private val HudBodyTextSize = 16.sp @@ -134,6 +166,7 @@ private val LocalHudPickerReturnFocus = * + scrolls to the selection, commits on Select and closes, and dismisses on * Back. */ +@OptIn(ExperimentalComposeUiApi::class) // focusProperties enter/exit @Composable internal fun TvPlayerHud( title: String, @@ -151,6 +184,8 @@ internal fun TvPlayerHud( subtitlePresentation: TvSubtitleHudPresentation, stats: PlayerStatsSnapshot, playbackPlan: PlaybackExecutionPlan? = null, + desiredAudioOrdinal: Int? = null, + desiredAudioConfirmed: Boolean = false, videoFillMode: VideoFillMode, onSelectAudio: (Int) -> Unit, onSelectVideoQuality: (String) -> Unit, @@ -160,8 +195,8 @@ internal fun TvPlayerHud( sleepTimerState: SleepTimerState, onStartSleepTimer: (Int) -> Unit, onCancelSleepTimer: () -> Unit, - autoSkipIntro: Boolean, - onAutoSkipIntroChanged: (Boolean) -> Unit, + introSkipMode: IntroSkipMode, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, autoPlayNext: Boolean, onAutoPlayNextChanged: (Boolean) -> Unit, audioDelayMs: Int, @@ -179,11 +214,12 @@ internal fun TvPlayerHud( onHdrEnabledChanged: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, onDolbyVisionEnabledChanged: (Boolean) -> Unit, + /** True while a DV toggle's in-place session restart is still pending. */ + dolbyVisionSwitchInFlight: Boolean = false, chapters: List, onSelectChapter: (Int) -> Unit, onDismiss: () -> Unit, initialTab: HudTab = HudTab.Info, - onPickerOpenChanged: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) { val tabs = visibleHudTabs( @@ -211,6 +247,23 @@ internal fun TvPlayerHud( val tabFocusRequesters = remember(tabs) { tabs.associateWith { FocusRequester() } } + // The pane's entry point: each pane attaches this to its first focusable + // row, and the card's custom `enter` sends a Down from the rail there. + // Only one pane is composed at a time, so one requester serves them all. + val paneEntryFocus = remember { FocusRequester() } + val activeVersion = fileVersions.firstOrNull { it.fileId == selectedFileId } + ?: fileVersions.firstOrNull() + // Whether the selected pane has a row the entry requester is attached to. + // Redirecting `enter` to an unattached requester cancels the move (and logs + // a Compose warning), so read-only panes and an all-disabled Audio pane + // fall back to the default search instead. + val paneEntryAvailable = when (selectedTab) { + HudTab.Video, HudTab.Subtitles -> true + HudTab.Audio -> activeVersion?.audioTracks.orEmpty().size > 1 || audioDelayEnabled + HudTab.Chapters -> chapters.isNotEmpty() + HudTab.Info, HudTab.Stats -> false + } + // Preserve the user's current tab when the visible-tabs list changes (Stats / // Audio / Chapters arriving asynchronously): only re-seed from initialTab when // the caller actually requests a different tab, or when the currently-selected @@ -226,9 +279,22 @@ internal fun TvPlayerHud( lastInitialTab = initialTab } + var hudHasFocus by remember { mutableStateOf(false) } + // Seed focus on the active tab pill when the HUD first appears. LaunchedEffect(Unit) { - tabFocusRequesters[selectedTab]?.let { runCatching { it.requestFocus() } } + tabFocusRequesters[selectedTab]?.let { requester -> + val claimed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = requester::requestFocus, + isFocused = { hudHasFocus }, + ) + TvFocusLog.d { "hud initial focus claim tab=$selectedTab claimed=$claimed" } + } + } + LaunchedEffect(hudHasFocus) { + TvFocusLog.d { "hud hasFocus=$hudHasFocus" } } // When a picker closes, return focus to the setting row that opened it rather @@ -239,7 +305,14 @@ internal fun TvPlayerHud( val target = pickerReturnFocus.value if (target != null) { pickerReturnFocus.value = null - runCatching { target.requestFocus() } + // Relocation: the picker has closed and focus is coming back to + // the row that opened it, which is being recomposed underneath. + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = target::requestFocus, + isFocused = { hudHasFocus }, + ) } } } @@ -269,54 +342,66 @@ internal fun TvPlayerHud( val presentPicker: (HudPickerPresentation) -> Unit = { activePicker = it } val closePicker: () -> Unit = { activePicker = null } - // Hoist picker-open state up so the screen-level BackHandler can defer to - // the picker (Back should dismiss only the active picker, not the whole - // HUD, while a picker is open). - val pickerOpen = activePicker != null - LaunchedEffect(pickerOpen) { onPickerOpenChanged(pickerOpen) } - - // Top-center card. No full-screen scrim — the video stays visible behind it. + // Android 16 no longer dispatches KEYCODE_BACK to target-36 apps. Register + // the picker as the most specific callback; when it is closed, the player + // screen's callback remains responsible for dismissing the HUD itself. + BackHandler(enabled = activePicker != null) { closePicker() } + + // Top-center: a floating tab rail over the video, and a card beneath it + // holding only the pane — the TVPlayerInfoHUD composition. No full-screen + // scrim; the picture stays visible. + // + // fillMaxWidth BEFORE widthIn. Chained the other way round, fillMaxWidth + // sees the already-capped max and takes its fraction of THAT: 0.72 × 680 = + // 490dp, which is what actually rendered — narrow enough to clip the tab + // rail ("Chap…") and cramp every two-column pane. Box( modifier = modifier + .onFocusChanged { hudHasFocus = it.hasFocus } + .fillMaxWidth(HudWidthFraction) .widthIn(max = HudMaxWidth) - .fillMaxWidth(0.72f) - .heightIn(min = HudMinHeight, max = HudMaxHeight) - .clip(RoundedCornerShape(HudPanelCorner)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.94f)) - .border( - width = 0.5.dp, - color = Color.White.copy(alpha = 0.14f), - shape = RoundedCornerShape(HudPanelCorner), - ) .onPreviewKeyEvent { ev -> - if (ev.type == KeyEventType.KeyUp && - (ev.key == Key.Back || ev.key == Key.Escape) - ) { - // Back closes the picker first (if open), else dismisses the HUD. - if (activePicker != null) { - activePicker = null - } else { - onDismiss() + if (ev.key != Key.Back && ev.key != Key.Escape) return@onPreviewKeyEvent false + TvFocusLog.d { "hud key BACK type=${ev.type} picker=${activePicker != null}" } + when (ev.type) { + // Consume the DOWN too, not just the UP. Compose maps an + // unconsumed Back/Escape KeyDown to FocusDirection.Exit + // (FocusInteropUtils.toFocusDirection) and the root + // AndroidComposeView runs a focus search on it — which + // moves focus out of the HUD before the UP arrives. Key + // events only route to the focused subtree, so the UP then + // never reached this handler: the panel stayed up with no + // focused pill, and it took a second press (unconsumed → + // onBackPressed → BackHandler) to close it. + KeyEventType.KeyDown -> true + KeyEventType.KeyUp -> { + // Pre-Android-16 remote and keyboard fallback. System + // Back uses the callbacks above and on TvPlayerScreen. + if (activePicker != null) { + activePicker = null + } else { + TvFocusLog.d { "hud key BACK -> onDismiss" } + onDismiss() + } + true } - true - } else { - false + else -> false } - } - .padding(HudPanelPadding), + }, ) { CompositionLocalProvider(LocalHudPickerReturnFocus provides registerPickerReturnFocus) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .graphicsLayer { alpha = if (activePicker != null) 0.28f else 1f }, - verticalArrangement = Arrangement.spacedBy(HudContentGap), + verticalArrangement = Arrangement.spacedBy(HudTabCardGap), + horizontalAlignment = Alignment.CenterHorizontally, ) { - // Horizontal pill tab bar at the top. + // Floating tab rail. Centred like the tvOS HStack; the scroll is a + // safety net for very long localised labels — at this width the six + // English tabs fit with room. Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { tabs.forEach { tab -> @@ -334,8 +419,62 @@ internal fun TvPlayerHud( } } - // Content pane below the tab bar. - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + // The card: wraps its pane between the height bounds, so a + // two-row Audio pane is a two-row card and a nine-row Stats pane + // scrolls inside a full one. Shadow sits outside the clip. + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = HudCardMinHeight, max = HudCardMaxHeight) + .animateContentSize(animationSpec = tween(160)) + // The card is a focus group so the rail↔pane hand-offs are + // deliberate rather than geometric. Compose picks a 2D + // candidate first and only then consults these on the + // groups being entered/left, so both redirects apply to + // every row regardless of which control was "nearest". + .focusProperties { + // Down from a pill lands on the pane's FIRST row — the + // top-left control — not whichever swatch or row happens + // to sit under that pill. + enter = { direction -> + if (direction == FocusDirection.Down && paneEntryAvailable) { + paneEntryFocus + } else { + FocusRequester.Default + } + } + // Up out of the pane returns to the SELECTED pill. With + // focus-driven selection, the nearest pill would switch + // panes as a side effect of leaving (tvOS: defaultFocus + // on activeTab, for the same reason). + exit = { direction -> + if (direction == FocusDirection.Up) { + tabFocusRequesters[selectedTab] ?: FocusRequester.Default + } else { + FocusRequester.Default + } + } + } + .focusGroup() + .shadow( + elevation = 14.dp, + shape = RoundedCornerShape(HudPanelCorner), + ambientColor = Color.Black.copy(alpha = 0.6f), + spotColor = Color.Black.copy(alpha = 0.6f), + ) + .clip(RoundedCornerShape(HudPanelCorner)) + // Near-opaque. The picture showing through the card read as + // "glass" but cost legibility over bright or busy frames — + // and this is a settings surface people squint at from the + // sofa. Keep the video visible AROUND the card, not through it. + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.96f)) + .border( + width = 0.5.dp, + color = Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(HudPanelCorner), + ) + .padding(HudPanelPadding), + ) { when (selectedTab) { HudTab.Info -> HudPaneViewport { HudInfoPane( @@ -346,8 +485,10 @@ internal fun TvPlayerHud( episodeNumber = episodeNumber, stats = stats, playbackPlan = playbackPlan, - subtitleTracks = subtitleTracks, - subtitleUrls = subtitleUrls, + subtitleLabel = subtitlePresentation.rows + .firstOrNull { row -> row.checked } + ?.label + ?: "Off", chapters = chapters, ) } @@ -362,6 +503,7 @@ internal fun TvPlayerHud( onHdrEnabledChanged = onHdrEnabledChanged, dolbyVisionEnabled = dolbyVisionEnabled, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, + dolbyVisionSwitchInFlight = dolbyVisionSwitchInFlight, fillMode = videoFillMode, onFillModeChanged = onVideoFillModeChanged, playbackSpeed = playbackSpeed, @@ -369,19 +511,34 @@ internal fun TvPlayerHud( sleepTimerState = sleepTimerState, onStartSleepTimer = onStartSleepTimer, onCancelSleepTimer = onCancelSleepTimer, - autoSkipIntro = autoSkipIntro, - onAutoSkipIntroChanged = onAutoSkipIntroChanged, + introSkipMode = introSkipMode, + onIntroSkipModeChanged = onIntroSkipModeChanged, autoPlayNext = autoPlayNext, onAutoPlayNextChanged = onAutoPlayNextChanged, + entryFocusRequester = paneEntryFocus, enabled = activePicker == null, onPresentPicker = presentPicker, ) HudTab.Audio -> HudAudioPane( audioTracks = audioTracks, + // The catalog decides WHICH tracks exist. Media3 only + // shows what this stream delivered, which a transcode + // collapses to one -- that disabled the row outright and + // made audio unswitchable for the whole session. + activeVersion = activeVersion, + // A locally-confirmed choice is the viewer's answer; + // the plan only names what the server last delivered. + planAudioOrdinal = desiredAudioOrdinal + ?: playbackPlan?.selectedTracks?.audioIndex, + // Only an unconfirmed intent renders as pending. + pendingLocalAudioOrdinal = desiredAudioOrdinal + ?.takeUnless { desiredAudioConfirmed }, onSelectAudio = onSelectAudio, audioDelayMs = audioDelayMs, audioDelayEnabled = audioDelayEnabled, onAudioDelayChanged = onAudioDelayChanged, + stats = stats, + entryFocusRequester = paneEntryFocus, enabled = activePicker == null, onPresentPicker = presentPicker, ) @@ -395,6 +552,7 @@ internal fun TvPlayerHud( onPaneShown = onSubtitlesPaneShown, onSearchSubtitles = onSearchSubtitles, onTranslateWithAi = onTranslateWithAi, + entryFocusRequester = paneEntryFocus, enabled = activePicker == null, onPresentPicker = presentPicker, ) @@ -402,6 +560,7 @@ internal fun TvPlayerHud( HudChaptersPane( chapters = chapters, onSelectChapter = onSelectChapter, + entryFocusRequester = paneEntryFocus, ) } } @@ -409,11 +568,13 @@ internal fun TvPlayerHud( } } - // Centered modal picker dialog, drawn on top of the dimmed panes. + // Centered modal picker dialog, drawn on top of the dimmed rail + card. + // matchParentSize, not fillMaxSize: the HUD box now wraps its content, + // so a fillMaxSize child would see an unbounded height and not stretch. val picker = activePicker if (picker != null) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center, ) { HudPickerDialog( @@ -473,16 +634,25 @@ private fun HudTabPill( if (isFocused) onFocused() } + // The rail floats on the video, so an idle pill needs its own ground: + // tvOS HUDTabPillBody — black@0.45 fill with a white@0.18 hairline idle; + // solid white when selected; white@0.9 when merely focused. A white@0.06 + // fill (the old idle) vanishes over a bright frame. + // + // Selection follows focus, so a focused pill is always the selected one. + // A selected pill that is NOT focused means focus is down in the pane — + // it dims to a marker so the one solid-white element on screen is the + // control you're actually on. (Deliberate departure from tvOS, which + // keeps the selected pill white throughout.) val bg = when { - isFocused -> Color.White.copy(alpha = 0.94f) - isSelected -> Color.White.copy(alpha = 0.18f) - else -> Color.White.copy(alpha = 0.06f) - } - val fg = when { - isFocused -> Color.Black - isSelected -> Color.White - else -> Color.White.copy(alpha = 0.72f) + isFocused -> Color.White + isSelected -> Color.White.copy(alpha = 0.22f) + // Firmer than tvOS's black@0.45: white type on 0.45 loses contrast + // over a bright frame, and the rail has no card behind it. + else -> Color.Black.copy(alpha = 0.62f) } + val fg = if (isFocused) Color.Black else Color.White + val stroke = if (isFocused || isSelected) Color.Transparent else Color.White.copy(alpha = 0.18f) val scale by animateFloatAsState( targetValue = if (isFocused) 1.0f else 0.96f, animationSpec = tween(120), @@ -493,11 +663,12 @@ private fun HudTabPill( modifier = Modifier .graphicsLayer { scaleX = scale; scaleY = scale } .height(HudTabHeight) - .clip(RoundedCornerShape(25.dp)) + .clip(RoundedCornerShape(50)) .background(bg) + .border(width = 0.5.dp, color = stroke, shape = RoundedCornerShape(50)) .focusRequester(focusRequester) .focusable(enabled = enabled, interactionSource = interactionSource) - .padding(horizontal = 16.dp), + .padding(horizontal = 18.dp), contentAlignment = Alignment.Center, ) { Text( @@ -519,7 +690,7 @@ private fun HudPaneViewport( ) { Column( modifier = modifier - .fillMaxSize() + .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(bottom = HudPaneBottomPadding), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -558,8 +729,7 @@ private fun HudInfoPane( episodeNumber: Int?, stats: PlayerStatsSnapshot, playbackPlan: PlaybackExecutionPlan?, - subtitleTracks: List, - subtitleUrls: List = emptyList(), + subtitleLabel: String, chapters: List, modifier: Modifier = Modifier, ) { @@ -578,17 +748,15 @@ private fun HudInfoPane( it.effectiveMediaFileId != null && it.requestedMediaFileId != it.effectiveMediaFileId }?.let { add("Source" to "Alternate version") } - stats.videoCodec?.let { add("Video" to it.uppercase()) } - stats.audioCodec?.let { add("Audio" to it.uppercase()) } - val sub = subtitleTracks.firstOrNull { it.isSelected } - // Built label ("Danish SRT (External)") via the mounted row — the raw - // Media3 displayLabel echoes sidecar filenames. - val subLabel = sub?.let { sel -> - resolveMountedSubtitleRow(sel, subtitleTracks, subtitleUrls) - ?.let { row -> subtitleChoiceLabel(row, subtitleUrls.indexOf(row)) } - ?: sel.displayLabel.ifBlank { "On" } - } ?: "Off" - add("Subtitles" to subLabel) + // Names people know, not shouted mimes: "H.264" rather than + // "AVC1.640029", "DTS-HD" rather than "AUDIO/VND.DTS.HD". Stats keeps + // the raw strings for anyone who needs them. + videoCodecShortName(stats.videoCodec)?.let { add("Video" to it) } + audioFormatShortName(stats.audioCodec)?.let { add("Audio" to it) } + // The adapter's COMMITTED identity, exactly as the Subtitles tab reads + // it. This used to ask Media3 which text track was selected, which is a + // different authority — so the two tabs could and did disagree. + add("Subtitles" to subtitleLabel) currentChapterTitle(chapters, positionSec)?.let { add("Chapter" to it) } } val badges = buildList { @@ -665,6 +833,23 @@ private fun HudInfoPane( ) } +/** Media3 video codec ids / mimes → the short names users know. */ +private fun videoCodecShortName(codecOrMime: String?): String? { + val raw = codecOrMime?.trim()?.lowercase(java.util.Locale.US)?.takeIf { it.isNotBlank() } ?: return null + val id = raw.substringAfterLast('/') + return when { + id.startsWith("avc") || id == "h264" -> "H.264" + id.startsWith("hev") || id.startsWith("hvc") || id == "hevc" || id == "h265" -> "HEVC" + id.startsWith("dvh") || id.startsWith("dva") -> "Dolby Vision" + id.startsWith("av01") || id == "av1" -> "AV1" + id.startsWith("vp09") || id == "vp9" || id == "x-vnd.on2.vp9" -> "VP9" + id.startsWith("vp08") || id == "vp8" -> "VP8" + id.startsWith("mp4v") || id == "mpeg4" -> "MPEG-4" + id == "mpeg2" || id == "mpeg2video" -> "MPEG-2" + else -> id.substringBefore('.').uppercase(java.util.Locale.US).take(12) + } +} + private fun PlaybackExecutionPlan?.validatedHdrBadge(): String? { val claims = this?.claims?.video ?: return null return when { @@ -708,6 +893,9 @@ private fun PaneColumn( @Composable private fun LabelValueRow(label: String, value: String) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + // Fixed gap, weighted value — same reasoning as HudFocusedSettingRow: a + // lone weighted spacer collapses to 0dp once the two texts fill the row, + // which is how "SubtitlesArabic — SRT · Exter…" rendered. Text( text = label, color = MaterialTheme.colorScheme.onSurface, @@ -716,8 +904,10 @@ private fun LabelValueRow(label: String, value: String) { lineHeight = HudBodyLineHeight, fontWeight = FontWeight.Medium, ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - Box(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Text( text = value, color = Color.White.copy(alpha = 0.7f), @@ -727,6 +917,8 @@ private fun LabelValueRow(label: String, value: String) { ), maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), ) } } @@ -746,29 +938,20 @@ private fun HudStatsPane(stats: PlayerStatsSnapshot, modifier: Modifier = Modifi return } - Column( + // Two columns, filled top-to-bottom then across, so nine rows read as a + // 5+4 grid instead of a single column stretched over the full card width + // with each value 500dp from its label. + val split = (rows.size + 1) / 2 + Row( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { - rows.forEach { (label, value) -> - Row(modifier = Modifier.fillMaxWidth()) { - Text( - text = label, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = HudBodyTextSize, - lineHeight = HudBodyLineHeight, - ), - color = MaterialTheme.colorScheme.onSurface, - ) + listOf(rows.take(split), rows.drop(split)).forEach { column -> + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + column.forEach { (label, value) -> LabelValueRow(label = label, value = value) } } } } @@ -841,6 +1024,7 @@ private fun HudVideoPane( onHdrEnabledChanged: (Boolean) -> Unit, dolbyVisionEnabled: Boolean, onDolbyVisionEnabledChanged: (Boolean) -> Unit, + dolbyVisionSwitchInFlight: Boolean, fillMode: VideoFillMode, onFillModeChanged: (VideoFillMode) -> Unit, playbackSpeed: Double, @@ -848,16 +1032,27 @@ private fun HudVideoPane( sleepTimerState: SleepTimerState, onStartSleepTimer: (Int) -> Unit, onCancelSleepTimer: () -> Unit, - autoSkipIntro: Boolean, - onAutoSkipIntroChanged: (Boolean) -> Unit, + introSkipMode: IntroSkipMode, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, autoPlayNext: Boolean, onAutoPlayNextChanged: (Boolean) -> Unit, + entryFocusRequester: FocusRequester, enabled: Boolean, onPresentPicker: (HudPickerPresentation) -> Unit, modifier: Modifier = Modifier, ) { + // Which row carries the pane's entry requester: the first row that is + // actually focusable. A disabled row is not focusable, so pointing the + // requester at it would cancel the move in from the rail. + val hasVersionRow = fileVersions.size > 1 + val hasQualityRow = videoQualities.size > 1 + val entryRow = when { + hasVersionRow -> "version" + hasQualityRow -> "quality" + else -> "speed" + } Row( - modifier = modifier.fillMaxSize(), + modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { // Playback column — Quality / Speed / Aspect / HDR + auto toggles. @@ -887,17 +1082,21 @@ private fun HudVideoPane( value = org.prairieserver.prairie.tv.ui.screens.detail.TvPlaybackFormatting .versionShortLabel(currentVersion), enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "version" }, onActivate = { onPresentPicker( HudPickerPresentation( title = "Version", - options = fileVersions.map { version -> - HudPickerOption( - id = version.fileId.toString(), - label = org.prairieserver.prairie.tv.ui.screens.detail - .TvPlaybackFormatting.versionShortLabel(version), - ) - }, + // Disambiguated as a set: two 4K DV files + // otherwise render as two identical rows. + options = org.prairieserver.prairie.tv.ui.screens.detail + .TvPlaybackFormatting.versionPickerLabels(fileVersions) + .mapIndexed { index, label -> + HudPickerOption( + id = fileVersions[index].fileId.toString(), + label = label, + ) + }, selectedId = (currentVersion?.fileId ?: -1).toString(), onSelect = { id -> id.toIntOrNull()?.let(onSelectFileVersion) @@ -918,6 +1117,7 @@ private fun HudVideoPane( label = "Quality", value = qualityValue, enabled = enabled && hasQualityChoice, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "quality" }, onActivate = { onPresentPicker( HudPickerPresentation( @@ -936,6 +1136,7 @@ private fun HudVideoPane( label = "Speed", value = formatTvPlaybackSpeed(playbackSpeed), enabled = enabled, + entryFocusRequester = entryFocusRequester.takeIf { entryRow == "speed" }, onActivate = { onPresentPicker( HudPickerPresentation( @@ -973,111 +1174,113 @@ private fun HudVideoPane( ) }, ) - - HudFocusedSettingRow( - label = "HDR passthrough", - value = onOffLabel(hdrEnabled), - enabled = enabled, - onActivate = { - onPresentPicker( - boolPicker( - title = "HDR Passthrough", - value = hdrEnabled, - onSet = onHdrEnabledChanged, - ), - ) - }, - ) - - // Off plays DV sources as their base layer (HDR10) — some - // users prefer HDR10 even on DV-capable displays. Profile 5 - // always plays as DV (no watchable base layer); applies from - // the next playback start. Apple parity (prairie-apple e9bd775). - HudFocusedSettingRow( - label = "Dolby Vision", - value = onOffLabel(dolbyVisionEnabled), - enabled = enabled, - onActivate = { - onPresentPicker( - boolPicker( - title = "Dolby Vision", - value = dolbyVisionEnabled, - onSet = onDolbyVisionEnabledChanged, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Auto-skip intro", - value = onOffLabel(autoSkipIntro), - enabled = enabled, - onActivate = { - onPresentPicker( - boolPicker( - title = "Auto-skip Intro", - value = autoSkipIntro, - onSet = onAutoSkipIntroChanged, - ), - ) - }, - ) - - HudFocusedSettingRow( - label = "Auto-play next", - value = onOffLabel(autoPlayNext), - enabled = enabled, - onActivate = { - onPresentPicker( - boolPicker( - title = "Auto-play Next", - value = autoPlayNext, - onSet = onAutoPlayNextChanged, - ), - ) - }, - ) } } - // Sync / timing column. - PaneColumn( - "Timers", + // Right column: what the device does with the picture, then what the + // player does on its own. Previously the left column carried eight + // rows against a lone Sleep timer here — the pane scrolled while + // half the card sat empty. + Column( modifier = Modifier .weight(1f) .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - val activeSleep = sleepTimerState as? SleepTimerState.Active - HudFocusedSettingRow( - label = "Sleep timer", - value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", - enabled = enabled, - onActivate = { - onPresentPicker( - HudPickerPresentation( - title = "Sleep Timer", - options = buildList { - if (activeSleep != null) { - add(HudPickerOption("cancel", "Cancel timer")) - } - add(HudPickerOption("off", "Off")) - addAll( - SLEEP_TIMER_PRESETS.map { minutes -> - HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) + PaneColumn("Output") { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HudFocusedSettingRow( + label = "HDR passthrough", + value = onOffLabel(hdrEnabled), + enabled = enabled, + showsChevron = false, + onActivate = { onHdrEnabledChanged(!hdrEnabled) }, + ) + + // Off plays DV sources as their base layer (HDR10) — some + // users prefer HDR10 even on DV-capable displays. Profile 5 + // always plays as DV (no watchable base layer); applies from + // the next playback start. Apple parity (silo-apple e9bd775). + HudFocusedSettingRow( + label = "Dolby Vision", + // A toggle on a DV file restarts the session so the + // server can re-plan the layer; say so on the row (the + // subtitle track row's idiom) and swallow presses until + // the replacement is playing, so a second press can't + // queue a second restart behind the first. Swallow, not + // disable: a disabled row is not focusable, and taking + // focus off the row the viewer just pressed left the + // next press landing on nothing. + value = if (dolbyVisionSwitchInFlight) { + "${onOffLabel(dolbyVisionEnabled)} · Applying…" + } else { + onOffLabel(dolbyVisionEnabled) + }, + enabled = enabled, + showsChevron = false, + onActivate = { + if (!dolbyVisionSwitchInFlight) { + onDolbyVisionEnabledChanged(!dolbyVisionEnabled) + } + }, + ) + } + } + + PaneColumn("Automation") { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Three values, so Select cycles rather than toggles — + // the same one-press shape as the rows around it, without + // a picker sheet over the picture. Settings has the list. + HudFocusedSettingRow( + label = stringResource(R.string.settings_intro_skip_title), + value = stringResource(introSkipModeLabel(introSkipMode)), + enabled = enabled, + showsChevron = false, + onActivate = { onIntroSkipModeChanged(introSkipMode.next()) }, + ) + + HudFocusedSettingRow( + label = "Auto-play next", + value = onOffLabel(autoPlayNext), + enabled = enabled, + showsChevron = false, + onActivate = { onAutoPlayNextChanged(!autoPlayNext) }, + ) + + val activeSleep = sleepTimerState as? SleepTimerState.Active + HudFocusedSettingRow( + label = "Sleep timer", + value = activeSleep?.let { "Sleeping in ${formatSleepRemaining(it.remainingSeconds)}" } ?: "Off", + enabled = enabled, + onActivate = { + onPresentPicker( + HudPickerPresentation( + title = "Sleep Timer", + options = buildList { + if (activeSleep != null) { + add(HudPickerOption("cancel", "Cancel timer")) + } + add(HudPickerOption("off", "Off")) + addAll( + SLEEP_TIMER_PRESETS.map { minutes -> + HudPickerOption(minutes.toString(), sleepPresetLabel(minutes)) + }, + ) }, - ) - }, - selectedId = if (activeSleep != null) "cancel" else "off", - onSelect = { id -> - when (id) { - "cancel", "off" -> onCancelSleepTimer() - else -> id.toIntOrNull()?.let(onStartSleepTimer) - } - }, - ), + selectedId = if (activeSleep != null) "cancel" else "off", + onSelect = { id -> + when (id) { + "cancel", "off" -> onCancelSleepTimer() + else -> id.toIntOrNull()?.let(onStartSleepTimer) + } + }, + ), + ) + }, ) - }, - ) + } + } } } } @@ -1143,41 +1346,89 @@ private fun HudClickChip( @Composable private fun HudAudioPane( audioTracks: List, + activeVersion: org.prairieserver.prairie.model.catalog.FileVersion?, + planAudioOrdinal: Int?, + pendingLocalAudioOrdinal: Int?, onSelectAudio: (Int) -> Unit, audioDelayMs: Int, audioDelayEnabled: Boolean, onAudioDelayChanged: (Int) -> Unit, + stats: PlayerStatsSnapshot, + entryFocusRequester: FocusRequester, enabled: Boolean, onPresentPicker: (HudPickerPresentation) -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(18.dp), + // Two columns like every other pane. A lone full-width column put the + // value 500dp from its label — "Audio track ……… English · DTS · 5.1" — + // and left the card two-thirds empty. The right column is read-only + // output facts the viewer would otherwise have to dig out of Stats. + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { - PaneColumn("Track") { + PaneColumn( + "Track", + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { val selectedTrack = audioTracks.firstOrNull { it.isSelected } + val catalogAudio = activeVersion?.audioTracks.orEmpty() + val formatting = org.prairieserver.prairie.tv.ui.screens.detail.TvPlaybackFormatting + val effectiveOrdinal = formatting.effectiveAudioOrdinal( + tracks = catalogAudio, + planOrdinal = planAudioOrdinal, + version = activeVersion, + ) + // Entry lands on the first row that can take focus: the track + // row when there is a choice, else the delay row when PCM. + val trackSelectable = catalogAudio.size > 1 HudFocusedSettingRow( label = "Audio track", - // Built labels ("English DTS 5.1") — raw Media3 labels echo - // server identity strings or bare ISO codes. - value = selectedTrack?.let { audioChoiceLabel(it, audioTracks.indexOf(it)) } + entryFocusRequester = entryFocusRequester.takeIf { trackSelectable }, + // SOURCE identity, from the catalog row the plan selected. + // The mounted Media3 track is the delivered representation, + // so a transcode showed "UND AAC Stereo" for what every + // other surface calls "English · DTS · 5.1". Media3 is only + // the fallback when there is no catalog audio metadata. + // + // A request in flight shows the requested track as pending + // rather than as fact: the row must not claim Dutch before + // the player confirms it actually switched. + value = pendingLocalAudioOrdinal + ?.let { pending -> + formatting.audioSummaryForOrdinal( + version = activeVersion, + ordinal = pending, + tracks = catalogAudio, + )?.let { "$it …" } + } + ?: formatting.audioSummaryForOrdinal( + version = activeVersion, + ordinal = effectiveOrdinal, + tracks = catalogAudio, + ) + ?: selectedTrack?.let { audioChoiceLabel(it, audioTracks.indexOf(it)) } ?: "Default", - enabled = enabled && audioTracks.size > 1, + // Gated on the CATALOG, not on what this stream delivered. + enabled = enabled && catalogAudio.size > 1, onActivate = { onPresentPicker( HudPickerPresentation( title = "Audio Track", - options = audioTracks.mapIndexed { idx, track -> + // Ids are catalog ordinals, the server's audio + // contract, so an undelivered row stays + // selectable and survives the round trip. + options = catalogAudio.indices.map { ordinal -> HudPickerOption( - id = track.index.toString(), - label = audioChoiceLabel(track, idx), + id = ordinal.toString(), + label = formatting.audioChoiceLabelForOrdinal(catalogAudio, ordinal) + ?: "Track ${ordinal + 1}", ) }, - selectedId = (selectedTrack?.index ?: 0).toString(), + selectedId = effectiveOrdinal?.toString().orEmpty(), onSelect = { id -> id.toIntOrNull()?.let(onSelectAudio) }, ), ) @@ -1186,8 +1437,10 @@ private fun HudAudioPane( HudFocusedSettingRow( label = "Delay (PCM only)", - value = if (audioDelayEnabled) delayLabel(audioDelayMs) else "Unavailable during passthrough", + // Output → Mode says why; the row itself just says it can't. + value = if (audioDelayEnabled) delayLabel(audioDelayMs) else "Unavailable", enabled = enabled && audioDelayEnabled, + entryFocusRequester = entryFocusRequester.takeIf { !trackSelectable && audioDelayEnabled }, onActivate = { onPresentPicker( delayPicker( @@ -1203,6 +1456,72 @@ private fun HudAudioPane( ) } } + + // Output — what the device is actually doing with the track. Mode is + // the fact behind the delay row's "Unavailable during passthrough": + // bitstream passthrough hands the codec to the receiver untouched, so + // there is no PCM to delay. + val outputRows = buildList> { + audioFormatShortName(stats.audioCodec)?.let { add("Codec" to it) } + add("Mode" to if (audioDelayEnabled) "Decoded to PCM" else "Passthrough") + stats.audioDecoderName + ?.takeIf { audioDelayEnabled } + ?.let { add("Decoder" to it.removePrefix("OMX.").removePrefix("c2.")) } + } + PaneColumn( + "Output", + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + outputRows.forEach { (label, value) -> + // Same row metrics as the setting rows on the left, so the + // two columns rule up; not focusable, nothing to open. + HudReadOnlyRow(label = label, value = value) + } + } + } + } +} + +/** + * A label/value row on the setting-row grid — same padding and type as + * [HudFocusedSettingRow], no focus, no chevron. For facts that sit beside + * settings and should line up with them. + */ +@Composable +private fun HudReadOnlyRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + color = Color.White, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = value, + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) } } @@ -1223,19 +1542,28 @@ private fun HudSubtitlesPane( onPaneShown: () -> Unit, onSearchSubtitles: (() -> Unit)?, onTranslateWithAi: (() -> Unit)?, + entryFocusRequester: FocusRequester, enabled: Boolean, onPresentPicker: (HudPickerPresentation) -> Unit, modifier: Modifier = Modifier, ) { LaunchedEffect(Unit) { onPaneShown() } + // Image (PGS/DVB) and burned-in tracks ignore most of the appearance block — + // say so instead of offering rows that silently do nothing. + val applicability = tvSubtitleAppearanceApplicability( + presentation.rows.firstOrNull { row -> row.checked }?.identity, + ) + val geometryEnabled = enabled && applicability.geometryApplies + val stylingEnabled = enabled && applicability.stylingApplies + val subtitleTrackFocus = remember { FocusRequester() } val subtitleTextColorFocus = remember { FocusRequester() } val subtitleBackgroundColorFocus = remember { FocusRequester() } val subtitleOutlineColorFocus = remember { FocusRequester() } Row( - modifier = modifier.fillMaxSize(), + modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(HudPaneColumnGap), ) { // Tracks + sync column. @@ -1256,6 +1584,7 @@ private fun HudSubtitlesPane( ?: "Off", enabled = enabled, focusRequester = subtitleTrackFocus, + entryFocusRequester = entryFocusRequester, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1315,7 +1644,7 @@ private fun HudSubtitlesPane( label = "Size", value = FONT_SIZES.firstOrNull { it.first == appearance.fontSize }?.second ?: appearance.fontSize.name, - enabled = enabled, + enabled = geometryEnabled, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1337,7 +1666,7 @@ private fun HudSubtitlesPane( label = "Font", value = FONT_FAMILIES.firstOrNull { it.first == appearance.fontFamily }?.second ?: appearance.fontFamily, - enabled = enabled, + enabled = stylingEnabled, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1359,7 +1688,7 @@ private fun HudSubtitlesPane( label = "Background", value = BACKGROUND_STYLES.firstOrNull { it.first == appearance.backgroundStyle }?.second ?: appearance.backgroundStyle.name, - enabled = enabled, + enabled = stylingEnabled, rightFocusRequester = subtitleBackgroundColorFocus, onActivate = { onPresentPicker( @@ -1380,7 +1709,7 @@ private fun HudSubtitlesPane( HudFocusedSettingRow( label = "Opacity", value = "${appearance.backgroundOpacity}%", - enabled = enabled, + enabled = stylingEnabled, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1401,27 +1730,20 @@ private fun HudSubtitlesPane( HudFocusedSettingRow( label = "Outline", value = onOffLabel(appearance.textOutline), - enabled = enabled, + enabled = stylingEnabled, // The outline-color swatch (subtitleOutlineColorFocus) is only // composed when textOutline is on. Right-nav must not target a // detached requester when it's off, so gate the target on it. rightFocusRequester = subtitleOutlineColorFocus.takeIf { appearance.textOutline }, - onActivate = { - onPresentPicker( - boolPicker( - title = "Text Outline", - value = appearance.textOutline, - onSet = { onAppearanceChanged(appearance.copy(textOutline = it)) }, - ), - ) - }, + showsChevron = false, + onActivate = { onAppearanceChanged(appearance.copy(textOutline = !appearance.textOutline)) }, ) HudFocusedSettingRow( label = "Position", value = POSITIONS.firstOrNull { it.first == appearance.position }?.second ?: appearance.position.name, - enabled = enabled, + enabled = geometryEnabled, rightFocusRequester = subtitleTextColorFocus, onActivate = { onPresentPicker( @@ -1438,6 +1760,15 @@ private fun HudSubtitlesPane( ) }, ) + + applicability.note?.let { note -> + Text( + text = note, + color = Color.White.copy(alpha = 0.62f), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } } } @@ -1450,32 +1781,24 @@ private fun HudSubtitlesPane( ) { HudSubtitlePreview(appearance = appearance) - HudFocusedSettingRow( - label = "No background", - value = if (appearance.backgroundStyle == SubtitleBackgroundStylePreset.None) "On" else "Off", - enabled = enabled, - leftFocusRequester = subtitleTrackFocus, - onActivate = { - // Toggle: turning it back off restores the default Box - // background (Apple parity) rather than staying stuck on None. - val target = if (appearance.backgroundStyle == SubtitleBackgroundStylePreset.None) { - SubtitleBackgroundStylePreset.Box - } else { - SubtitleBackgroundStylePreset.None - } - onAppearanceChanged(appearance.copy(backgroundStyle = target)) - }, - ) + // There is deliberately no "No background" toggle here. It was a + // second control over backgroundStyle, which the Background picker + // in the left column already exposes as "No background" — and + // toggling it off could not know what the style had been, so it + // hard-coded Box and silently destroyed the user's choice + // (Drop Shadow -> On -> Off left you on Box, persisted immediately). + // tvOS has no such toggle either: TVPlayerInfoHUD offers a single + // Style picker plus a Background color row. // Color swatches stay inline — tvOS draws color swatches directly, // and a row→dialog of colors would lose the at-a-glance palette. - StyleSection("Text color") { + StyleSection("Text color", dimmed = !applicability.stylingApplies) { TEXT_COLOR_SWATCHES.forEachIndexed { index, hex -> StyleColorSwatch( hex = hex, label = TvSubtitleAppearanceOptions.fontColorLabel(hex), selected = appearance.fontColor.equals(hex, ignoreCase = true), - enabled = enabled, + enabled = stylingEnabled, focusRequester = if (index == 0) subtitleTextColorFocus else null, leftFocusRequester = subtitleTrackFocus, ) { @@ -1483,13 +1806,13 @@ private fun HudSubtitlesPane( } } } - StyleSection("Background color") { + StyleSection("Background color", dimmed = !applicability.stylingApplies) { BACKGROUND_COLOR_SWATCHES.forEachIndexed { index, hex -> StyleColorSwatch( hex = hex, label = TvSubtitleAppearanceOptions.backgroundColorLabel(hex), selected = appearance.backgroundColor.equals(hex, ignoreCase = true), - enabled = enabled, + enabled = stylingEnabled, focusRequester = if (index == 0) subtitleBackgroundColorFocus else null, leftFocusRequester = subtitleTrackFocus, ) { @@ -1498,13 +1821,13 @@ private fun HudSubtitlesPane( } } if (appearance.textOutline) { - StyleSection("Outline color") { + StyleSection("Outline color", dimmed = !applicability.stylingApplies) { OUTLINE_COLOR_SWATCHES.forEachIndexed { index, hex -> StyleColorSwatch( hex = hex, label = TvSubtitleAppearanceOptions.outlineColorLabel(hex), selected = appearance.textOutlineColor.equals(hex, ignoreCase = true), - enabled = enabled, + enabled = stylingEnabled, focusRequester = if (index == 0) subtitleOutlineColorFocus else null, leftFocusRequester = subtitleTrackFocus, ) { @@ -1632,8 +1955,18 @@ private fun HudSubtitlePreview( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun StyleSection(title: String, content: @Composable () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier.padding(top = 5.dp)) { +private fun StyleSection( + title: String, + dimmed: Boolean = false, + content: @Composable () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier + .padding(top = 5.dp) + // Same 0.35 alpha HudFocusedSettingRow uses for a disabled row. + .graphicsLayer { alpha = if (dimmed) 0.35f else 1f }, + ) { Text( text = title, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), @@ -1788,7 +2121,11 @@ private fun PlayerStatsSnapshot.hudRows(): List> = buildLis videoDecoderName?.let { add("Video decoder" to it) } audioCodec?.let { add("Audio codec" to it) } audioDecoderName?.let { add("Audio decoder" to it) } - bitrateBps?.let { add("Bitrate" to formatBitrate(it)) } + // NOT the media bitrate: this is Media3's onBandwidthEstimate value, i.e. + // measured network throughput. Labelling it "Bitrate" read as a ~19 Mbps + // stream reporting 151.3 Mbps on a fast LAN, which is actively misleading + // in a panel whose whole job is diagnosing playback. + bitrateBps?.let { add("Estimated bandwidth" to formatBitrate(it)) } if (droppedFrames > 0) add("Dropped frames" to droppedFrames.toString()) if (audioUnderruns > 0) add("Audio underruns" to audioUnderruns.toString()) } @@ -1810,7 +2147,8 @@ private fun formatBitrate(bps: Long): String = when { private fun HudEmptyStatePane(message: String, modifier: Modifier = Modifier) { Box( modifier = modifier - .fillMaxSize() + .fillMaxWidth() + .heightIn(min = HudCardMinHeight - HudPanelPadding * 2) .padding(18.dp), contentAlignment = Alignment.Center, ) { @@ -1830,6 +2168,7 @@ private fun HudEmptyStatePane(message: String, modifier: Modifier = Modifier) { private fun HudChaptersPane( chapters: List, onSelectChapter: (Int) -> Unit, + entryFocusRequester: FocusRequester, modifier: Modifier = Modifier, ) { if (chapters.isEmpty()) { @@ -1850,6 +2189,7 @@ private fun HudChaptersPane( HudChapterRow( chapter = ch, onSelect = { onSelectChapter(idx) }, + focusRequester = entryFocusRequester.takeIf { idx == 0 }, ) } } @@ -1859,6 +2199,7 @@ private fun HudChaptersPane( private fun HudChapterRow( chapter: VersionChapter, onSelect: () -> Unit, + focusRequester: FocusRequester? = null, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1869,6 +2210,7 @@ private fun HudChapterRow( Row( modifier = Modifier .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .clip(RoundedCornerShape(12.dp)) .background(bg) .clickable(enabled = true, interactionSource = interactionSource, indication = null) { onSelect() } @@ -1923,14 +2265,6 @@ internal data class HudPickerPresentation( val onSelect: (String) -> Unit, ) -private fun boolPicker(title: String, value: Boolean, onSet: (Boolean) -> Unit): HudPickerPresentation = - HudPickerPresentation( - title = title, - options = listOf(HudPickerOption("on", "On"), HudPickerOption("off", "Off")), - selectedId = if (value) "on" else "off", - onSelect = { onSet(it.equals("on", ignoreCase = true)) }, - ) - private fun delayPicker( title: String, current: Int, @@ -1962,8 +2296,19 @@ internal fun HudFocusedSettingRow( enabled: Boolean = true, colorHex: String? = null, focusRequester: FocusRequester? = null, + /** + * A second requester for the same row — the pane's entry point, which the + * HUD card's custom `enter` routes a Down from the rail to. Separate from + * [focusRequester] so a pane can keep its own handle on the row too. + */ + entryFocusRequester: FocusRequester? = null, leftFocusRequester: FocusRequester? = null, rightFocusRequester: FocusRequester? = null, + /** + * False for a toggle row: Select flips the value in place, so there is no + * drill-in to advertise. Mirrors tvOS HUDToggleRow (showsChevron: false). + */ + showsChevron: Boolean = true, onActivate: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } @@ -1985,6 +2330,7 @@ internal fun HudFocusedSettingRow( .fillMaxWidth() .focusRequester(selfFocusRequester) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .then(if (entryFocusRequester != null) Modifier.focusRequester(entryFocusRequester) else Modifier) .focusProperties { if (leftFocusRequester != null) left = leftFocusRequester if (rightFocusRequester != null) right = rightFocusRequester @@ -2003,6 +2349,14 @@ internal fun HudFocusedSettingRow( .padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { + // A single weighted spacer used to be the only thing between label and + // value. Compose measures unweighted children first, so once the two + // texts filled the row that spacer resolved to 0dp and they abutted + // ("BackgroundNo background", "SubtitlesDanish — SRT · E…"). + // + // Now the gap is fixed and unconditional, and the value region carries + // the weight: it still right-aligns, but it is the side that gives way + // and ellipsizes when the row is cramped. Text( text = label, color = labelColor, @@ -2014,10 +2368,11 @@ internal fun HudFocusedSettingRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Box(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(12.dp)) Row( + modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.End), ) { if (colorHex != null) { Box( @@ -2028,6 +2383,10 @@ internal fun HudFocusedSettingRow( .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape), ) } + // Weighted so the swatch and chevron are measured first and the + // value is what gives way. Unweighted, a long value consumes the + // width and squeezes the trailing chevron toward zero. + // fill = false keeps short values grouped against the right edge. Text( text = value, color = valueColor, @@ -2038,13 +2397,16 @@ internal fun HudFocusedSettingRow( ), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), ) - Icon( - imageVector = Icons.Filled.ChevronRight, - contentDescription = null, - tint = chevronColor, - modifier = Modifier.size(11.dp), - ) + if (showsChevron) { + Icon( + imageVector = Icons.Filled.ChevronRight, + contentDescription = null, + tint = chevronColor, + modifier = Modifier.size(11.dp), + ) + } } } } @@ -2074,12 +2436,14 @@ internal fun HudPickerDialog( // Auto-focus the selected option on appear. Because every option is in the // focus graph, Compose's scroll container brings that focused row onscreen. - LaunchedEffect(presentation.title) { - runCatching { focusRequester.requestFocus() } - } + val optionFocusModifier = rememberTvContentInitialFocus( + target = focusRequester, + contentKey = presentation.title, + ) Box( modifier = modifier + .then(optionFocusModifier) .width(360.dp) .heightIn(max = 220.dp) .clip(RoundedCornerShape(14.dp)) @@ -2100,7 +2464,7 @@ internal fun HudPickerDialog( ), ) // Fully compose this small modal list so every D-pad destination is - // present in the focus graph. LazyColumn made below-fold rows look + // present in the focus graph. A lazy list made below-fold rows look // like the end of the modal and either trapped or leaked focus. Column( modifier = Modifier @@ -2136,6 +2500,8 @@ private fun HudPickerOptionRow( onSelect: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val scope = rememberCoroutineScope() val isFocused by interactionSource.collectIsFocusedAsState() val bg = when { @@ -2155,7 +2521,13 @@ private fun HudPickerOptionRow( .clip(RoundedCornerShape(8.dp)) .background(bg) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) - .onFocusChanged { if (it.isFocused) onFocused() } + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { state -> + if (state.isFocused) { + onFocused() + scope.launch { bringIntoViewRequester.bringIntoView() } + } + } .clickable(interactionSource = interactionSource, indication = null) { onSelect() } .semantics { this.selected = isSelected } .padding(horizontal = 10.dp, vertical = 8.dp), @@ -2202,3 +2574,15 @@ private fun formatTime(seconds: Double): String { val s = total % 60 return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) } + +/** The label each intro-skip mode is offered under; the copy is contract-fixed. */ +@StringRes +private fun introSkipModeLabel(mode: IntroSkipMode): Int = when (mode) { + IntroSkipMode.NEVER -> R.string.settings_intro_skip_never + IntroSkipMode.ASK -> R.string.settings_intro_skip_ask + IntroSkipMode.ALWAYS -> R.string.settings_intro_skip_always +} + +/** Declaration order, wrapping: never -> ask -> always -> never. */ +private fun IntroSkipMode.next(): IntroSkipMode = + IntroSkipMode.entries[(ordinal + 1) % IntroSkipMode.entries.size] diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt index c15e98937..afc9ed2ce 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt @@ -7,7 +7,18 @@ internal enum class TvPlayerRemoteKeyAction { FocusTransport, SkipBack, SkipForward, - OpenHud, + /** + * The settings entry point — the remote's Menu/Settings key and the + * transport's Tune button. Opens the HUD on Video, matching tvOS + * `applyHUDEntryPoint(.settings)`. + */ + OpenSettingsHud, + /** + * The playback entry point — Down from clean playback. Opens the HUD on + * whichever tab that press was most likely reaching for (audio, else + * subtitles), matching tvOS `preferredPlaybackHUDTab`. + */ + OpenPlaybackHud, // Unconsumed media-key events reach the system media-key fallback, which // toggles the Media3 session a second time — so both the UP half and any // auto-repeat DOWN events must be swallowed here without acting on them. @@ -22,6 +33,10 @@ internal fun tvPlayerRemoteKeyAction( // overlay, HUD, Up Next) is on screen. When one is, Left/Right must fall // through so Compose focus navigation keeps moving the selection. dpadHorizontalSeek: Boolean = true, + // Down opens the settings HUD only from clean playback. With the transport + // overlay up, Down still belongs to it — that is the press that reaches the + // buttons under the scrubber. + dpadDownOpensHud: Boolean = false, ): TvPlayerRemoteKeyAction? = when (keyCode) { KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE, @@ -32,14 +47,15 @@ internal fun tvPlayerRemoteKeyAction( TvPlayerRemoteKeyAction.ConsumeOnly } + // From clean playback Down opens the settings HUD, which is the tvOS + // idiom and the gesture people reach for to change audio or subtitles. + // Once the overlay is up Down belongs to it again, moving focus into the + // transport row. KeyEvent.KEYCODE_DPAD_DOWN -> when { action != KeyEvent.ACTION_DOWN -> null - // tvOS parity (QA 2026-07-08): while playing with nothing on - // screen, D-pad-down opens the hover menu (HUD). When a - // focus-owning surface is up (dpadHorizontalSeek == false), Down - // keeps moving focus into the transport instead. - dpadHorizontalSeek -> TvPlayerRemoteKeyAction.OpenHud + repeatCount != 0 -> TvPlayerRemoteKeyAction.ConsumeOnly + dpadDownOpensHud -> TvPlayerRemoteKeyAction.OpenPlaybackHud else -> TvPlayerRemoteKeyAction.FocusTransport } @@ -59,7 +75,7 @@ internal fun tvPlayerRemoteKeyAction( KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS, - -> if (action == KeyEvent.ACTION_UP) TvPlayerRemoteKeyAction.OpenHud else null + -> if (action == KeyEvent.ACTION_UP) TvPlayerRemoteKeyAction.OpenSettingsHud else null else -> null } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt index 82abeed02..59a8b594d 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt @@ -14,17 +14,18 @@ import android.view.WindowManager import android.widget.FrameLayout import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.foundation.clickable import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -34,81 +35,97 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.zIndex -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bedtime import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player -import org.prairieserver.prairie.common.player.PlayWhenReadyReconciliationGate import androidx.media3.common.Tracks import androidx.media3.common.VideoSize import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.google.common.util.concurrent.MoreExecutors +import kotlin.math.roundToInt +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.prairieserver.prairie.cast.PrairieCastPlaybackState +import org.prairieserver.prairie.cast.PrairieCastQualityOption +import org.prairieserver.prairie.cast.PrairieCastTrack +import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator +import org.prairieserver.prairie.common.pip.PrairiePictureInPicturePlaybackState +import org.prairieserver.prairie.common.pip.PrairiePictureInPictureSurface import org.prairieserver.prairie.common.player.ActivePlayerHolder import org.prairieserver.prairie.common.player.AudioCapabilityManager -import org.prairieserver.prairie.common.player.PrairiePlaybackService import org.prairieserver.prairie.common.player.DisplayHdrProbe import org.prairieserver.prairie.common.player.HdrDisplayController +import org.prairieserver.prairie.common.player.LetterboxInsets +import org.prairieserver.prairie.common.player.PlayWhenReadyReconciliationGate import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackPreflightListener -import org.prairieserver.prairie.common.player.LetterboxInsets +import org.prairieserver.prairie.common.player.PlayerNotice import org.prairieserver.prairie.common.player.SessionState +import org.prairieserver.prairie.common.player.PrairiePlaybackService import org.prairieserver.prairie.common.player.SleepTimerState import org.prairieserver.prairie.common.player.SubtitleManager import org.prairieserver.prairie.common.player.VideoPlayerMediaSpec -import org.prairieserver.prairie.common.player.validatedColorRangeFallback -import org.prairieserver.prairie.common.pip.PrairiePictureInPictureCoordinator -import org.prairieserver.prairie.common.pip.PrairiePictureInPicturePlaybackState -import org.prairieserver.prairie.common.pip.PrairiePictureInPictureSurface +import org.prairieserver.prairie.common.player.subtitlesForVideoMediaMount import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendFactory import org.prairieserver.prairie.common.player.backend.VideoPlaybackBackendRequest -import org.prairieserver.prairie.common.player.video.PlaybackStartupStallDetector +import org.prairieserver.prairie.common.player.validatedColorRangeFallback import org.prairieserver.prairie.common.player.video.PlaybackRuntimeCorrectionMetrics +import org.prairieserver.prairie.common.player.video.PlaybackStartupStallDetector import org.prairieserver.prairie.common.player.video.PostResumeVideoStallDetector import org.prairieserver.prairie.common.player.video.VideoPlayerTrackEntry -import org.prairieserver.prairie.cast.PrairieCastPlaybackState -import org.prairieserver.prairie.cast.PrairieCastQualityOption -import org.prairieserver.prairie.cast.PrairieCastTrack +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.domain.player.IntroAutoSkipState import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlaybackSourceMetadata @@ -119,26 +136,19 @@ import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.prairieserver.prairie.model.settings.legacyPosition import org.prairieserver.prairie.model.watchtogether.RoomPlaybackState import org.prairieserver.prairie.model.watchtogether.RoomSnapshot -import org.prairieserver.prairie.watchtogether.shouldNavigateToLocalNext import org.prairieserver.prairie.player.DolbyVisionDetection import org.prairieserver.prairie.player.formatSubtitleTrackDisplayLabel import org.prairieserver.prairie.tv.R -import org.prairieserver.prairie.tv.cast.TvPrairieCastPlayerAdapter -import org.prairieserver.prairie.tv.cast.TvPrairieCastReceiver +import org.prairieserver.prairie.tv.cast.TvSiloCastPlayerAdapter +import org.prairieserver.prairie.tv.cast.TvSiloCastReceiver import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus -import com.google.common.util.concurrent.MoreExecutors -import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.koin.core.parameter.parametersOf -import kotlin.math.roundToInt +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvFocusLog +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.watchtogether.shouldNavigateToLocalNext private const val CONTROLS_AUTO_HIDE_MS = 5_000L // slow) under NonCancellable while holding engineSwitchMutex, so this must be @@ -153,13 +163,8 @@ private const val SKIP_BACK_MS = 10_000L private const val SKIP_FEEDBACK_HIDE_MS = 1_200L private const val SKIP_FORWARD_MS = 30_000L private const val CLEAN_SEEK_HOLD_THRESHOLD_MS = 300L -private const val CLEAN_SEEK_TICK_MS = 100L -private const val CLEAN_SEEK_RAMP_INTERVAL_MS = 1_200L -private const val CLEAN_SEEK_BASE_STEP_SECONDS = 2.0 private const val CLEAN_QUICK_SKIP_CAPTURE_MS = 200L -private val CLEAN_PLAYBACK_SEEK_RATES = listOf(-32, -16, -8, -4, -2, -1, 1, 2, 4, 8, 16, 32) - private enum class TvIdleOverlayFocusTarget { Scrubber, Transport, @@ -170,14 +175,40 @@ private data class TvIdleOverlayFocusRequest( val nonce: Int = 0, ) -internal fun adjustedCleanPlaybackSeekRate(currentRate: Int, adjustment: Int): Int { +/** + * Manual rate step for a hidden-controls hold-seek. + * + * Delegates to [TvSeekRateLadder] so this control and the focused scrubber's + * hold-seek walk one ladder. They previously kept separate ones, and the + * hidden path's was both dishonest about its multiples (see + * [advanceCleanPlaybackSeekPreview]) and flipped direction when stepped below + * 1× — so "slower" eventually meant "backwards". + */ +internal fun adjustedCleanPlaybackSeekRate( + currentRate: Int, + adjustment: Int, + durationSec: Double, +): Int { if (adjustment == 0) return currentRate - val currentIndex = CLEAN_PLAYBACK_SEEK_RATES.indexOf(currentRate) - if (currentIndex < 0) return currentRate - val step = if (adjustment < 0) -1 else 1 - return CLEAN_PLAYBACK_SEEK_RATES[ - (currentIndex + step).coerceIn(0, CLEAN_PLAYBACK_SEEK_RATES.lastIndex) - ] + return TvSeekRateLadder.bumped(currentRate, adjustment, durationSec) +} + +/** + * The HUD tab a Down press from clean playback should land on. + * + * Mirrors tvOS `preferredPlaybackHUDTab`: that press is nearly always reaching + * for an audio or subtitle track, so route straight there rather than making + * the viewer traverse from Info every time. Falls back to Video, which — like + * Info and Subtitles — is always present in [visibleHudTabs], so this can + * never name a tab the HUD would reject. + */ +internal fun preferredPlaybackHudTab( + hasAudioTracks: Boolean, + hasSubtitleTracks: Boolean, +): HudTab = when { + hasAudioTracks -> HudTab.Audio + hasSubtitleTracks -> HudTab.Subtitles + else -> HudTab.Video } internal fun shouldEnterCleanPlaybackSeekHold( @@ -190,13 +221,23 @@ internal fun isCleanPlaybackSeekAdjustmentTap( pressDurationMs: Long, ): Boolean = !repeated && pressDurationMs < CLEAN_SEEK_HOLD_THRESHOLD_MS +/** + * One hold-seek tick for the hidden-controls scan. + * + * Advances by [TvSeekRateLadder.tickSeconds], which is what makes the rate + * chip mean what it says: rate × tick seconds per tick is exactly rate × real + * time. This previously advanced a flat 2s per 100ms tick at 1×, so every + * multiple on screen was a twentieth of the truth — a chip reading "8×" moved + * at 160×, and the same gesture ran 20× faster with the chrome hidden than the + * focused scrubber's hold-seek, which had already been corrected. + */ internal fun advanceCleanPlaybackSeekPreview( previewSec: Double, durationSec: Double, rate: Int, ): Double { val safePreview = previewSec.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.0 - val next = (safePreview + CLEAN_SEEK_BASE_STEP_SECONDS * rate).coerceAtLeast(0.0) + val next = (safePreview + TvSeekRateLadder.tickSeconds(rate)).coerceAtLeast(0.0) return if (durationSec.isFinite() && durationSec > 0.0) { next.coerceAtMost(durationSec) } else { @@ -230,12 +271,21 @@ fun TvPlayerScreen( // subtitle -1 = Off). Audio goes to the server session start; subtitle is // applied client-side once the player's tracks land. initialAudioTrackIndex: Int? = null, + initialAudioPickedThisSession: Boolean = false, initialSubtitleTrackIndex: Int? = null, + // True when the carried subtitle index is the detail row's Auto preview + // rather than the viewer's own pick (it still decides what starts). + initialSubtitleAutoResolved: Boolean = false, // Consecutive auto-advance count (pass-out protection); 0 = manual start. autoAdvanceCount: Int = 0, + episodeSelectionHandoff: org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff? = null, // Navigate to the next episode (auto-advance / "Continue"), carrying the // updated streak count. - onPlayNext: (contentId: String, autoAdvanceCount: Int, preferredQuality: String?) -> Unit = { _, _, _ -> }, + onPlayNext: ( + contentId: String, + autoAdvanceCount: Int, + episodeSelectionHandoff: org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff, + ) -> Unit = { _, _, _ -> }, // Scope the ViewModel key by fileId too so switching 4K <-> 1080p on // the detail screen and replaying actually spins up a fresh player // session instead of reusing the cached one bound to the first fileId. @@ -250,8 +300,11 @@ fun TvPlayerScreen( roomId = roomId, resumePositionOverride = resumePositionOverride, initialAudioTrackIndex = initialAudioTrackIndex, + initialAudioPickedThisSession = initialAudioPickedThisSession, initialSubtitleTrackIndex = initialSubtitleTrackIndex, + initialSubtitleAutoResolved = initialSubtitleAutoResolved, autoAdvanceCount = autoAdvanceCount, + episodeSelectionHandoff = episodeSelectionHandoff, ), ) }, @@ -262,7 +315,7 @@ fun TvPlayerScreen( capabilityDetector: PlaybackCapabilityDetector = koinInject(), activePlayerHolder: ActivePlayerHolder = koinInject(), pictureInPictureCoordinator: PrairiePictureInPictureCoordinator = koinInject(), - prairieCastReceiver: TvPrairieCastReceiver = koinInject(), + siloCastReceiver: TvSiloCastReceiver = koinInject(), ) { // The player never takes text input, so any soft keyboard visible here // leaked in from a prior screen (e.g. starting playback from a search with @@ -285,38 +338,36 @@ fun TvPlayerScreen( } val sessionState by viewModel.sessionState.collectAsState() val introSkipState by viewModel.introSkipState.collectAsState() + val introSkipCountdownRun by viewModel.introSkipCountdownRun.collectAsState() + val introSkipTimerRunning by viewModel.introSkipTimerRunning.collectAsState() val subtitleAppearance by viewModel.subtitleAppearance.collectAsState() val playbackSpeed by viewModel.playbackSpeed.collectAsState() val sleepTimerState by viewModel.sleepTimerState.collectAsState() - val autoSkipIntroEnabled by viewModel.autoSkipIntroEnabled.collectAsState() + val introSkipMode by viewModel.introSkipMode.collectAsState() val autoPlayNextEnabled by viewModel.autoPlayNextEnabled.collectAsState() val audioDelayMs by viewModel.audioDelayMs.collectAsState() val subtitleDelayMs by viewModel.subtitleDelayMs.collectAsState() val hdrEnabled by viewModel.hdrEnabled.collectAsState() val dolbyVisionEnabled by viewModel.dolbyVisionEnabled.collectAsState() + val dolbyVisionSwitchInFlight by viewModel.dolbyVisionSwitchInFlight.collectAsState() val subtitleSearch by viewModel.subtitleSearch.collectAsState() val aiTranslate by viewModel.aiTranslate.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val latestOnExit by rememberUpdatedState(onExit) - val latestPrairieCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) - val latestPrairieCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) - val latestPrairieCastHdrEnabled by rememberUpdatedState(hdrEnabled) - val latestPrairieCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) + val latestSiloCastPlaybackSpeed by rememberUpdatedState(playbackSpeed) + val latestSiloCastSubtitleDelayMs by rememberUpdatedState(subtitleDelayMs) + val latestSiloCastHdrEnabled by rememberUpdatedState(hdrEnabled) + val latestSiloCastSubtitleAppearance by rememberUpdatedState(subtitleAppearance) val context = LocalContext.current val hdrDisplayController = remember { HdrDisplayController() } val displayHdr = remember { DisplayHdrProbe.probe(context) } val audioCaps by audioCapabilityManager.capabilities.collectAsState() val rootFocus = remember { FocusRequester() } + var playerRootHasFocus by remember { mutableStateOf(false) } var exitRequested by remember { mutableStateOf(false) } var requestedHudTab by remember { mutableStateOf(HudTab.Info) } var showQuickSubtitlePicker by remember { mutableStateOf(false) } var subtitleFocusedStableId by remember { mutableStateOf(null) } - // Mirrors the HUD's internal active-picker slot so the screen-level - // BackHandler can defer to an open picker (Back closes only the picker). - var hudPickerOpen by remember { mutableStateOf(false) } - // Clear the mirror whenever the HUD itself is gone, so a stale "picker open" - // can never wedge the screen BackHandler off. - LaunchedEffect(state.hudOpen) { if (!state.hudOpen) hudPickerOpen = false } // Captured PlayerView reference so subtitleManager.applyAppearance can hit // the inflated subtitleView after the AndroidView factory runs. Mirrors // the phone PlayerScreen's `playerViewRef` pattern. @@ -405,40 +456,31 @@ fun TvPlayerScreen( val playWhenReadyReconciliationGate = remember(mediaController, roomId) { PlayWhenReadyReconciliationGate() } - val videoBackend = remember( - sessionPlayer, - mediaController, - backendFactory, - contentId, - preferredFileId, - state.playMethod, - state.playbackPlan, - state.delivery, - state.container, - state.streamUrl, - ) { - val plan = state.playbackPlan - val delivery = plan?.delivery ?: state.delivery - (sessionPlayer ?: mediaController)?.let { player -> + val backendPlayer = sessionPlayer ?: mediaController + val videoBackend = remember(backendPlayer, backendFactory) { + backendPlayer?.let { player -> backendFactory.create( player = player, request = VideoPlaybackBackendRequest(), ) } } + // False until presets have been applied once for the current backend, so + // only later capability changes wait for the route to settle. + var trackPresetsApplied by remember(videoBackend) { mutableStateOf(false) } LaunchedEffect(videoBackend) { videoBackend?.let { backend -> viewModel.onBackendCapabilities(backend.capabilities) } } - val latestPrairieCastMediaController by rememberUpdatedState(mediaController) - val latestPrairieCastSessionPlayer by rememberUpdatedState(sessionPlayer) - DisposableEffect(prairieCastReceiver, viewModel, contentId) { - var lastAudibleRemoteVolume = latestPrairieCastMediaController + val latestSiloCastMediaController by rememberUpdatedState(mediaController) + val latestSiloCastSessionPlayer by rememberUpdatedState(sessionPlayer) + DisposableEffect(siloCastReceiver, viewModel, contentId) { + var lastAudibleRemoteVolume = latestSiloCastMediaController ?.volume ?.takeIf { it > 0.001f } ?: 1f - val adapter = TvPrairieCastPlayerAdapter( + val adapter = TvSiloCastPlayerAdapter( play = { // Watch Together is authoritative for transport: suppress // PrairieCast transport while in a room so a caster can't desync @@ -446,13 +488,13 @@ fun TvPlayerScreen( // remoteTransportSuppressed gate. Non-room casting is unchanged. if (!viewModel.remoteTransportSuppressed) { viewModel.setPaused(false) - latestPrairieCastMediaController?.play() + latestSiloCastMediaController?.play() } }, pause = { if (!viewModel.remoteTransportSuppressed) { viewModel.setPaused(true) - latestPrairieCastMediaController?.pause() + latestSiloCastMediaController?.pause() } }, playPause = { if (!viewModel.remoteTransportSuppressed) viewModel.onPlayPause() }, @@ -466,10 +508,10 @@ fun TvPlayerScreen( selectSubtitle = { index -> viewModel.remoteSelectSubtitle(index?.toInt() ?: -1) }, setPlaybackSpeed = { speed -> viewModel.onSetPlaybackSpeed(speed) - latestPrairieCastMediaController?.playbackParameters = PlaybackParameters(speed.toFloat()) + latestSiloCastMediaController?.playbackParameters = PlaybackParameters(speed.toFloat()) }, setQuality = { qualityId -> - val player = latestPrairieCastMediaController ?: latestPrairieCastSessionPlayer + val player = latestSiloCastMediaController ?: latestSiloCastSessionPlayer if (player != null && selectVideoQuality(player, qualityId)) { val resolution = viewModel.uiState.value.videoQualities .firstOrNull { it.id == qualityId } @@ -478,22 +520,22 @@ fun TvPlayerScreen( } }, setVideoGravity = { value -> - viewModel.onVideoFillModeChanged(value.toPrairieCastVideoFillMode()) + viewModel.onVideoFillModeChanged(value.toSiloCastVideoFillMode()) }, setHdrEnabled = viewModel::onSetHdrEnabled, setSubtitleSyncMs = viewModel::onSubtitleDelayChanged, setSubtitlePosition = { value -> viewModel.onSetSubtitleAppearance( - latestPrairieCastSubtitleAppearance.copy(position = value.toPrairieCastSubtitlePosition()), + latestSiloCastSubtitleAppearance.copy(position = value.toSiloCastSubtitlePosition()), ) }, setVolume = { volume -> val next = volume.toFloat().coerceIn(0f, 1f) if (next > 0.001f) lastAudibleRemoteVolume = next - latestPrairieCastMediaController?.volume = next + latestSiloCastMediaController?.volume = next }, setMuted = { muted -> - val controller = latestPrairieCastMediaController + val controller = latestSiloCastMediaController if (muted) { controller?.volume?.takeIf { it > 0.001f }?.let { lastAudibleRemoteVolume = it } controller?.volume = 0f @@ -503,14 +545,14 @@ fun TvPlayerScreen( }, playNext = viewModel::playNextEpisodeNow, ) - val registration = prairieCastReceiver.registerPlayer(adapter) { - viewModel.uiState.value.toPrairieCastPlaybackState( + val registration = siloCastReceiver.registerPlayer(adapter) { + viewModel.uiState.value.toSiloCastPlaybackState( contentId = contentId, - playbackSpeed = latestPrairieCastPlaybackSpeed, - hdrEnabled = latestPrairieCastHdrEnabled, - subtitleDelayMs = latestPrairieCastSubtitleDelayMs, - subtitleAppearance = latestPrairieCastSubtitleAppearance, - volume = latestPrairieCastMediaController?.volume?.toDouble() ?: 1.0, + playbackSpeed = latestSiloCastPlaybackSpeed, + hdrEnabled = latestSiloCastHdrEnabled, + subtitleDelayMs = latestSiloCastSubtitleDelayMs, + subtitleAppearance = latestSiloCastSubtitleAppearance, + volume = latestSiloCastMediaController?.volume?.toDouble() ?: 1.0, ) } onDispose { registration.close() } @@ -523,15 +565,14 @@ fun TvPlayerScreen( // idempotent local departure follows behind it. roomController?.leave(closeRoom = false) mediaController?.let { controller -> - viewModel.onPositionChanged( - controller.currentPosition, - controller.duration.coerceAtLeast(0L), + viewModel.stopSessionForExitAsync( + positionMs = controller.currentPosition, + durationMs = controller.duration.coerceAtLeast(0L), ) controller.pause() controller.stop() controller.clearMediaItems() - } - viewModel.stopSessionForExitAsync() + } ?: viewModel.stopSessionForExitAsync() latestOnExit() } } @@ -553,7 +594,7 @@ fun TvPlayerScreen( // singleton, so a late stop() could clobber the next episode's freshly // adopted session, and popUpTo would otherwise cancel it mid-flight. viewModel.stopSessionForExit() - onPlayNext(req.contentId, req.autoAdvanceCount, req.preferredQuality) + onPlayNext(req.contentId, req.autoAdvanceCount, req.episodeSelectionHandoff) } } val latestIntroSkipState by rememberUpdatedState(introSkipState) @@ -564,6 +605,11 @@ fun TvPlayerScreen( subtitleFocusedStableId = tvSubtitleOptionStableId(identity) viewModel.selectSubtitleOption(identity) } + fun applyQuickSubtitlePickerExit(exit: TvQuickSubtitlePickerExit) { + val chrome = tvQuickSubtitlePickerChromeState(exit) + showQuickSubtitlePicker = chrome.pickerVisible + viewModel.setControlsVisible(chrome.controlsVisible) + } val subtitlePresentation = buildTvSubtitleHudPresentation( options = buildTvSubtitleHudOptions( subtitleUrls = state.subtitleUrls, @@ -584,18 +630,29 @@ fun TvPlayerScreen( ) } - fun handleSkipIntroNow(): Boolean { - val target = viewModel.uiState.value.intro?.end ?: return false + fun handleIntroPromptSelect(): Boolean { + val playerState = viewModel.uiState.value + if (!latestIntroSkipState.isVisible) return false + // The controller decides where Select goes — the intro's end for the + // `ask` offer, its start for `always`'s undo — and resolves the intro. + // In a room the gate is checked BEFORE asking, so a guest's refused + // press leaves the pill (and the intro) exactly as it was. if (roomController != null) { if (tvRoomTransportGate(latestRoomSnapshot, TvTransportIntent.Seek) != TransportGate.Send) { return true } - viewModel.onSkipIntroNow() ?: return false + val target = viewModel.onSelectIntroPrompt() ?: return false roomController.onUserSeek(target) } else { - val soloTarget = viewModel.onSkipIntroNow() ?: return false + val soloTarget = viewModel.onSelectIntroPrompt() ?: return false viewModel.seekImmediate(soloTarget) } + // The pill unmounts with the intro state, taking its focus with it, so + // aim at the scrubber (where Down from the pill goes). Only with the + // controls up: otherwise the overlay owning the scrubber isn't composed. + if (playerState.showControls) { + requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) + } return true } @@ -630,7 +687,8 @@ fun TvPlayerScreen( ) { return true } - val duration = playerState.duration.takeIf { it > 0.0 } ?: (controller.duration / 1000.0) + val duration = playerState.duration.takeIf { it > 0.0 } + ?: if (playerState.playbackPlan == null) controller.duration / 1000.0 else 0.0 val targetSec = if (roomController == null) { viewModel.onSkipBy(deltaMs / 1000.0) } else { @@ -646,15 +704,21 @@ fun TvPlayerScreen( requestIdleOverlayFocus(TvIdleOverlayFocusTarget.Scrubber) } viewModel.setControlsVisible(true) - } else { - // Silent seek: surface the transient skip indicator instead. - skipSeekFeedback = SkipSeekFeedback( - deltaSeconds = (deltaMs / 1000).toInt(), - targetSec = targetSec, - durationSec = duration.coerceAtLeast(0.0), - nonce = (skipSeekFeedback?.nonce ?: 0) + 1, - ) } + // The chip runs on BOTH paths. Revealing the transport shows where the + // playhead landed but not that it moved, nor by how much — a solitary + // press reads as the bar twitching. Room seeks commit per press with no + // accumulator, so there the per-press delta IS the total. + val burstDeltaSec = viewModel.quickSkipBurstOriginSec + ?.takeIf { roomController == null } + ?.let { targetSec - it } + ?: (deltaMs / 1000.0) + skipSeekFeedback = SkipSeekFeedback( + deltaSeconds = burstDeltaSec.roundToInt(), + targetSec = targetSec, + durationSec = duration.coerceAtLeast(0.0), + nonce = (skipSeekFeedback?.nonce ?: 0) + 1, + ) if (captureQuickSkipBurst && roomController == null) { armQuickSkipCapture() } @@ -694,7 +758,8 @@ fun TvPlayerScreen( quickSkipCaptureJob = null quickSkipCaptureActive = false cleanSeekPreviewSec = viewModel.uiState.value.position.coerceAtLeast(0.0) - cleanSeekRate = if (direction < 0) -1 else 1 + val sign = if (direction < 0) -1 else 1 + cleanSeekRate = TvSeekRateLadder.BASE_RATE * sign cleanSeekTickJob?.cancel() cleanSeekTickJob = cleanPlaybackSeekScope.launch { @@ -704,17 +769,29 @@ fun TvPlayerScreen( durationSec = viewModel.uiState.value.duration, rate = cleanSeekRate, ) - delay(CLEAN_SEEK_TICK_MS) + delay(TvSeekRateLadder.TICK_MILLIS) } } + // Ramp on the shared ladder rather than a fixed 2→4→8. Now that a tick + // covers rate × real time, a fixed ceiling cannot serve both ends: 8× + // would take over twenty minutes to cross a film. The ladder derives + // its top from the runtime so "hold until it arrives" costs about the + // same whatever you're watching. cleanSeekRampJob?.cancel() cleanSeekRampJob = cleanPlaybackSeekScope.launch { - for (magnitude in listOf(2, 4, 8)) { - delay(CLEAN_SEEK_RAMP_INTERVAL_MS) - val currentRate = cleanSeekRate - if (currentRate == 0) return@launch - cleanSeekRate = if (currentRate < 0) -magnitude else magnitude + val durationSec = viewModel.uiState.value.duration + var previous = TvSeekRateLadder.BASE_RATE * sign + repeat(TvSeekRateLadder.rampSteps(durationSec)) { step -> + delay(TvSeekRateLadder.RAMP_STEP_MILLIS) + // Only continue while the viewer is still holding at the rate + // the previous step left, so a release and a fresh press the + // other way isn't overwritten by this hold's timer. + if (cleanSeekRate != previous) return@launch + val next = TvSeekRateLadder.sustainedRate(step, sign, durationSec) + if (next == previous) return@launch + cleanSeekRate = next + previous = next } } } @@ -776,7 +853,11 @@ fun TvPlayerScreen( fun adjustCleanPlaybackSeek(adjustment: Int) { cleanSeekRampJob?.cancel() cleanSeekRampJob = null - cleanSeekRate = adjustedCleanPlaybackSeekRate(cleanSeekRate, adjustment) + cleanSeekRate = adjustedCleanPlaybackSeekRate( + currentRate = cleanSeekRate, + adjustment = adjustment, + durationSec = viewModel.uiState.value.duration, + ) } fun commitCleanPlaybackSeek(snapshot: RoomSnapshot?) { @@ -827,12 +908,26 @@ fun TvPlayerScreen( } } - // While a HUD picker dialog is open, the HUD owns Back: its onPreviewKeyEvent - // closes only the active picker and consumes the event, so the screen-level - // BackHandler must defer (disabled) rather than tearing down the whole HUD. - BackHandler(enabled = !(state.hudOpen && hudPickerOpen)) { + // More-specific overlays register their own BackHandlers later in the + // composition and therefore run first. This screen callback owns the + // remaining player-state ladder on Android 16, where KEYCODE_BACK is no + // longer dispatched to apps targeting API 36. + BackHandler { + TvFocusLog.d { + "player BackHandler hudOpen=${state.hudOpen} showControls=${state.showControls} " + + "scrubbing=${state.isScrubbing} quickSubs=$showQuickSubtitlePicker " + + "cleanSeek=$cleanSeekRate paused=${state.isPaused}" + } when { cleanSeekRate != 0 -> stopCleanPlaybackSeek() + state.isScrubbing -> viewModel.cancelScrub() + // Below the seek/scrub entries deliberately: a Back during a scrub + // belongs to the scrub. Above the overlays because the countdown is + // the most transient thing on screen. Handled HERE and not only in + // the legacy key bridge — on API 36 Back never reaches + // dispatchKeyEvent, so a countdown Back would otherwise fall + // through to hiding the controls or exiting the player. + latestIntroSkipState.isVisible -> viewModel.onDismissIntroPrompt() showQuickSubtitlePicker -> showQuickSubtitlePicker = false state.showSubtitleStyleDialog -> viewModel.closeSubtitleStyleDialog() state.showSubtitleMenu -> viewModel.closeSubtitleMenu() @@ -844,7 +939,7 @@ fun TvPlayerScreen( // While PLAYING, Back steps controls -> hidden before exiting. // While PAUSED, hiding controls would just strand a frozen frame, // so Back falls through to the exit (or room-leave) flow instead — - // Apple parity (prairie-apple f12a928). + // Apple parity (silo-apple f12a928). state.showControls && !state.isPaused -> viewModel.setControlsVisible(false) // In a room: Back surfaces the Leave affordance. Host gets a // close-confirm dialog (closing tears down the room for everyone); @@ -863,6 +958,13 @@ fun TvPlayerScreen( DisposableEffect(viewModel, roomController) { val handler: (KeyEvent) -> Boolean = handler@{ event -> val playerState = viewModel.uiState.value + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + TvFocusLog.d { + "player bridge BACK action=${event.action} hudOpen=${playerState.hudOpen} " + + "showControls=${playerState.showControls} paused=${playerState.isPaused} " + + "quickSubs=$latestShowQuickSubtitlePicker cleanSeek=$cleanSeekRate" + } + } if (playerState.streamUrl == null || playerState.isLoading || playerState.error != null) { return@handler false } @@ -968,6 +1070,10 @@ fun TvPlayerScreen( // With the transport overlay or Up Next on screen, Left/Right // belong to Compose focus navigation, not seeking. dpadHorizontalSeek = !playerState.showControls && !playerState.showNextUp, + // Same condition, different job: Down opens the HUD only from + // clean playback. The HUD-open and modal cases never reach the + // dispatch below — the guard beneath this call returns first. + dpadDownOpensHud = !playerState.showControls && !playerState.showNextUp, ) // Apple parity (TVPlayerControls.rearmAutoHideOnFocusMove): any key // activity while the overlay is up re-arms the 5s auto-hide so the @@ -999,6 +1105,29 @@ fun TvPlayerScreen( return@handler false } + // Back takes the pill down and resolves the intro. Consumed so the + // press cannot also exit playback; afterwards no pill is showing, + // so a second Back behaves normally. Mirrors the BackHandler + // ladder's priority: a scrub or clean seek owns Back first, so the + // pill must not swallow it here on older Android and leave the + // scrub running. + if (latestIntroSkipState.isVisible && + event.keyCode == KeyEvent.KEYCODE_BACK && + cleanSeekRate == 0 && + !state.isScrubbing + ) { + // Both phases are consumed: a leaked ACTION_UP would reach the + // activity's back dispatcher. + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + viewModel.onDismissIntroPrompt() + } + return@handler true + } + + // D-pad directions deliberately do NOT touch the pill: the contract + // says focus moves as normal and the timer keeps running, so the + // viewer can look at the transport without losing the offer. + if (!playerState.showControls && !playerState.showNextUp && horizontalDirection != 0) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { beginCleanSeekPress(direction = horizontalDirection, allowsHold = true) @@ -1008,7 +1137,7 @@ fun TvPlayerScreen( if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && - latestIntroSkipState is IntroAutoSkipState.ShowingButton && + latestIntroSkipState.isVisible && // Only while the transport overlay is hidden: with controls up // a focused button owns Select — hijacking it here made every // OK press skip the intro for the whole intro window. @@ -1019,7 +1148,7 @@ fun TvPlayerScreen( KeyEvent.KEYCODE_NUMPAD_ENTER, ) ) { - return@handler handleSkipIntroNow() + return@handler handleIntroPromptSelect() } // Back while PLAYING with the transport overlay up: hide the @@ -1077,8 +1206,16 @@ fun TvPlayerScreen( performRelativeSeek(-SKIP_BACK_MS, latestRoomSnapshot, revealControls = true) TvPlayerRemoteKeyAction.SkipForward -> performRelativeSeek(SKIP_FORWARD_MS, latestRoomSnapshot, revealControls = true) - TvPlayerRemoteKeyAction.OpenHud -> { - requestedHudTab = HudTab.Info + TvPlayerRemoteKeyAction.OpenSettingsHud -> { + requestedHudTab = HudTab.Video + viewModel.openHUD() + true + } + TvPlayerRemoteKeyAction.OpenPlaybackHud -> { + requestedHudTab = preferredPlaybackHudTab( + hasAudioTracks = playerState.audioTracks.isNotEmpty(), + hasSubtitleTracks = playerState.subtitleTracks.isNotEmpty(), + ) viewModel.openHUD() true } @@ -1111,6 +1248,18 @@ fun TvPlayerScreen( } } + // A subtitle or audio change that failed has to say so. Stage, validation, + // commit, rollback and mount failures all populated subtitleFailureMessage + // and nothing ever read it: "Applying…" simply vanished and the tick + // returned to the previous track, which is indistinguishable from the + // viewer having imagined pressing it. Audio replans share this adapter, so + // they were equally silent. + LaunchedEffect(state.subtitleFailureId) { + val message = state.subtitleFailureMessage ?: return@LaunchedEffect + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + viewModel.onSubtitleFailureShown(state.subtitleFailureId) + } + // Surface transient Watch Together server rejections (e.g. a guest seek the // server refuses) as a brief Toast. These flow on the repo errors stream and // do NOT eject the user. Only collected while bound to a room. @@ -1139,23 +1288,47 @@ fun TvPlayerScreen( dolbyVisionEnabled, ) { val backend = videoBackend ?: return@LaunchedEffect + // Let the audio route settle before asking media3 to reselect. + // + // A reselection is resolved by seeking the current media period, and + // while an audio sink is being torn down and rebuilt there is no such + // period — the seek then dereferences a null holder and kills playback + // outright (MediaPeriodHolder.info in seekToCurrentPosition). A KVM + // switching inputs produces exactly that: HDMI drops or returns, the + // sink is rebuilt, and capabilities are re-reported mid-rebuild. + // + // Guarding by state cannot see this window — the player looks healthy + // from here throughout, which is why two previous attempts (#182, #186) + // did not help. Waiting does: this effect restarts on every capability + // report, so route churn coalesces into a single application once the + // reports stop. Only capability *changes* wait; the first application + // for a backend still runs immediately, because startup track selection + // must not be deferred. + if (trackPresetsApplied) delay(TrackSelectionSettleMs) // With Dolby Vision off, drop DV profiles (except 5 — no watchable // base layer) so the DV MIME preference is not added and multi-track // content selects the HEVC/HDR10 variant. DolbyVisionPolicy is the - // single decision source (Apple parity, prairie-apple e9bd775). + // single decision source (Apple parity, silo-apple e9bd775). val effectiveDisplayHdr = displayHdr.copy( dolbyVisionProfiles = org.prairieserver.prairie.player.DolbyVisionPolicy.advertisableProfiles( displayHdr.dolbyVisionProfiles, org.prairieserver.prairie.player.DolbyVisionPolicy.Snapshot(dolbyVisionEnabled = dolbyVisionEnabled), ), ) - backend.applyTrackSelection( - audioCaps = audioCaps, - displayHdr = effectiveDisplayHdr, - preferredAudioLanguage = state.preferredAudioLanguage, - preferredTextLanguage = state.preferredTextLanguage, - hdrEnabled = hdrEnabled, - ) + // Only a REAL application counts. The factory skips silently while the + // player is idle or unmounted; letting that skip flip the flag would + // reclassify the true first application as a "later capability change" + // and defer startup track selection by the settle delay. + if (backend.applyTrackSelection( + audioCaps = audioCaps, + displayHdr = effectiveDisplayHdr, + preferredAudioLanguage = state.preferredAudioLanguage, + preferredTextLanguage = state.preferredTextLanguage, + hdrEnabled = hdrEnabled, + ) + ) { + trackPresetsApplied = true + } } // HDR display-mode switching: attach the controller to the activity window @@ -1166,16 +1339,23 @@ fun TvPlayerScreen( onDispose { hdrDisplayController.restore() } } - DisposableEffect(context) { + // Hold the screen awake only while playback is actually advancing. The + // flag used to be held for the life of the screen, which on a TV meant a + // paused player suppressed the system screensaver indefinitely — a static + // image parked on the panel for hours is exactly what burn-in protection + // exists to prevent. Mirrors the phone player's gate (same user-visible + // rule: pause long enough and the screensaver takes over, resume and the + // screen is held again). Buffering counts as playing so a rebuffer at a + // scene boundary cannot blank the screen mid-watch. + val keepScreenAwake = !state.isPaused && (state.isPlaying || state.isBuffering) + DisposableEffect(context, keepScreenAwake) { val window = (context as? Activity)?.window - if (window != null) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - onDispose { - if (window != null) { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } + if (keepScreenAwake) { + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } val latestLifecycleRoomSnapshot by rememberUpdatedState(roomSnapshot) @@ -1459,11 +1639,18 @@ fun TvPlayerScreen( recovery.correctionId, "rendered_frame_progress", ) - is PostResumeVideoStallDetector.Signal.Failed -> viewModel.onRuntimeCorrection( - "runtime_correction_failed", - recovery.correctionId, - "bounded_recovery_exhausted", - ) + is PostResumeVideoStallDetector.Signal.Failed -> { + viewModel.onRuntimeCorrection( + "runtime_correction_failed", + recovery.correctionId, + "bounded_recovery_exhausted", + ) + // Tell the viewer too. This signal fires once and never + // again, so a frozen picture with running audio would + // otherwise sit there indefinitely, recorded in + // telemetry and invisible on screen. + viewModel.onPlaybackRecoveryExhausted() + } null -> Unit } delay(1_000) @@ -1472,6 +1659,26 @@ fun TvPlayerScreen( } // Prepare the player when a stream URL becomes available. + // Applies a local audio switch: the track is already in the mounted stream, + // so it only needs selecting on the player. The ViewModel does not commit + // on the strength of this call -- AudioTrackManager returns Unit and does + // nothing silently if the group is gone -- it waits for onTracksChanged to + // show the target selected. + LaunchedEffect(videoBackend) { + val backend = videoBackend ?: return@LaunchedEffect + viewModel.pendingLocalAudioSelection.collect { request -> + request ?: return@collect + backend.selectAudioTrack( + VideoPlayerTrackEntry( + index = request.targetOrdinal, + label = "", + language = null, + isSelected = true, + ), + ) + } + } + LaunchedEffect( videoBackend, state.sessionId, @@ -1495,15 +1702,25 @@ fun TvPlayerScreen( delivery = delivery, serverUrl = state.serverUrl, container = state.container, - subtitles = state.subtitleUrls, + subtitles = subtitlesForVideoMediaMount( + subtitles = state.subtitleUrls, + playbackPlan = plan, + subtitleIdentity = state.pendingSubtitleIdentity + ?: state.committedSubtitleIdentity, + preferMuxedTracks = true, + ), title = state.title.ifBlank { null }, artworkUrl = state.artworkUrl, startPositionSeconds = state.startPosition, timelineOffsetSeconds = plan?.timeline?.timelineOffsetSeconds ?: 0.0, durationSeconds = viewModel.uiState.value.duration.takeIf { it > 0.0 } - ?: mediaController?.duration - ?.takeIf { it > 0L } - ?.div(1000.0) + ?: if (plan == null) { + mediaController?.duration + ?.takeIf { it > 0L } + ?.div(1000.0) + } else { + null + } ?: 0.0, audioPassthroughCodecs = plan.validatedPassthroughCodecs(), requestHeaders = state.requestHeaders, @@ -1521,6 +1738,7 @@ fun TvPlayerScreen( playMethod = method, startPositionMs = mediaSpec.startPositionMs, nowMs = SystemClock.elapsedRealtime(), + clientTransformations = mediaSpec.transformations, ) postResumeStallDetector.onMounted( "$sessionId:$url:${plan?.planId.orEmpty()}:" + @@ -1551,15 +1769,25 @@ fun TvPlayerScreen( delivery = delivery, serverUrl = state.serverUrl, container = state.container, - subtitles = state.subtitleUrls, + subtitles = subtitlesForVideoMediaMount( + subtitles = state.subtitleUrls, + playbackPlan = plan, + subtitleIdentity = state.pendingSubtitleIdentity + ?: state.committedSubtitleIdentity, + preferMuxedTracks = true, + ), title = state.title.ifBlank { null }, artworkUrl = state.artworkUrl, startPositionSeconds = state.startPosition, timelineOffsetSeconds = plan?.timeline?.timelineOffsetSeconds ?: 0.0, durationSeconds = viewModel.uiState.value.duration.takeIf { it > 0.0 } - ?: mediaController?.duration - ?.takeIf { it > 0L } - ?.div(1000.0) + ?: if (plan == null) { + mediaController?.duration + ?.takeIf { it > 0L } + ?.div(1000.0) + } else { + null + } ?: 0.0, audioPassthroughCodecs = plan.validatedPassthroughCodecs(), requestHeaders = state.requestHeaders, @@ -1571,27 +1799,27 @@ fun TvPlayerScreen( backend.refresh(mediaSpec) } - // Auto-select a freshly downloaded/translated subtitle track once the - // rebuilt item's tracks land (the VM matches by label in onTracksChanged - // and emits the ordinal text-group index). Mirrors the seekRequests idiom. + // The single path from the subtitle transaction adapter to the player. + // Every request carries the owner that armed it, so the acknowledgement can + // never be dropped for want of one. Mirrors the seekRequests idiom. LaunchedEffect(videoBackend) { val backend = videoBackend ?: return@LaunchedEffect - viewModel.subtitleSelectRequests.collect { idx -> - if (idx == -1) { + viewModel.subtitleMountRequests.collect { request -> + if (request.trackIndex == -1) { if (backend.selectSubtitle(null)) { - viewModel.onSubtitleSelectionApplied(idx) + viewModel.onSubtitleSelectionApplied(request) } else { - viewModel.onSubtitleSelectionFailed(idx) + viewModel.onSubtitleSelectionFailed(request) } return@collect } val selectedTrack = viewModel.uiState.value.subtitleTracks - .firstOrNull { it.index == idx } + .firstOrNull { it.index == request.trackIndex } ?.toVideoTrackEntry() if (selectedTrack != null && backend.selectSubtitle(selectedTrack)) { - viewModel.onSubtitleSelectionApplied(idx) + viewModel.onSubtitleSelectionApplied(request) } else { - viewModel.onSubtitleSelectionFailed(idx) + viewModel.onSubtitleSelectionFailed(request) } } } @@ -1651,6 +1879,10 @@ fun TvPlayerScreen( subtitleManager.applyAppearance(pv, subtitleAppearance) } + // The video branch of the player's `when` below — the only state in which + // the PlayerView is mounted and video-scoped overlays should draw. + val videoActive = state.streamUrl != null && !state.isLoading && state.error == null + LaunchedEffect( context, mediaController, @@ -1668,7 +1900,7 @@ fun TvPlayerScreen( surface = PrairiePictureInPictureSurface.Tv, state = PrairiePictureInPicturePlaybackState( enabled = false, - videoActive = state.streamUrl != null && !state.isLoading && state.error == null, + videoActive = videoActive, isPlaying = state.isPlaying && !state.isPaused, videoWidth = pictureInPictureVideoWidth, videoHeight = pictureInPictureVideoHeight, @@ -1687,7 +1919,15 @@ fun TvPlayerScreen( // remote key press can reach onPreviewKeyEvent. LaunchedEffect(state.showControls) { if (!state.showControls) { - runCatching { rootFocus.requestFocus() } + // The outer Box must own focus while the overlay is hidden or the + // first remote press never reaches onPreviewKeyEvent — the viewer + // presses once, nothing happens, and presses again. + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = rootFocus::requestFocus, + isFocused = { playerRootHasFocus }, + ) } } // Auto-hide the Compose overlay after CONTROLS_AUTO_HIDE_MS. @@ -1699,13 +1939,18 @@ fun TvPlayerScreen( state.showSubtitleMenu, state.showSubtitleStyleDialog, state.isScrubbing, + state.showNextUp, ) { // Never auto-hide mid-scrub: hiding the scrubber would tear down the // in-flight preview under the user. The timer re-arms once the scrub // commits or cancels (isScrubbing flips back to false). + // + // Up Next counts down for longer than this timer, and it is a + // focus-owning surface: letting the timer fire under it hides the + // controls and pulls focus to the root, off the primary action. if (state.showControls && !state.isPaused && !state.hudOpen && !state.showSubtitleMenu && !state.showSubtitleStyleDialog && - !state.isScrubbing + !state.isScrubbing && !state.showNextUp ) { delay(CONTROLS_AUTO_HIDE_MS) viewModel.setControlsVisible(false) @@ -1717,6 +1962,7 @@ fun TvPlayerScreen( .fillMaxSize() .background(Color.Black) .focusRequester(rootFocus) + .onFocusChanged { playerRootHasFocus = it.isFocused } .focusable() .onPreviewKeyEvent { event -> // Hidden Left/Right is classified from the complete Android @@ -1777,7 +2023,10 @@ fun TvPlayerScreen( isFocusable = false isFocusableInTouchMode = false descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS - setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + // Buffering is surfaced by our own "Buffering" + // capsule, not PlayerView's centered spinner. + // Enabling both draws two indicators at once. + setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) // Capture the inflated view so the subtitle // appearance LaunchedEffect can target it. playerViewRef = this @@ -1822,14 +2071,16 @@ fun TvPlayerScreen( bufferedAheadSec = bufferedAheadSec, chapters = state.chapters, introRange = state.intro, - isBuffering = state.isBuffering, - sleepTimerState = sleepTimerState, + creditsRange = state.credits, + recapRange = state.recap, + previewRange = state.preview, // In a room, skip/scrub/seek are routed through the // controller (transport_request → server → broadcast // command → engine applies the seek locally). Solo // playback seeks the MediaController directly. transportEnabled = canSeekInRoom, playPauseEnabled = canPlayPauseInRoom, + canToggleAfterCommit = roomController == null, onSkipBack = { if (canSeekInRoom) { performRelativeSeek( @@ -1874,8 +2125,10 @@ fun TvPlayerScreen( } viewModel.setControlsVisible(true) }, + // The Tune button IS the cog: same settings entry point + // as the remote's Menu/Settings key, so same landing tab. onOpenHUD = { - requestedHudTab = HudTab.Info + requestedHudTab = HudTab.Video viewModel.openHUD() }, onOpenQuickSubtitles = { @@ -1931,6 +2184,8 @@ fun TvPlayerScreen( subtitlePresentation = subtitlePresentation, stats = state.stats, playbackPlan = state.playbackPlan, + desiredAudioOrdinal = state.desiredAudioOrdinal, + desiredAudioConfirmed = state.desiredAudioConfirmed, videoFillMode = state.videoFillMode, onSelectAudio = viewModel::selectAudioOption, onSelectVideoQuality = { id -> @@ -1944,8 +2199,8 @@ fun TvPlayerScreen( sleepTimerState = sleepTimerState, onStartSleepTimer = viewModel::onStartSleepTimer, onCancelSleepTimer = viewModel::onCancelSleepTimer, - autoSkipIntro = autoSkipIntroEnabled, - onAutoSkipIntroChanged = viewModel::onSetAutoSkipIntro, + introSkipMode = introSkipMode, + onIntroSkipModeChanged = viewModel::onSetIntroSkipMode, autoPlayNext = autoPlayNextEnabled, onAutoPlayNextChanged = viewModel::onSetAutoPlayNext, audioDelayMs = audioDelayMs, @@ -1981,6 +2236,7 @@ fun TvPlayerScreen( onHdrEnabledChanged = viewModel::onSetHdrEnabled, dolbyVisionEnabled = dolbyVisionEnabled, onDolbyVisionEnabledChanged = viewModel::onSetDolbyVisionEnabled, + dolbyVisionSwitchInFlight = dolbyVisionSwitchInFlight, chapters = state.chapters, onSelectChapter = { idx -> viewModel.onSeekToChapter(idx)?.let { sec -> @@ -2002,7 +2258,6 @@ fun TvPlayerScreen( }, onDismiss = { viewModel.closeHUD() }, initialTab = requestedHudTab, - onPickerOpenChanged = { hudPickerOpen = it }, ) } } @@ -2026,35 +2281,58 @@ fun TvPlayerScreen( } } - // Transient skip feedback for hidden-controls D-pad seeks. - // Suppressed while the transport, HUD, or Up Next own the - // screen (they provide their own position feedback) and in PiP. - if (!isInPictureInPictureMode && cleanSeekRate == 0 && !state.showControls && + // Transient skip feedback, for both the hidden-controls D-pad + // skip and the transport/remote skip that reveals the overlay. + // Suppressed while the HUD or Up Next own the screen (they + // provide their own position feedback) and in PiP. + // + // ONE render site across both cases on purpose: a reveal-path + // skip sets the chip and flips showControls in the same handler, + // and splitting these would tear down one AnimatedVisibility and + // fade in another mid-transition. + if (!isInPictureInPictureMode && cleanSeekRate == 0 && !state.hudOpen && !state.showNextUp ) { - // Align the transient line with the REAL scrubber track's - // position inside the idle overlay, which stacks (bottom-up): - // 40dp overlay padding + 33dp transport cluster + 16dp gap + - // 8dp spacer + 16dp gap = 113dp to the scrubber COLUMN's - // bottom — plus ~6dp because the 3.5dp track is centered in - // the column's lower box (41dp minus label row), not flush - // with its bottom. Horizontal 80dp matches the track width. + // Controls hidden: align the transient line with the REAL + // scrubber track's position inside the idle overlay, which + // stacks (bottom-up): 40dp overlay padding + 33dp transport + // cluster + 16dp gap + 8dp spacer + 16dp gap = 113dp to the + // scrubber COLUMN's bottom — plus ~6dp because the 3.5dp + // track is centered in the column's lower box (41dp minus + // label row), not flush with its bottom. + // + // Controls visible: the live scrubber already reports + // position, so the chip drops its own track and rises into + // the 42dp gap between that column's top (113 + 41) and the + // title block at 196dp. Centred, so it clears the + // left-aligned title at any title width. + // Horizontal 80dp matches the track width in both cases. Box( modifier = Modifier .fillMaxSize() - .padding(start = 80.dp, end = 80.dp, bottom = 119.dp), + .padding( + start = 80.dp, + end = 80.dp, + bottom = if (state.showControls) 154.dp else 119.dp, + ), contentAlignment = Alignment.BottomCenter, ) { - TvSkipSeekIndicator(feedback = skipSeekFeedback) + TvSkipSeekIndicator( + feedback = skipSeekFeedback, + showTrack = !state.showControls, + ) } } if (!isInPictureInPictureMode && showQuickSubtitlePicker) { TvQuickSubtitlePicker( presentation = subtitlePresentation, + onSelect = subtitlePresentation.onSelect, + onSelectionComplete = { + applyQuickSubtitlePickerExit(TvQuickSubtitlePickerExit.Selection) + }, onDismiss = { - showQuickSubtitlePicker = false - viewModel.setControlsVisible(true) + applyQuickSubtitlePickerExit(TvQuickSubtitlePickerExit.Back) }, ) } @@ -2095,145 +2373,48 @@ fun TvPlayerScreen( } } - // Lifecycle-driven notice toast (top-start). Slides in for outage - // recovery, fades out when the lifecycle clears the notice. - if (!isInPictureInPictureMode) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 32.dp, start = 32.dp), - contentAlignment = Alignment.TopStart, - ) { - TvPlayerNoticeOverlay(notice = notice) - } - } - - // Remote-control "display_message" toast (top-center), shown a few - // seconds regardless of controls visibility. - if (!isInPictureInPictureMode) remoteMessage?.let { message -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 48.dp) - .zIndex(10f), - contentAlignment = Alignment.TopCenter, - ) { - Box( - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.82f), - shape = RoundedCornerShape(12.dp), - ) - .padding(horizontal = 24.dp, vertical = 14.dp), - ) { - Text( - text = message.text, - color = Color.White, - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - - // Watch Together room indicator (top-end so it doesn't collide with - // the top-start lifecycle notice). Member count, a "Waiting for - // members…" pill while the room is on the wait barrier, and the join - // code for the host. Only shown while the idle overlay is up. - val snapshot = roomSnapshot - if (!isInPictureInPictureMode && roomController != null && snapshot != null && state.showControls && !state.hudOpen) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 32.dp, end = 32.dp), - contentAlignment = Alignment.TopEnd, - ) { - TvRoomIndicator( - memberCount = snapshot.memberCount, - waiting = snapshot.playbackState == RoomPlaybackState.Waiting, - joinCode = snapshot.code.takeIf { snapshot.selfCanManageRoom && it.isNotBlank() }, - ) - } - } - - // Host close-confirm dialog. Closing tears the room down for everyone - // (server emits room_closed → every member exits). Cancel resumes. - if (!isInPictureInPictureMode && showLeaveDialog && roomController != null) { - TvRoomCloseConfirmDialog( - onClose = { - showLeaveDialog = false - roomController.leave(closeRoom = true) - stopPlaybackAndExit() - }, - onCancel = { showLeaveDialog = false }, - ) - } - - // F2 / Up-Next end-of-playback surface. Replaces the old "Still - // watching?" dialog: a 16:9 mini-player (the still-playing video, - // visible behind a bordered frame) beside a next-episode panel with - // Play Now / Keep Watching / Back and an auto-play countdown ring. - if (!isInPictureInPictureMode) { - if (state.showNextUp) { - TvPlayerNextUpOverlay( - nextEpisode = state.nextEpisode, - videoEnded = state.nextUpVideoEnded, - countdownSeconds = state.nextUpCountdownSeconds, - countdownTotalSeconds = state.nextUpCountdownTotalSeconds, - autoPlayEnabled = autoPlayNextEnabled, - onPlayNow = viewModel::playNextEpisodeNow, - onKeepWatching = viewModel::dismissNextUp, - onToggleAutoPlay = { viewModel.onSetAutoPlayNext(!autoPlayNextEnabled) }, - onBack = { stopPlaybackAndExit() }, - ) - } - } - - // Intro auto-skip banner (bottom-end, above the transport cluster). - // It must remain visible even when transport controls auto-hide; D-pad - // Center routes directly to [handleSkipIntroNow] while the manual prompt - // is active, so the viewer does not need a first click just to reveal UI. - // Bottom inset (200dp) clears the transport cluster + scrubber column. - if (!isInPictureInPictureMode) { - if (!state.hudOpen && !state.showNextUp) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 200.dp, end = 32.dp), - contentAlignment = Alignment.BottomEnd, - ) { - TvIntroAutoSkipBanner( - state = introSkipState, - onSkipNow = { handleSkipIntroNow() }, - onCancelCountdown = viewModel::onCancelIntroAutoSkip, - ) - } - } - } - - // Outage spinner. Native ExoPlayer buffering now surfaces as the - // top-right Buffering capsule inside the idle overlay's statusColumn - // (mirroring tvOS), so the centered full-screen spinner is reserved for - // the lifecycle Reconnecting state (the server-outage probe loop, which - // the player itself can't observe) — and only when the idle overlay - // isn't already showing the chip. The Up-Next overlay owns its own - // loading state, so no spinner there either. - val showSpinner = shouldShowReconnectSpinner( - isReconnecting = sessionState is SessionState.Reconnecting, - showNextUp = state.showNextUp, + TvPlayerOverlays( isInPictureInPictureMode = isInPictureInPictureMode, + notice = notice, + remoteMessage = remoteMessage, + roomSnapshot = roomSnapshot, + roomActive = roomController != null, + showControls = state.showControls, + hudOpen = state.hudOpen, + showLeaveDialog = showLeaveDialog, + showNextUp = state.showNextUp, + nextEpisode = state.nextEpisode, + nextUpVideoEnded = state.nextUpVideoEnded, + nextUpCountdownSeconds = state.nextUpCountdownSeconds, + nextUpCountdownTotalSeconds = state.nextUpCountdownTotalSeconds, + autoPlayNextEnabled = autoPlayNextEnabled, + introSkipState = introSkipState, + introSkipCountdownRun = introSkipCountdownRun, + introSkipTimerRunning = introSkipTimerRunning, + introSkipTotalSeconds = viewModel.introSkipTotalSeconds, + // The scrubber commits its seek on focus loss, so the prompt must + // not take focus out from under an active scrub. + introBannerMayTakeFocus = !state.isScrubbing && cleanSeekRate == 0, + videoActive = videoActive, + isBuffering = state.isBuffering, + sleepTimerState = sleepTimerState, + showSpinner = shouldShowReconnectSpinner( + isReconnecting = sessionState is SessionState.Reconnecting, + showNextUp = state.showNextUp, + isInPictureInPictureMode = isInPictureInPictureMode, + ), + onCloseRoom = { + showLeaveDialog = false + roomController?.leave(closeRoom = true) + stopPlaybackAndExit() + }, + onCancelLeaveDialog = { showLeaveDialog = false }, + onPlayNextNow = viewModel::playNextEpisodeNow, + onKeepWatching = viewModel::dismissNextUp, + onToggleAutoPlayNext = { viewModel.onSetAutoPlayNext(!autoPlayNextEnabled) }, + onExitPlayback = { stopPlaybackAndExit() }, + onIntroPromptSelect = { handleIntroPromptSelect() }, ) - if (showSpinner) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = Color.White, - strokeWidth = 4.dp, - modifier = Modifier.size(64.dp), - ) - } - } } } @@ -2255,8 +2436,9 @@ private fun TvPlayerIdleOverlay( bufferedAheadSec: Double, chapters: List, introRange: org.prairieserver.prairie.model.catalog.TimeRange?, - isBuffering: Boolean, - sleepTimerState: SleepTimerState, + creditsRange: org.prairieserver.prairie.model.catalog.TimeRange?, + recapRange: org.prairieserver.prairie.model.catalog.TimeRange?, + previewRange: org.prairieserver.prairie.model.catalog.TimeRange?, onPlayPause: () -> Unit, onSkipBack: () -> Unit, onSkipForward: () -> Unit, @@ -2275,21 +2457,51 @@ private fun TvPlayerIdleOverlay( // play/pause (host_only policy) gets a no-op play/pause. transportEnabled: Boolean = true, playPauseEnabled: Boolean = true, + /** + * Whether Center may toggle playback after committing a scrub. + * + * False in a Watch Together room. There, the commit and the play/pause are + * two independently launched room requests, and the play/pause carries the + * live position rather than the committed one — so it can land after the + * seek and pull every participant back to where the scrub started. Solo + * playback applies both locally and in order, so it keeps the behaviour. + */ + canToggleAfterCommit: Boolean = true, ) { val scrubberFocus = remember { FocusRequester() } val playPauseFocus = remember { FocusRequester() } + var idleOverlayHasFocus by remember { mutableStateOf(false) } + // Observed per ROW, not for the overlay as a whole. `idleOverlayHasFocus` is + // hasFocus on the overlay's root, so it is already true whenever ANY control + // holds focus — including the scrubber. Using it as the arrival test made + // every request from inside the overlay a no-op: D-pad Down on the scrub bar + // asks for the transport, the retry loop sees "already focused" and never + // requests, and focus stays on the bar. That is why reaching the controls + // needed a Back (which hides the overlay, clearing the flag) before Down. + var scrubberHasFocus by remember { mutableStateOf(false) } + var transportHasFocus by remember { mutableStateOf(false) } var currentRate by remember { mutableStateOf(0) } LaunchedEffect(focusRequest.nonce) { - runCatching { - when (focusRequest.target) { - TvIdleOverlayFocusTarget.Scrubber -> scrubberFocus.requestFocus() - TvIdleOverlayFocusTarget.Transport -> playPauseFocus.requestFocus() - } - } + val overlayTarget = when (focusRequest.target) { + TvIdleOverlayFocusTarget.Scrubber -> scrubberFocus + TvIdleOverlayFocusTarget.Transport -> playPauseFocus + } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = overlayTarget::requestFocus, + isFocused = { + when (focusRequest.target) { + TvIdleOverlayFocusTarget.Scrubber -> scrubberHasFocus + TvIdleOverlayFocusTarget.Transport -> transportHasFocus + } + }, + ) } Box( modifier = Modifier + .onFocusChanged { idleOverlayHasFocus = it.hasFocus } .fillMaxSize() .onPreviewKeyEvent { event -> when ( @@ -2304,7 +2516,10 @@ private fun TvPlayerIdleOverlay( true } TvPlayerRemoteKeyAction.FocusTransport -> { - runCatching { playPauseFocus.requestFocus() } + playPauseFocus.claimFocusOrReport( + target = "player_transport", + action = "remote_focus_transport", + ) true } TvPlayerRemoteKeyAction.SkipBack -> { @@ -2315,7 +2530,13 @@ private fun TvPlayerIdleOverlay( onSkipForward() true } - TvPlayerRemoteKeyAction.OpenHud -> { + // OpenPlaybackHud can't originate here — this surface leaves + // dpadDownOpensHud off, because with the overlay up Down is + // how focus reaches the transport row. Handled so the branch + // stays exhaustive if that ever changes. + TvPlayerRemoteKeyAction.OpenSettingsHud, + TvPlayerRemoteKeyAction.OpenPlaybackHud, + -> { onOpenHUD() true } @@ -2350,6 +2571,7 @@ private fun TvPlayerIdleOverlay( // Interactive scrubber — capsule track with chapter ticks, ±10s // skip, hold-to-auto-seek, and Select to commit. tvOS spec §4.1. TvPlayerScrubber( + modifier = Modifier.onFocusChanged { scrubberHasFocus = it.hasFocus }, positionSec = positionSec, durationSec = durationSec, bufferedAheadSec = bufferedAheadSec, @@ -2364,6 +2586,15 @@ private fun TvPlayerIdleOverlay( introRangeSec = introRange ?.takeIf { it.end > it.start } ?.let { it.start..it.end }, + creditsRangeSec = creditsRange + ?.takeIf { it.end > it.start } + ?.let { it.start..it.end }, + recapRangeSec = recapRange + ?.takeIf { it.end > it.start } + ?.let { it.start..it.end }, + previewRangeSec = previewRange + ?.takeIf { it.end > it.start } + ?.let { it.start..it.end }, cancelOnBlur = false, onSkipBack = onSkipBack, onSkipForward = onSkipForward, @@ -2372,8 +2603,13 @@ private fun TvPlayerIdleOverlay( onCommitScrub = onCommitScrub, onCancelScrub = onCancelScrub, onRequestFocus = scrubberFocus, + onPlayPause = onPlayPause, + canToggleAfterCommit = canToggleAfterCommit, onMoveDownToTransport = { - runCatching { playPauseFocus.requestFocus() } + playPauseFocus.claimFocusOrReport( + target = "player_transport", + action = "scrubber_move_down", + ) }, onExitWhenIdle = onClose, onRateChanged = { currentRate = it }, @@ -2382,6 +2618,7 @@ private fun TvPlayerIdleOverlay( Spacer(modifier = Modifier.height(8.dp)) TvPlayerTransportCluster( + modifier = Modifier.onFocusChanged { transportHasFocus = it.hasFocus }, isPlaying = !isPaused, onSkipBack = onSkipBack, onPlayPause = onPlayPause, @@ -2392,69 +2629,14 @@ private fun TvPlayerIdleOverlay( onClose = onClose, playPauseFocus = playPauseFocus, onMoveUpToScrubber = { - runCatching { scrubberFocus.requestFocus() } + scrubberFocus.claimFocusOrReport( + target = "player_scrubber", + action = "transport_move_up", + ) }, ) } - // Top-right status chips — buffering capsule (spinner + "Buffering") - // and a sleep-timer countdown chip — mirroring tvOS statusColumn. - // Replaces the full-screen buffering spinner during playback. - val sleepRemaining = (sleepTimerState as? SleepTimerState.Active)?.remainingSeconds - if (isBuffering || sleepRemaining != null) { - Column( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 64.dp, end = 80.dp), - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (isBuffering) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.Black.copy(alpha = 0.55f)) - .padding(horizontal = 14.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - color = Color.White, - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - ) - androidx.tv.material3.Text( - text = "Buffering", - color = Color.White.copy(alpha = 0.85f), - style = androidx.tv.material3.MaterialTheme.typography.labelLarge, - ) - } - } - if (sleepRemaining != null) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(percent = 50)) - .background(Color.Black.copy(alpha = 0.55f)) - .padding(horizontal = 14.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.tv.material3.Icon( - imageVector = Icons.Filled.Bedtime, - contentDescription = null, - tint = Color.White.copy(alpha = 0.85f), - modifier = Modifier.size(16.dp), - ) - androidx.tv.material3.Text( - text = formatSleepCountdown(sleepRemaining), - color = Color.White.copy(alpha = 0.85f), - style = androidx.tv.material3.MaterialTheme.typography.labelLarge, - ) - } - } - } - } - // Quiet bottom-left title footer above the scrubber column (tvOS // titleFooter idiom) — series / title / episode tag, shadowed, no box, // no "Playing" literal. Sits above the transport stack's top padding. @@ -2517,6 +2699,8 @@ private fun formatSleepCountdown(seconds: Int): String { @Composable private fun TvQuickSubtitlePicker( presentation: TvSubtitleHudPresentation, + onSelect: (SubtitleIdentity) -> Unit, + onSelectionComplete: () -> Unit, onDismiss: () -> Unit, ) { val checkedRow = presentation.rows.firstOrNull { row -> row.checked } @@ -2554,9 +2738,12 @@ private fun TvQuickSubtitlePicker( closeOnSelect = false, onFocused = presentation.onFocused, onSelect = { stableId -> - presentation.rows - .firstOrNull { row -> row.stableId == stableId } - ?.let { row -> presentation.onSelect(row.identity) } + dispatchTvQuickSubtitlePickerSelection( + presentation = presentation, + stableId = stableId, + onSelect = onSelect, + onSelectionComplete = onSelectionComplete, + ) }, ), onClose = onDismiss, @@ -2612,8 +2799,10 @@ private fun TvRoomIndicator( * next-episode panel on the right: an "Up Next" / "Playing Next" eyebrow, * series-context-free episode metadata ("S·E · title" + overview), a Play Now * primary button, a Keep Watching dismiss button, a Back button, an auto-play - * countdown ring (counts to zero then plays the next episode), and finished / - * loading states when no next episode is available. + * countdown ring (a card raised at the end counts a wall clock to zero and then + * plays the next episode; one raised at the credits marker mirrors the + * remaining playback time and waits for the stream to actually end), and + * finished / loading states when no next episode is available. * * Replaces the old "Still watching?" dialog as the sole end-of-playback * surface; the pass-out gate now manifests as the overlay appearing WITHOUT a @@ -2632,12 +2821,19 @@ private fun TvPlayerNextUpOverlay( onBack: () -> Unit, ) { val primaryFocus = remember { FocusRequester() } + var upNextHasFocus by remember { mutableStateOf(false) } LaunchedEffect(nextEpisode?.contentId, videoEnded) { - runCatching { primaryFocus.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = primaryFocus::requestFocus, + isFocused = { upNextHasFocus }, + ) } Box( modifier = Modifier + .onFocusChanged { upNextHasFocus = it.hasFocus } .fillMaxSize() .background( Brush.horizontalGradient( @@ -2922,6 +3118,15 @@ private fun PlaybackExecutionPlan?.validatedPassthroughCodecs(): List { private const val TAG = "TvPlayerScreen" +/** + * How long a capability change waits before track-selection presets are + * re-applied, so an audio sink that is being rebuilt is not asked to reselect + * mid-rebuild. Long enough to cover a KVM input switch settling; short enough + * that a genuine capability change (AVR powered on, headphones paired) still + * takes effect while the viewer is watching. + */ +private const val TrackSelectionSettleMs = 1_500L + /** * Flatten an ExoPlayer [Tracks] object into TV-facing entries. Audio/video * keep the legacy group-level mapping. Text tracks flatten every format inside @@ -2946,7 +3151,7 @@ internal fun extractTrackEntries(tracks: Tracks, type: Int): List "top" SubtitlePositionPreset.LowerThird -> "lower-third" SubtitlePositionPreset.Bottom -> "bottom" } -private fun String.toPrairieCastVideoFillMode(): VideoFillMode = +private fun String.toSiloCastVideoFillMode(): VideoFillMode = when (trim().lowercase()) { "zoom", "crop", "fill" -> VideoFillMode.Zoom "stretch" -> VideoFillMode.Stretch else -> VideoFillMode.Fit } -private fun String.toPrairieCastSubtitlePosition(): SubtitlePositionPreset { +private fun String.toSiloCastSubtitlePosition(): SubtitlePositionPreset { when (trim().lowercase()) { "top" -> return SubtitlePositionPreset.Top "lower-third", "lower_third", "lowerthird" -> return SubtitlePositionPreset.LowerThird @@ -3252,3 +3457,288 @@ internal fun selectVideoQuality(player: Player, id: String): Boolean { } return false } + + +/** + * The overlay layer stacked above the player surface: lifecycle notice, remote + * message toast, Watch Together indicator and close confirmation, the Up Next + * surface, the intro auto-skip banner, and the reconnect spinner. + * + * Split out of [TvPlayerScreen] to keep that composable's generated method + * within ART's JIT limit. Past it the method is never compiled, so the whole + * player screen runs interpreted and the runtime logs "Method exceeds compiler + * instruction limit" on every recomposition — roughly once a second during + * playback. + */ +@Composable +private fun TvPlayerOverlays( + isInPictureInPictureMode: Boolean, + notice: PlayerNotice?, + remoteMessage: RemoteMessage?, + roomSnapshot: RoomSnapshot?, + roomActive: Boolean, + showControls: Boolean, + hudOpen: Boolean, + showLeaveDialog: Boolean, + showNextUp: Boolean, + nextEpisode: NextEpisodeState?, + nextUpVideoEnded: Boolean, + nextUpCountdownSeconds: Int?, + nextUpCountdownTotalSeconds: Int, + autoPlayNextEnabled: Boolean, + introSkipState: IntroAutoSkipState, + /** Bumps when the pill's timer (re)starts, so its fill re-anchors. */ + introSkipCountdownRun: Int, + /** False while the pill is up but its timer is frozen by a pause. */ + introSkipTimerRunning: Boolean, + introSkipTotalSeconds: Int, + /** False while a scrub owns focus — see TvIntroAutoSkipBanner.mayTakeFocus. */ + introBannerMayTakeFocus: Boolean, + /** True only while the video branch is composed — not loading, not errored. */ + videoActive: Boolean, + isBuffering: Boolean, + sleepTimerState: SleepTimerState, + showSpinner: Boolean, + onCloseRoom: () -> Unit, + onCancelLeaveDialog: () -> Unit, + onPlayNextNow: () -> Unit, + onKeepWatching: () -> Unit, + onToggleAutoPlayNext: () -> Unit, + onExitPlayback: () -> Unit, + onIntroPromptSelect: () -> Unit, +) { + // Lifecycle-driven notice toast (top-start). Slides in for outage + // recovery, fades out when the lifecycle clears the notice. + if (!isInPictureInPictureMode) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, start = 32.dp), + contentAlignment = Alignment.TopStart, + ) { + TvPlayerNoticeOverlay(notice = notice) + } + } + + // Remote-control "display_message" toast (top-center), shown a few + // seconds regardless of controls visibility. + if (!isInPictureInPictureMode) remoteMessage?.let { message -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp) + .zIndex(10f), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 24.dp, vertical = 14.dp), + ) { + Text( + text = message.text, + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + + // Watch Together room indicator (top-end so it doesn't collide with + // the top-start lifecycle notice). Member count, a "Waiting for + // members…" pill while the room is on the wait barrier, and the join + // code for the host. Only shown while the idle overlay is up. + val snapshot = roomSnapshot + if (!isInPictureInPictureMode && roomActive && snapshot != null && showControls && !hudOpen) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 32.dp, end = 32.dp), + contentAlignment = Alignment.TopEnd, + ) { + TvRoomIndicator( + memberCount = snapshot.memberCount, + waiting = snapshot.playbackState == RoomPlaybackState.Waiting, + joinCode = snapshot.code.takeIf { snapshot.selfCanManageRoom && it.isNotBlank() }, + ) + } + } + + // Top-right status chips: buffering capsule (spinner + "Buffering") and + // a sleep-timer countdown chip, mirroring tvOS statusColumn. Lives here + // rather than in the idle overlay so a hidden-controls D-pad seek still + // reports buffering. + val sleepRemaining = (sleepTimerState as? SleepTimerState.Active)?.remainingSeconds + // A stalled player reports buffering during an outage too, but the + // centered reconnect spinner and the notice toast already say more than + // the capsule would, so the capsule stands down while that one shows. + // + // Otherwise it stays up unconditionally, because PlayerView's own + // spinner is off (SHOW_BUFFERING_NEVER) and this capsule is now the + // only buffering feedback there is. In particular it must survive the + // HUD — picking a quality or version restarts the whole session with + // the HUD still open (closeOnSelect closes only the picker), which is + // the longest rebuffer in the app — and Up Next, where video keeps + // playing behind the mini-player frame until the credits end. Neither + // surface has a loading state of its own. + val showBufferingChip = isBuffering && !showSpinner + // The sleep countdown is ambient rather than urgent, so it yields the + // corner to the HUD and Up Next instead of competing with them. + val showSleepChip = sleepRemaining != null && !hudOpen && !showNextUp + // Chips belong to the playing video. The loading and error screens are + // separate branches of the player's `when` and own their whole surface, + // so a stale "Buffering" capsule must not float over either of them — + // `fail()` sets `error` without clearing `isBuffering`. + if (!isInPictureInPictureMode && videoActive && (showBufferingChip || showSleepChip)) { + // The HUD is a top-center card (top 56dp, up to 680dp wide, up to + // 360dp tall), so at the usual 960dp TV width its right edge runs + // under the chip's 80dp end inset. Drop below it rather than over + // it; nothing else occupies that band. + val chipTopPadding = if (hudOpen) 440.dp else 64.dp + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = chipTopPadding, end = 80.dp) + // Up Next composes after this block and paints a + // full-screen scrim, so lift the chips above it (still + // under the remote-message toast at 10f). + .zIndex(5f), + contentAlignment = Alignment.TopEnd, + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (showBufferingChip) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 14.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + androidx.tv.material3.Text( + text = "Buffering", + color = Color.White.copy(alpha = 0.85f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + ) + } + } + if (showSleepChip && sleepRemaining != null) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 14.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.tv.material3.Icon( + imageVector = Icons.Filled.Bedtime, + contentDescription = null, + tint = Color.White.copy(alpha = 0.85f), + modifier = Modifier.size(16.dp), + ) + androidx.tv.material3.Text( + text = formatSleepCountdown(sleepRemaining), + color = Color.White.copy(alpha = 0.85f), + style = androidx.tv.material3.MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } + + // Host close-confirm dialog. Closing tears the room down for everyone + // (server emits room_closed → every member exits). Cancel resumes. + if (!isInPictureInPictureMode && showLeaveDialog && roomActive) { + TvRoomCloseConfirmDialog( + onClose = onCloseRoom, + onCancel = onCancelLeaveDialog, + ) + } + + // F2 / Up-Next end-of-playback surface. Replaces the old "Still + // watching?" dialog: a 16:9 mini-player (the still-playing video, + // visible behind a bordered frame) beside a next-episode panel with + // Play Now / Keep Watching / Back and an auto-play countdown ring. + if (!isInPictureInPictureMode) { + if (showNextUp) { + TvPlayerNextUpOverlay( + nextEpisode = nextEpisode, + videoEnded = nextUpVideoEnded, + countdownSeconds = nextUpCountdownSeconds, + countdownTotalSeconds = nextUpCountdownTotalSeconds, + autoPlayEnabled = autoPlayNextEnabled, + onPlayNow = onPlayNextNow, + onKeepWatching = onKeepWatching, + onToggleAutoPlay = onToggleAutoPlayNext, + onBack = onExitPlayback, + ) + } + } + + // Intro skip pill (bottom-end, above the transport cluster). + // It must remain visible even when transport controls auto-hide; D-pad + // Center routes straight to the pill's Select while it is showing, so + // the viewer does not need a first click just to reveal UI. + // Bottom inset (200dp) clears the transport cluster + scrubber column. + if (!isInPictureInPictureMode) { + if (!hudOpen && !showNextUp) { + // Sits above the transport cluster while controls are up and + // drops toward the corner when they hide. + val introSkipBottomInset by animateDpAsState( + targetValue = if (showControls) 200.dp else 56.dp, + animationSpec = tween(durationMillis = 220), + label = "introSkipBottomInset", + ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = introSkipBottomInset, end = 32.dp), + contentAlignment = Alignment.BottomEnd, + ) { + TvIntroAutoSkipBanner( + state = introSkipState, + onSelect = onIntroPromptSelect, + totalSeconds = introSkipTotalSeconds, + countdownRun = introSkipCountdownRun, + timerRunning = introSkipTimerRunning, + // Not while the viewer is working the timeline: the + // scrubber commits its seek on focus loss, so taking + // focus here would land a seek they never confirmed. + mayTakeFocus = introBannerMayTakeFocus, + ) + } + } + } + + // Outage spinner. Native ExoPlayer buffering surfaces as the top-right + // Buffering capsule (mirroring tvOS), so this centered spinner is + // reserved for the lifecycle Reconnecting state: the server-outage + // probe loop, which the player itself can't observe. The capsule + // stands down while this shows, so the two never stack. Up Next keeps + // the capsule instead, so it gets no centered spinner. + if (showSpinner) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 4.dp, + modifier = Modifier.size(64.dp), + ) + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt index 0877c355d..8a4cae0cc 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScrubber.kt @@ -68,8 +68,10 @@ import kotlin.math.roundToInt * * - **Tap left/right** (idle): ±10 s quick skip via [onSkipBack] / [onSkipForward]. * - **Tap left/right** (timeline scrub): ±10 s nudge of the in-flight preview. - * - **Hold left/right**: enter timeline auto-seek at ±2x → tap again to bump - * the rate up to ±32x. + * - **Hold left/right**: enter timeline auto-seek at ±2x, doubling every + * 900 ms up to a ceiling derived from the item's runtime — ±256x for a + * 22-minute episode, ±1024x for a feature — or tap again to bump the rate + * by hand. See [TvSeekRateLadder.maxRateFor]. * - **OK / Select**: commit an in-flight preview (or enter timeline scrub). * - **Back / Down**: cancel the preview / move focus to transport. * @@ -102,9 +104,12 @@ fun TvPlayerScrubber( scrubPreviewSec: Double, chapters: List, cancelOnBlur: Boolean, - // Intro / skip region [startSec, endSec] drawn as a cyan band on the track - // when known (mirrors tvOS TVPlayerScrubber.introRegion). Null = no band. + // Detected marker bands [startSec, endSec] drawn on the track when known. + // Null = no band. Mirrors tvOS TVPlayerScrubber.introRegion. introRangeSec: ClosedRange? = null, + creditsRangeSec: ClosedRange? = null, + recapRangeSec: ClosedRange? = null, + previewRangeSec: ClosedRange? = null, onSkipBack: () -> Unit, onSkipForward: () -> Unit, onBeginScrub: () -> Unit, @@ -112,6 +117,16 @@ fun TvPlayerScrubber( onCommitScrub: () -> Unit, onCancelScrub: () -> Unit, onRequestFocus: FocusRequester, + /** + * Toggle play/pause. Center on the bar is bound to this, not to entering a + * scrub: the Google TV remote has no dedicated play/pause key, so Center + * with the overlay up is the only one-press pause a viewer has — and it is + * what every other TV player does. Scrubbing does not need it; Left/Right + * skip and long-press engages auto-seek. + */ + onPlayPause: () -> Unit, + /** See TvPlayerIdleOverlay.canToggleAfterCommit. */ + canToggleAfterCommit: Boolean = true, onMoveDownToTransport: () -> Unit, onExitWhenIdle: () -> Unit, onRateChanged: (Int) -> Unit = {}, @@ -132,13 +147,10 @@ fun TvPlayerScrubber( var autoSeekRate by remember { mutableStateOf(0) } val scope = rememberCoroutineScope() var autoSeekJob by remember { mutableStateOf(null) } - // Time-based ramp ladder. Sustained press climbs through ±[1, 2, 4, 8] at - // fixed elapsed-time milestones (1.0s / 2.0s / 3.0s); subsequent repeat - // bumps via `bumpRate` can carry past 8 up to 32. + // Speeds and ramp live in TvSeekRateLadder so the chip's number and the + // distance actually travelled cannot drift apart again. var holdRampJob by remember { mutableStateOf(null) } - val rates = remember { listOf(-32, -16, -8, -4, -2, -1, 1, 2, 4, 8, 16, 32) } - fun setRate(rate: Int) { autoSeekRate = rate onRateChanged(rate) @@ -156,46 +168,49 @@ fun TvPlayerScrubber( if (!isScrubbing) onBeginScrub() isTimelineScrubbing = true val sign = if (direction < 0) -1 else 1 - setRate(sign) + setRate(TvSeekRateLadder.BASE_RATE * sign) autoSeekJob?.cancel() autoSeekJob = scope.launch { while (isActive) { // Delay first so onBeginScrub's position seed lands in // currentPreviewSec before the first tick reads it (otherwise the // first update would overwrite the seed and scanning starts at ~0). - delay(100) + delay(TvSeekRateLadder.TICK_MILLIS) val rate = autoSeekRate if (rate == 0) break - val base = currentPreviewSec + 2.0 * rate + val base = currentPreviewSec + TvSeekRateLadder.tickSeconds(rate) onUpdateScrub(base) } } - // Time-based progression: 1.0s -> ±2, 2.0s -> ±4, 3.0s -> ±8. Stops at - // 8 — repeat-key bumps can still climb to ±16/±32. Cancelled in - // stopAutoSeek when the user releases / commits / cancels. + // Sustained progression through the ladder. Each step only fires if the + // viewer is still holding the same direction at the rate the previous + // step left — otherwise a release and a fresh press the other way would + // be overwritten by a timer from the abandoned hold. holdRampJob?.cancel() holdRampJob = scope.launch { - delay(1000) - // Only bump if the user is still holding the same direction (rate - // sign matches). Avoids races where the user released and a - // separate press flipped direction before the timer fired. - if (autoSeekRate == sign) setRate(2 * sign) - delay(1000) - if (autoSeekRate == 2 * sign) setRate(4 * sign) - delay(1000) - if (autoSeekRate == 4 * sign) setRate(8 * sign) + var previous = TvSeekRateLadder.BASE_RATE * sign + repeat(TvSeekRateLadder.rampSteps(durationSec)) { step -> + delay(TvSeekRateLadder.RAMP_STEP_MILLIS) + // Only continue while the viewer is still holding at the rate + // the previous step left; a release and a fresh press the other + // way must not be overwritten by this hold's timer. + if (autoSeekRate != previous) return@launch + val next = TvSeekRateLadder.sustainedRate(step, sign, durationSec) + if (next == previous) return@launch + setRate(next) + previous = next + } } } fun bumpRate(delta: Int) { - val idx = rates.indexOf(autoSeekRate) - if (idx < 0) return - val next = (idx + delta).coerceIn(0, rates.size - 1) + val next = TvSeekRateLadder.bumped(autoSeekRate, delta, durationSec) + if (next == autoSeekRate) return // User-driven rate change cancels the time-based ramp so it doesn't // overwrite the manual pick a beat later. holdRampJob?.cancel() holdRampJob = null - setRate(rates[next]) + setRate(next) } // Cancel any in-flight scrub on focus loss when the shell asks us to @@ -345,13 +360,21 @@ fun TvPlayerScrubber( Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { if (isUp) { stopAutoSeek() - if (isTimelineScrubbing || isScrubbing) { + // Center means "here": land any scrub in + // flight, then flip playback. Racing forward at + // 32x it stops on the frame you asked for; + // hunting a spot while paused it plays on from + // it. Entering a scrub MODE here — what this + // used to do — spent the viewer's only + // one-press pause on something Left/Right + // already do, and the Google TV remote has no + // dedicated play/pause key to fall back on. + val committed = isTimelineScrubbing || isScrubbing + if (committed) { isTimelineScrubbing = false onCommitScrub() - } else { - onBeginScrub() - isTimelineScrubbing = true } + if (!committed || canToggleAfterCommit) onPlayPause() true } else if (isDown) true else false } @@ -395,26 +418,31 @@ fun TvPlayerScrubber( ), ) - // Intro / skip region — cyan band on the track (tvOS introRegion). - // Drawn above the bare track but below the played fill / ticks so - // the playhead still reads clearly over it. - if (introRangeSec != null && durationSec > 0) { - val introStart = (introRangeSec.start / durationSec).toFloat().coerceIn(0f, 1f) - val introEnd = (introRangeSec.endInclusive / durationSec).toFloat().coerceIn(0f, 1f) - if (introEnd > introStart) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .offset(x = barWidthDp * introStart) - .fillMaxWidth(introEnd - introStart) - .height(trackHeight) - .clip(RoundedCornerShape(percent = 50)) - .background( - Color.Cyan.copy( - alpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f, - ), - ), - ) + // Marker bands — intro/recap/credits/preview, each a tinted band on + // the track. Drawn above the bare track but below the played fill / + // ticks so the playhead still reads clearly over it. + if (durationSec > 0) { + val bandAlpha = if (isTimelineScrubbing || isFocused) 0.45f else 0.34f + val markers = listOfNotNull( + introRangeSec?.let { it to Color.Cyan }, + recapRangeSec?.let { it to Color(0xFF8BC34A) }, + creditsRangeSec?.let { it to Color(0xFFFFB74D) }, + previewRangeSec?.let { it to Color(0xFFBA68C8) }, + ) + for ((range, color) in markers) { + val start = (range.start / durationSec).toFloat().coerceIn(0f, 1f) + val end = (range.endInclusive / durationSec).toFloat().coerceIn(0f, 1f) + if (end > start) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = barWidthDp * start) + .fillMaxWidth(end - start) + .height(trackHeight) + .clip(RoundedCornerShape(percent = 50)) + .background(color.copy(alpha = bandAlpha)), + ) + } } } @@ -452,7 +480,9 @@ fun TvPlayerScrubber( if (frac > 0.001f) { Box( modifier = Modifier - .align(Alignment.Center) + // CenterStart, not Center: `offset` is anchor-relative, + // so Center adds half the bar width to every tick. + .align(Alignment.CenterStart) .offset(x = barWidthDp * frac - 1.5.dp) .width(if (isTimelineScrubbing) 3.dp else 2.dp) .height(trackHeight + 8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt index 65a96ee98..c1c9b97e3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitlePolicy.kt @@ -6,6 +6,7 @@ import org.prairieserver.prairie.model.catalog.SubtitleTrack import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.playback.audioTrackFingerprint @@ -13,7 +14,9 @@ import org.prairieserver.prairie.playback.SUBTITLE_OFF_FINGERPRINT import org.prairieserver.prairie.playback.decodeSubtitleIdentityPreference import org.prairieserver.prairie.playback.encodeCatalogSubtitlePreference import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.matchesSubtitleMediaIdentity import org.prairieserver.prairie.playback.resolveCatalogSubtitlePreferenceOrdinal +import org.prairieserver.prairie.playback.resolveDownloadedSubtitlePreferenceOrdinal import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal import org.prairieserver.prairie.playback.subtitleTrackFingerprint import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate @@ -55,6 +58,14 @@ internal fun resolveTvFreshSubtitlePreference( typed is SubtitleIdentity.ServerBurnIn || typed is SubtitleIdentity.Embedded ) { + val authoritativeMatches = hydratedRows.filter { row -> + tvSubtitleIdentity(row) == typed + } + if (authoritativeMatches.size == 1) { + return TvFreshSubtitlePreferenceResolution( + tvSubtitleIdentity(authoritativeMatches.single()), + ) + } val ordinal = resolveCatalogSubtitlePreferenceOrdinal(catalogTracks, saved) ?: return null val rebuilt = encodeCatalogSubtitlePreference(catalogTracks, ordinal) ?.let(::decodeSubtitleIdentityPreference) @@ -63,13 +74,14 @@ internal fun resolveTvFreshSubtitlePreference( } if (typed is SubtitleIdentity.Downloaded) { - val row = hydratedRows - .filter { it.downloadId == typed.downloadId } - .singleOrNull() + val ordinal = resolveDownloadedSubtitlePreferenceOrdinal(typed, hydratedRows) ?: return null - val rebuilt = tvSubtitleIdentity(row) - return (rebuilt as? SubtitleIdentity.Downloaded) - ?.let(::TvFreshSubtitlePreferenceResolution) + val rebuilt = tvSubtitleIdentity(hydratedRows[ordinal]) + return TvFreshSubtitlePreferenceResolution( + identity = rebuilt, + migratedPreference = encodeSubtitleIdentityPreference(rebuilt) + .takeIf { rebuilt != typed }, + ) } if (typed is SubtitleIdentity.LocalMedia3) { @@ -83,7 +95,7 @@ internal fun resolveTvFreshSubtitlePreference( return TvFreshSubtitlePreferenceResolution(typed) } val candidates = localRows.filter { row -> - row.tvMediaIdentity().matchesPersisted(typed.media) + row.tvMediaIdentity().matchesSubtitleMediaIdentity(typed.media) } if (candidates.size != 1) return null return TvFreshSubtitlePreferenceResolution(typed) @@ -113,19 +125,6 @@ internal fun resolveTvFreshSubtitlePreference( ) } -internal fun resolveTvPersistedAudioPlayerOrdinal( - fingerprint: String?, - catalogAudioTracks: List, - mountedAudioTracks: List, -): Int? { - val catalogOrdinal = resolveAudioTrackOrdinal(catalogAudioTracks, fingerprint) - ?.takeIf { it >= 0 } - ?: return null - return mountedAudioTracks - .singleOrNull { it.index == catalogOrdinal } - ?.index -} - /** * Hydration and restore resolution are one owned publication unit. A stale * load can finish its network call, but it cannot return rows or an intent. @@ -243,12 +242,64 @@ internal fun tvAudioTrackPersistenceUpdate( committedAudioTrackIndex: Int?, audioTracks: List, ): TrackSelectionFingerprintUpdate = + // An ORDINAL into audioTracks: audio carries no index on the wire, so + // matching on AudioTrack.index found nothing for any ordinal above 0 and + // silently Preserved — the chosen track was never persisted, so reopening + // the item lost it. committedAudioTrackIndex - ?.let { selected -> audioTracks.singleOrNull { it.index == selected } } + ?.let(audioTracks::getOrNull) ?.let(::audioTrackFingerprint) ?.let(TrackSelectionFingerprintUpdate::Set) ?: TrackSelectionFingerprintUpdate.Preserve +/** + * Whether a commit may write the durable per-item subtitle preference. + * + * The transaction adapter cannot tell an automatic pick from a viewer's choice + * once it is committed — both arrive at the persistence port as the same + * [SubtitleIdentity] — so the caller carries [automaticIdentity]: the identity + * the APP selected on the viewer's behalf, if it is still the committed one. An + * automatic pick must never be written back as though the viewer had made it, + * or every later launch would "restore" a choice nobody made. + */ +internal fun tvSubtitlePersistenceUpdate( + committedIdentity: SubtitleIdentity, + automaticIdentity: SubtitleIdentity?, +): TrackSelectionFingerprintUpdate = + if (automaticIdentity != null && committedIdentity == automaticIdentity) { + TrackSelectionFingerprintUpdate.Preserve + } else { + TrackSelectionFingerprintUpdate.Set( + encodeSubtitleIdentityPreference(committedIdentity), + ) + } + +/** + * Safety net for a text track selected by something that is not the subtitle + * transaction adapter — device caption settings, a selector quirk, a renderer + * default. Returns the identity to adopt, or null when there is nothing to + * reconcile. + * + * This is NOT the mechanism by which subtitles get selected; reaching a + * non-null result means an authority we believed removed is still acting, which + * is why the caller logs it loudly. It deliberately stands down while anything + * is in flight: mid-transaction the track list is being republished and the + * pending identity is about to become the committed one, so "disagreement" + * there is just latency, not a second authority. + */ +internal fun tvExternalSubtitleAdoption( + subtitleTracks: List, + subtitleRows: List, + committedIdentity: SubtitleIdentity, + pendingIdentity: SubtitleIdentity?, + selectionInFlight: Boolean, +): SubtitleIdentity? { + if (selectionInFlight || pendingIdentity != null) return null + val selected = subtitleTracks.firstOrNull { it.isSelected } ?: return null + return tvMountedSubtitleIdentity(selected, subtitleTracks, subtitleRows) + .takeIf { it != committedIdentity } +} + @Suppress("UNUSED_PARAMETER") internal fun authoritativeTvSubtitleRows( snapshotRows: List, @@ -272,15 +323,18 @@ internal fun resolveTvRemoteSubtitleIntent( ?.let(::tvSubtitleIdentity) } +/** + * Remote `set_audio_track` carries an ordinal, and the server addresses audio + * by ordinal too, so this is an identity mapping guarded by range. It used to + * read `.index`, which audio never carries, so every remote pick requested 0. + */ internal fun resolveTvRemoteAudioIntent( playerOrdinal: Int, audioTracks: List, -): Int? = audioTracks.getOrNull(playerOrdinal)?.index +): Int? = playerOrdinal.takeIf { it in audioTracks.indices } private fun PlayerSubtitleInfo.isDownloadedTvPolicyRow(): Boolean = - downloadId != null || - source.equals("downloaded", ignoreCase = true) || - catalogSource.equals("downloaded", ignoreCase = true) + isLocalDownloadedSubtitle() private fun PlayerSubtitleInfo.tvMediaIdentity(): SubtitleMediaIdentity = when ( val identity = tvSubtitleIdentity(this) @@ -292,15 +346,3 @@ private fun PlayerSubtitleInfo.tvMediaIdentity(): SubtitleMediaIdentity = when ( is SubtitleIdentity.LocalMedia3 -> identity.media SubtitleIdentity.Off -> SubtitleMediaIdentity() } - -private fun SubtitleMediaIdentity.matchesPersisted(saved: SubtitleMediaIdentity): Boolean { - val discriminators = listOf( - saved.trackId?.let { trackId == it }, - saved.label?.let { label == it }, - saved.language?.let { language == it }, - saved.codecFamily?.let { codecFamily == it }, - saved.forced?.let { forced == it }, - saved.hearingImpaired?.let { hearingImpaired == it }, - ).filterNotNull() - return discriminators.isNotEmpty() && discriminators.all { it } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt index 1ec14638c..13e568253 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt @@ -3,7 +3,6 @@ package org.prairieserver.prairie.tv.ui.screens.player import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -65,7 +64,7 @@ fun TvPlayerTransportCluster( onOpenQuickSubtitles: () -> Unit, /** * Non-null only when there is a next episode to show. Mirrors - * silo-apple#86: the automatic trigger fires at the credits, and this lets + * prairie-apple#86: the automatic trigger fires at the credits, and this lets * a viewer who is already done reach it early. */ onUpNext: (() -> Unit)? = null, @@ -159,8 +158,9 @@ private fun TransportIconButton( val isFocused by interactionSource.collectIsFocusedAsState() // Uniform sizes across all buttons so the row reads as one transport group. - val buttonSize = 33.dp - val symbolSize = if (isPrimary) 15.dp else 12.5.dp + val metrics = tvTransportControlMetrics(isPrimary) + val buttonSize = metrics.buttonSizeDp.dp + val symbolSize = metrics.symbolSizeDp.dp // Focus is signaled by filling the circle white — no scale transform so the // buttons never cross the bounds of their circular hit target. @@ -176,11 +176,6 @@ private fun TransportIconButton( .size(buttonSize) .clip(CircleShape) .background(focusBg) - .border( - width = 0.5.dp, - color = if (isFocused) Color.Transparent else Color.White.copy(alpha = 0.22f), - shape = CircleShape, - ) .let { mod -> if (focusRequester != null) mod.focusRequester(focusRequester) else mod } .focusable(interactionSource = interactionSource) .onPreviewKeyEvent { event -> diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicy.kt new file mode 100644 index 000000000..105f504f1 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicy.kt @@ -0,0 +1,12 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +data class TvTransportControlMetrics( + val buttonSizeDp: Float, + val symbolSizeDp: Float, +) + +fun tvTransportControlMetrics(isPrimary: Boolean): TvTransportControlMetrics = + TvTransportControlMetrics( + buttonSizeDp = 44f, + symbolSizeDp = if (isPrimary) 22f else 20f, + ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt index 0b577e816..391e67ed8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt @@ -3,11 +3,13 @@ package org.prairieserver.prairie.tv.ui.screens.player import org.prairieserver.prairie.common.player.dolbyVisionTransformClassification +import org.prairieserver.prairie.common.player.failureDiagnostics import org.prairieserver.prairie.tv.BuildConfig import android.os.SystemClock import android.util.Log +import org.prairieserver.prairie.common.player.SubDiag import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality @@ -15,6 +17,14 @@ import org.prairieserver.prairie.common.player.PlaybackAnalyticsListener import org.prairieserver.prairie.common.player.PlaybackCapabilityDetector import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.player.PlaybackSessionManager +import org.prairieserver.prairie.common.player.PlaybackTeardownGate +import org.prairieserver.prairie.common.player.video.MountedAudioTrack +import org.prairieserver.prairie.common.player.video.AudioReconcileAction +import org.prairieserver.prairie.common.player.video.DesiredAudio +import org.prairieserver.prairie.common.player.video.LocalAudioSelection +import org.prairieserver.prairie.common.player.video.reconcileDesiredAudioAction +import org.prairieserver.prairie.common.player.video.matchMountedAudioTrack +import org.prairieserver.prairie.playback.resolveAudioTrackOrdinal import org.prairieserver.prairie.common.player.FinalPlaybackPosition import org.prairieserver.prairie.common.player.FinalPlaybackPositionWriter import org.prairieserver.prairie.common.player.VideoSessionStartV3 @@ -25,7 +35,6 @@ import org.prairieserver.prairie.common.player.SleepTimerController import org.prairieserver.prairie.common.player.SleepTimerState import org.prairieserver.prairie.common.player.StartParams import org.prairieserver.prairie.common.player.MountedSubtitleTrack -import org.prairieserver.prairie.common.player.isBitmapSubtitleCodecOrMime import org.prairieserver.prairie.common.player.resolveMountedSubtitle import org.prairieserver.prairie.common.player.backend.VideoBackendCapabilities import org.prairieserver.prairie.common.player.reducePlayerStats @@ -37,51 +46,81 @@ import org.prairieserver.prairie.common.player.seek.SeekPositionDecision import org.prairieserver.prairie.common.player.seek.decideSeek import org.prairieserver.prairie.common.player.seek.isSameRouteSeekReanchorCandidate import org.prairieserver.prairie.common.player.seek.playerPositionForSource +import org.prairieserver.prairie.common.player.seek.replanMountPositionForSource import org.prairieserver.prairie.common.player.seek.sourcePositionForPlayer import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.player.video.VideoPlaybackSessionCoordinator import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest +import org.prairieserver.prairie.common.player.video.EpisodeAudioIntent +import org.prairieserver.prairie.common.player.video.EpisodeAudioMode +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode +import org.prairieserver.prairie.common.player.video.ResolvedEpisodeSelection +import org.prairieserver.prairie.common.player.video.captureEpisodeSourceIntent +import org.prairieserver.prairie.common.player.video.captureEpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.normalizedSubtitleCodecFamily import org.prairieserver.prairie.common.player.video.VideoPlayerUiState import org.prairieserver.prairie.common.player.video.resolvedPlaybackDelivery import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot import org.prairieserver.prairie.domain.player.IntroAutoSkipController import org.prairieserver.prairie.domain.player.IntroAutoSkipState +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.domain.player.settlingFalseEdges import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.catalog.TimeRange import org.prairieserver.prairie.model.catalog.VersionChapter import org.prairieserver.prairie.model.settings.SubtitleAppearance +import org.prairieserver.prairie.model.playback.AutoSubtitleCandidate +import org.prairieserver.prairie.model.playback.AutoSubtitleContext +import org.prairieserver.prairie.model.playback.AutoSubtitleResolution +import org.prairieserver.prairie.model.playback.inventoryAutoSubtitleCandidates +import org.prairieserver.prairie.model.playback.resolveAutoSubtitle +import org.prairieserver.prairie.model.playback.selectedCandidate import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 import org.prairieserver.prairie.model.playback.PlayMethod +import org.prairieserver.prairie.model.playback.ClientCodecCapabilities +import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan import org.prairieserver.prairie.model.playback.PlaybackRouteFamily import org.prairieserver.prairie.model.playback.PlaybackSessionResponse +import org.prairieserver.prairie.model.playback.PlaybackTimeline import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.model.playback.mergeDownloadedSubtitles +import org.prairieserver.prairie.playback.PlaybackSubtitleReady +import org.prairieserver.prairie.playback.applyAuthoritativeSubtitleReadyTrack import org.prairieserver.prairie.model.subtitles.SubtitleAiQuota import org.prairieserver.prairie.model.subtitles.SubtitleAiStatus import org.prairieserver.prairie.model.subtitles.SubtitleDownloadRequest import org.prairieserver.prairie.model.subtitles.SubtitleResult import org.prairieserver.prairie.model.subtitles.SubtitleSearchRequest import org.prairieserver.prairie.model.subtitles.SubtitleTranslateRequest -import org.prairieserver.prairie.playback.SUBTITLE_OFF_FINGERPRINT -import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.playback.nextEpisodeAfter -import org.prairieserver.prairie.playback.resolveMountedSubtitleOrdinal import org.prairieserver.prairie.playback.subtitleTrackFingerprint +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.player.DolbyVisionPolicy import org.prairieserver.prairie.repository.SubtitlesRepository import org.prairieserver.prairie.repository.port.PlaybackWriteScope import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate +import org.prairieserver.prairie.tv.ui.screens.detail.TvDetailTrackSelectionSession +import kotlin.math.ceil import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.Flow @@ -91,6 +130,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flowOf @@ -99,8 +139,57 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +/** + * Always-on tag for subtitle-ownership anomalies. + * + * Distinct from SubDiag, which is opt-in tracing: everything logged under this + * tag means some authority other than the subtitle transaction adapter acted on + * a text track, which should not be possible and must be visible in a bug + * report without anyone having set a system property first. + */ +internal const val TV_SUBTITLE_LOG_TAG = "TvSubtitle" + +/** + * How long `isPlaying` must stay false before it counts as a pause rather than + * a rebuffer, for the intro countdown's purposes. + * + * Long enough to cover an ordinary network stall on a TV box, short enough that + * a viewer who actually pressed pause does not watch the countdown keep running + * afterwards. + */ +private const val PLAYBACK_PAUSE_GRACE_MS = 1_500L + +/** Reduced to the fields that can identify the track across index spaces. */ +internal fun PlayerTrackEntry.toMountedAudioTrack(): MountedAudioTrack = MountedAudioTrack( + ordinal = index, + language = language, + codecOrMime = codecOrMime, + channelCount = channelCount.takeIf { it > 0 }, + label = displayLabel.ifBlank { label }, +) + +/** Projects the protocol-v3 quality menu verbatim, preserving server order. */ +internal fun authoritativePlaybackQualityOptions( + available: List, + selectedLabel: String?, +): List = available.map { quality -> + VideoQualityOption( + id = quality.label, + label = quality.label, + isSelected = quality.label == selectedLabel, + resolution = quality.height.takeIf { it > 0 }?.let { "${it}p" }, + ) +} + +internal fun clampTvScrubPreview(seconds: Double, duration: Double): Double = + seconds.coerceAtLeast(0.0).let { value -> + if (duration > 0.0) value.coerceAtMost(duration) else value + } + /** * Renderable audio or subtitle track pulled out of ExoPlayer's current * `Tracks` object. [index] is the ordinal position among groups of the same @@ -123,13 +212,46 @@ data class PlayerTrackEntry( val trackId: String? = null, ) +/** + * The audio the server considers in force, as an ORDINAL into + * [FileVersion.audioTracks]. + * + * That ordinal is the server's actual contract for audio. Unlike subtitles, + * audio tracks carry NO index field on the wire — a probe of the running server + * returns `{"title":"English DTS 5.1","language":"en","codec":"dts",...}` with + * no `index`, while a subtitle in the same payload has `"index": 2`. So + * [AudioTrack.index] deserialises to its `0` default for every audio track and + * is not an identifier. `effective_audio_track_index` is likewise an ordinal. + * + * This previously read `catalogAudioTracks.getOrNull(ordinal)?.index`, which + * therefore evaluated to 0 for every track: every explicit audio pick asked the + * server for track 0, so choosing Dutch played English. + * + * The plan wins over the mounted Media3 ordinal. The plan carries the server's + * own selection, while a Media3 group ordinal describes only what THIS stream + * delivered — after a transcode the stream carries just the chosen track and + * reports ordinal zero, which is "first delivered group", not "catalog track + * zero". Preferring it made the next replan ask for track zero and silently + * reverted the audio to the first language. + * + * The Media3 ordinal survives as a fallback when there is no plan identity, and + * only when it is actually within the catalog's range. It is a guess: it holds + * just when delivered order matches catalog order. + */ internal fun selectedServerAudioTrackIndex( selectedPlayerOrdinal: Int?, catalogAudioTracks: List?, currentPlanTrackIndex: Int?, -): Int? = selectedPlayerOrdinal - ?.let { catalogAudioTracks?.getOrNull(it)?.index } - ?: currentPlanTrackIndex +): Int? { + val catalog = catalogAudioTracks.orEmpty() + // Validate the plan against the catalog when we have one: a stale + // plan/catalog pairing would otherwise forward an out-of-range ordinal. + // With no catalog to check against, the plan is still the best identity. + currentPlanTrackIndex?.let { plan -> + if (catalog.isEmpty() || plan in catalog.indices) return plan + } + return selectedPlayerOrdinal?.takeIf { it in catalog.indices } +} private fun SubtitleIdentity.serverTrackIndexForTv(): Int = when (this) { SubtitleIdentity.Off -> -1 @@ -141,24 +263,160 @@ private fun SubtitleIdentity.serverTrackIndexForTv(): Int = when (this) { -> -1 } -private val hearingImpairedSubtitleTokenRegex = Regex( - pattern = """(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""", - option = RegexOption.IGNORE_CASE, +private fun SubtitleMediaIdentity.toEpisodeSubtitleIntent( + external: Boolean?, +): EpisodeSubtitleIntent = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = canonicalSubtitleLanguage(language), + codecFamily = normalizedSubtitleCodecFamily(codecFamily), + forced = forced, + hearingImpaired = hearingImpaired, + external = external, +) + +/** + * Captures only episode-portable intent. Server, download, and Media3 identities + * remain local to the current item and therefore cannot cross this boundary. + */ +internal fun captureTvEpisodeSelectionHandoff( + activeVersion: FileVersion?, + committedSubtitleIdentity: SubtitleIdentity, + catalogSubtitles: List, + hasExplicitSubtitleSelection: Boolean, + selectedAudioTrack: PlayerTrackEntry? = null, + selectedCatalogAudio: AudioTrack? = null, + hasExplicitAudioSelection: Boolean = false, +): EpisodeSelectionHandoff = EpisodeSelectionHandoff( + source = captureEpisodeSourceIntent(activeVersion), + // Only an explicit choice travels. Carrying whatever the server happened to + // default to would pin that default onto every later episode, which looks + // identical to a preference the viewer never expressed. + audio = when { + !hasExplicitAudioSelection -> EpisodeAudioIntent.auto() + // Prefer the SOURCE row the plan selected. The mounted Media3 track is + // the delivered representation, so a DTS 5.1 source transcoded to AAC + // stereo would hand the next episode "UND / AAC / 2ch" as the stated + // preference — and the resolver weighs title and codec heavily enough + // to then match the wrong track or give up and take the default. + selectedCatalogAudio != null -> EpisodeAudioIntent( + mode = EpisodeAudioMode.TRACK, + language = selectedCatalogAudio.language, + codecFamily = selectedCatalogAudio.codec, + channelCount = selectedCatalogAudio.channels?.takeIf { it > 0 }, + title = selectedCatalogAudio.title, + ) + // Legacy fallback: no catalog row to resolve against. + selectedAudioTrack != null -> EpisodeAudioIntent( + mode = EpisodeAudioMode.TRACK, + language = selectedAudioTrack.language, + codecFamily = selectedAudioTrack.codecOrMime, + channelCount = selectedAudioTrack.channelCount.takeIf { it > 0 }, + // The label is what tells a commentary track from the main mix when + // language, codec and channel count are identical. + title = selectedAudioTrack.label, + ) + else -> EpisodeAudioIntent.auto() + }, + subtitle = if (!hasExplicitSubtitleSelection) { + org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent.auto() + } else { + when (committedSubtitleIdentity) { + SubtitleIdentity.Off -> captureEpisodeSubtitleIntent(-1, catalogSubtitles) + is SubtitleIdentity.ServerSidecar, + is SubtitleIdentity.ServerBurnIn, + is SubtitleIdentity.Embedded, + -> captureEpisodeSubtitleIntent( + committedSubtitleIdentity.serverTrackIndexForTv(), + catalogSubtitles, + ) + is SubtitleIdentity.Downloaded -> committedSubtitleIdentity.media + .toEpisodeSubtitleIntent(external = true) + is SubtitleIdentity.LocalMedia3 -> committedSubtitleIdentity.media + .toEpisodeSubtitleIntent(external = null) + } + }, ) -internal fun String.indicatesHearingImpairedSubtitle(): Boolean { - val lower = lowercase() - return lower.contains("closed caption") || - lower.contains("hearing impaired") || - lower.contains("hearing-impaired") || - lower.contains("hearing") || - hearingImpairedSubtitleTokenRegex.containsMatchIn(this) +internal class TvEpisodeSelectionHandoffLease internal constructor( + val ownerGeneration: Long, + val sequence: Long, + val handoff: EpisodeSelectionHandoff, +) + +/** Recoverable-start lease; only the current owner can retain or acknowledge it. */ +internal class TvEpisodeSelectionHandoffSlot( + handoff: EpisodeSelectionHandoff?, +) { + private var pending = handoff + private var currentLease: TvEpisodeSelectionHandoffLease? = null + private var sequence = 0L + + @Synchronized + fun leaseForStart(ownerGeneration: Long): TvEpisodeSelectionHandoffLease? { + val handoff = pending ?: return null + currentLease?.let { lease -> + if (lease.ownerGeneration == ownerGeneration) return lease + // A newer launch owner supersedes this transition rather than + // inheriting an older in-flight selection intent. + invalidate() + return null + } + return TvEpisodeSelectionHandoffLease( + ownerGeneration = ownerGeneration, + sequence = ++sequence, + handoff = handoff, + ).also { currentLease = it } + } + + @Synchronized + fun retainForRetry(lease: TvEpisodeSelectionHandoffLease?): Boolean { + if (lease == null || currentLease != lease) return false + currentLease = null + return true + } + + @Synchronized + fun acknowledgeReady(lease: TvEpisodeSelectionHandoffLease?): Boolean { + if (lease == null || currentLease != lease) return false + currentLease = null + pending = null + return true + } + + @Synchronized + fun invalidate() { + currentLease = null + pending = null + } +} + +internal data class TvEpisodeInitialSubtitleSelection( + val pendingInitialSubtitleIndex: Int?, + val suppressDurableSubtitleRestore: Boolean, +) + +/** Applies a target-only resolution without changing ordinary/manual starts. */ +internal fun resolveTvEpisodeInitialSubtitleSelection( + episodeSelectionHandoff: EpisodeSelectionHandoff?, + resolvedEpisodeSelection: ResolvedEpisodeSelection?, + existingPendingInitialSubtitleIndex: Int?, +): TvEpisodeInitialSubtitleSelection { + if (episodeSelectionHandoff == null || resolvedEpisodeSelection == null) { + return TvEpisodeInitialSubtitleSelection( + pendingInitialSubtitleIndex = existingPendingInitialSubtitleIndex, + suppressDurableSubtitleRestore = false, + ) + } + return TvEpisodeInitialSubtitleSelection( + pendingInitialSubtitleIndex = resolvedEpisodeSelection.subtitleTrackIndex, + suppressDurableSubtitleRestore = resolvedEpisodeSelection.subtitleIntentSpecified, + ) } private fun PlayerTrackEntry.isEffectivelyHearingImpaired(): Boolean = isHearingImpaired || - label.indicatesHearingImpairedSubtitle() || - displayLabel.indicatesHearingImpairedSubtitle() + subtitleLabelIndicatesHearingImpaired(label) || + subtitleLabelIndicatesHearingImpaired(displayLabel) internal fun subtitleTracksWithSelection( tracks: List, @@ -174,71 +432,102 @@ internal sealed class SubtitleAutoSelection { data class Select(val index: Int) : SubtitleAutoSelection() } +/** + * Ranks MOUNTED Media3 text tracks through the shared resolver. + * + * The ranking itself lives in [resolveAutoSubtitle] — one cascade, one language + * table, one SDH predicate, one bitmap predicate, shared with the detail page's + * Auto preview. This only adapts [PlayerTrackEntry] into candidates. + */ internal fun resolveAutoSubtitleSelection( audioTracks: List, subtitleTracks: List, preferredLanguage: String?, subtitleMode: String?, showForced: Boolean, -): SubtitleAutoSelection { - if (subtitleTracks.isEmpty()) return SubtitleAutoSelection.NoChange - - val mode = subtitleMode?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: "auto" - if (mode == "off") return SubtitleAutoSelection.Disable - - if (preferredLanguage != null && preferredLanguage.isBlank()) { - return SubtitleAutoSelection.Disable - } - val targetLanguage = normalizedSubtitleLanguage(preferredLanguage) - if (targetLanguage == null) { - if (mode == "always") { - return bestAutoSubtitleTrack( - subtitleTracks = subtitleTracks, - targetLanguage = null, - preferForced = showForced, - )?.let { SubtitleAutoSelection.Select(it.index) } - ?: SubtitleAutoSelection.NoChange - } - return SubtitleAutoSelection.NoChange - } - - val selectedAudioLanguage = audioTracks - .firstOrNull { it.isSelected } - ?.language - ?.let(::normalizedSubtitleLanguage) - if (mode == "auto" && selectedAudioLanguage != null && selectedAudioLanguage == targetLanguage) { - if (showForced) { - val forcedTarget = bestForcedAutoSubtitleTrack( - subtitleTracks = subtitleTracks, - targetLanguage = targetLanguage, - ) - if (forcedTarget != null) { - // Idempotent re-select even when already selected: NoChange is - // reserved for "no track should be on", so the launch-time - // consumer can map it to an explicit disable (Apple parity) - // without turning off a forced track the defaults picked. - return SubtitleAutoSelection.Select(forcedTarget.index) - } - } - return SubtitleAutoSelection.Disable +): SubtitleAutoSelection = + when ( + val resolution = resolveAutoSubtitle( + candidates = playerTrackAutoSubtitleCandidates(subtitleTracks), + context = AutoSubtitleContext( + preferredLanguage = preferredLanguage, + mode = subtitleMode, + showForced = showForced, + audioLanguage = audioTracks.firstOrNull { it.isSelected }?.language, + ), + ) + ) { + AutoSubtitleResolution.NoChange -> SubtitleAutoSelection.NoChange + AutoSubtitleResolution.Disable -> SubtitleAutoSelection.Disable + is AutoSubtitleResolution.Select -> + SubtitleAutoSelection.Select(resolution.candidate.selectionIndex) } - val target = bestAutoSubtitleTrack( - subtitleTracks = subtitleTracks, - targetLanguage = targetLanguage, - preferForced = showForced, - ) ?: if (showForced) { - subtitleTracks.firstOrNull { it.isForced } - } else { - null - } +/** + * The identity Auto resolves to for a launch that carried NO decision (deep + * link, cast, remote/realtime start). + * + * Resolved over the SERVER inventory whenever there is one: `subtitle_urls` + * lists external sidecars the initial plan did not mount, and Media3's mounted + * text tracks do not. Ranking only what was mounted is what made an external + * SRT structurally invisible and started the embedded PGS track instead. If the + * winner is not mounted yet the adapter mounts it — a replan that is legitimate + * precisely because nobody decided this launch. + * + * A null resolution maps to an explicit Off: Auto picked nothing, but a + * selector or device caption setting may still have a track on, and Apple's + * engines start subs OFF ("Auto - None" in the detail preview). + */ +internal fun resolveTvAutoSubtitleIdentity( + audioTracks: List, + subtitleTracks: List, + subtitleRows: List, + preferredLanguage: String?, + subtitleMode: String?, + showForced: Boolean, +): SubtitleIdentity { + val context = AutoSubtitleContext( + preferredLanguage = preferredLanguage, + mode = subtitleMode, + showForced = showForced, + audioLanguage = audioTracks.firstOrNull { it.isSelected }?.language, + ) + if (subtitleRows.isNotEmpty()) { + val winner = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(subtitleRows), + context = context, + ).selectedCandidate() ?: return SubtitleIdentity.Off + return subtitleRows.firstOrNull { it.index == winner.selectionIndex } + ?.let(::tvSubtitleIdentity) + ?: SubtitleIdentity.Off + } + // No server inventory (a purely local mount): the mounted tracks are then + // the whole truth anyway. + val winner = resolveAutoSubtitle( + candidates = playerTrackAutoSubtitleCandidates(subtitleTracks), + context = context, + ).selectedCandidate() ?: return SubtitleIdentity.Off + return subtitleTracks.firstOrNull { it.index == winner.selectionIndex } + ?.let { track -> tvMountedSubtitleIdentity(track, subtitleTracks, subtitleRows) } + ?: SubtitleIdentity.Off +} - return when (target) { - // Idempotent re-select for an already-selected target (see the forced - // branch above): NoChange now strictly means "no track should be on". - null -> SubtitleAutoSelection.NoChange - else -> SubtitleAutoSelection.Select(target.index) - } +/** + * Mounted text tracks as resolver candidates, keyed by Media3 track index. + * + * Hearing-impaired travels as an explicit signal (role flags and both labels), + * which the catalog cannot supply and the shared predicate ORs with the title. + */ +internal fun playerTrackAutoSubtitleCandidates( + subtitleTracks: List, +): List = subtitleTracks.map { track -> + AutoSubtitleCandidate( + selectionIndex = track.index, + language = track.language, + codec = track.codecOrMime, + forced = track.isForced, + hearingImpaired = track.isEffectivelyHearingImpaired(), + ) } internal fun preferredAutoTextSubtitleIndex( @@ -264,49 +553,6 @@ internal fun preferredAutoTextSubtitleIndex( } } -private fun bestAutoSubtitleTrack( - subtitleTracks: List, - targetLanguage: String?, - preferForced: Boolean, -): PlayerTrackEntry? { - val pool = if (targetLanguage == null) { - subtitleTracks - } else { - subtitleTracks.filter { normalizedSubtitleLanguage(it.language) == targetLanguage } - } - if (pool.isEmpty()) return null - - if (preferForced) { - pool.firstOrNull { it.isForced && !it.isEffectivelyHearingImpaired() && !isBitmapSubtitleCodecOrMime(it.codecOrMime) } - ?.let { return it } - } - pool.firstOrNull { !it.isForced && !it.isEffectivelyHearingImpaired() && !isBitmapSubtitleCodecOrMime(it.codecOrMime) } - ?.let { return it } - pool.firstOrNull { !it.isForced && !isBitmapSubtitleCodecOrMime(it.codecOrMime) } - ?.let { return it } - pool.firstOrNull { !isBitmapSubtitleCodecOrMime(it.codecOrMime) } - ?.let { return it } - return pool.first() -} - -private fun bestForcedAutoSubtitleTrack( - subtitleTracks: List, - targetLanguage: String?, -): PlayerTrackEntry? { - val pool = if (targetLanguage == null) { - subtitleTracks - } else { - subtitleTracks.filter { normalizedSubtitleLanguage(it.language) == targetLanguage } - }.filter { it.isForced } - if (pool.isEmpty()) return null - - pool.firstOrNull { !it.isEffectivelyHearingImpaired() && !isBitmapSubtitleCodecOrMime(it.codecOrMime) } - ?.let { return it } - pool.firstOrNull { !it.isEffectivelyHearingImpaired() } - ?.let { return it } - return pool.first() -} - internal fun resolveInitialSubtitleTrackIndex( requestedOrdinal: Int, subtitleTracks: List, @@ -363,26 +609,6 @@ private fun PlayerTrackEntry.toMountedSubtitleTrack(): MountedSubtitleTrack = hearingImpaired = isHearingImpaired, ) -private fun normalizedSubtitleLanguage(language: String?): String? { - val primary = language - ?.trim() - ?.takeUnless { it.isBlank() || it.equals("und", ignoreCase = true) } - ?.lowercase() - ?.replace('_', '-') - ?.substringBefore('-') - ?: return null - return when (primary) { - "eng" -> "en" - "spa" -> "es" - "fre", "fra" -> "fr" - "ger", "deu" -> "de" - "dut", "nld" -> "nl" - "jpn" -> "ja" - "dan" -> "da" - else -> primary - } -} - /** * How the video surface scales to fill the player area. Session-scoped * (resets to [Fit] on each new playback) — matches tvOS behavior. @@ -417,8 +643,17 @@ data class TvPlayerLaunchArgs( val resumePositionOverride: Double? = null, /** Pre-selected audio track index from the detail screen (null = auto). */ val initialAudioTrackIndex: Int? = null, - /** Pre-selected subtitle track index (null = auto, -1 = Off). */ + /** True when the launch ordinal is a pick made this session, not a restore. */ + val initialAudioPickedThisSession: Boolean = false, + /** Pre-selected subtitle track index (null = no handoff, -1 = Off). */ val initialSubtitleTrackIndex: Int? = null, + /** + * True when [initialSubtitleTrackIndex] is the detail row's Auto preview + * rather than the viewer's own pick. The player still starts on it — that + * is the whole point of the handoff — but must not record it as a manual + * selection (no durable persistence, no explicit episode intent). + */ + val initialSubtitleAutoResolved: Boolean = false, /** * How many consecutive auto-advances led to this playback (0 = a manual * start). The player re-mounts per episode, so the pass-out streak rides @@ -427,10 +662,15 @@ data class TvPlayerLaunchArgs( * instead of auto-advancing. */ val autoAdvanceCount: Int = 0, + val episodeSelectionHandoff: EpisodeSelectionHandoff? = null, ) /** Emitted to ask the screen to navigate to the next episode (auto-advance / Continue). */ -data class PlayNextRequest(val contentId: String, val autoAdvanceCount: Int, val preferredQuality: String?) +data class PlayNextRequest( + val contentId: String, + val autoAdvanceCount: Int, + val episodeSelectionHandoff: EpisodeSelectionHandoff, +) /** * Subtitle provider search/download state backing the TV subtitle search @@ -516,6 +756,8 @@ class TvPlayerViewModel( // demoting to a server transcode (resets once playback progresses). private const val MAX_TRANSIENT_NETWORK_RETRIES = 1 private const val SEEK_SETTLE_DEADLINE_MS = 15_000L + /** How long a Dolby Vision toggle may claim "Applying…" before the cue gives up. */ + private const val OUTPUT_SWITCH_FEEDBACK_TIMEOUT_MS = 20_000L // Record a durable position roughly every 10s of content time. private const val POSITION_RECORD_INTERVAL_SEC = 10.0 // Non-empty onTracksChanged callbacks an unresolved explicit subtitle @@ -575,6 +817,9 @@ class TvPlayerViewModel( private var qualityOverride: String? = null private val roomId: String? = launchArgs.roomId private val resumePositionOverride: Double? = launchArgs.resumePositionOverride + // The handoff belongs to one cross-screen transition. A recoverable start + // leases it until Ready publication; replacement/exit invalidates it. + private val episodeSelectionHandoffSlot = TvEpisodeSelectionHandoffSlot(launchArgs.episodeSelectionHandoff) // Pre-playback track selections from the detail screen. Audio is sent to the // server session start; subtitle is applied once the player's tracks land @@ -583,6 +828,15 @@ class TvPlayerViewModel( private val initialAudioTrackIndex: Int? = launchArgs.initialAudioTrackIndex private var pendingInitialSubtitleIndex: Int? = launchArgs.initialSubtitleTrackIndex + /** + * Whether [pendingInitialSubtitleIndex] is the detail row's Auto preview + * rather than the viewer's own pick. Both are applied identically — the + * row's decision is what starts — but only an explicit pick may be recorded + * as a manual selection. + */ + private var pendingInitialSubtitleAutoResolved: Boolean = + launchArgs.initialSubtitleAutoResolved + /** * Non-empty track callbacks the explicit pick has failed to resolve * tracks land (Media3 reports everything at once), so an unresolved pick @@ -592,10 +846,31 @@ class TvPlayerViewModel( */ private var pendingInitialSubtitleAttempts = 0 private var pendingPersistedAudioFingerprint: String? = null - private var pendingPersistedSubtitleFingerprint: String? = null private var autoTextSubtitleSelectionAttempted = false private var manualSubtitleSelectionApplied = false + /** + * A launch handoff (explicit pick OR the detail row's Auto preview) has been + * applied, so the player must not re-decide. + * + * Separate from [manualSubtitleSelectionApplied], which answers a different + * question — "did the VIEWER choose this" — and drives persistence and the + * next-episode intent. + */ + private var launchSubtitleSelectionApplied = false + /** + * Whether the viewer picked the current audio track themselves. + * + * Only an explicit choice is carried into the next episode; a server + * default must not be pinned onto every later one. + */ + private var manualAudioSelectionApplied = false + + /** Monotonic across the screen's life, so no id is ever reused. */ + private var subtitleFailureIdSeed = 0L + + private fun nextSubtitleFailureId(): Long = ++subtitleFailureIdSeed + /** Guards [startServerRecoveryFallback] against concurrent fallbacks racing the same session. */ private var recoveryJob: Job? = null @@ -720,6 +995,16 @@ class TvPlayerViewModel( // Track selection — populated by the screen from ExoPlayer's // `currentTracks` once playback starts. val audioTracks: List = emptyList(), + /** + * Catalog ordinal of the audio the viewer wants — including a track the + * mounted stream already carried, switched without a server replan. + * Outranks the plan for display, for later replan requests and for the + * next episode's handoff: the plan names what the server last + * delivered, not what was chosen. + */ + val desiredAudioOrdinal: Int? = null, + /** False while the player has not yet been shown on that track. */ + val desiredAudioConfirmed: Boolean = false, val subtitleTracks: List = emptyList(), val videoTracks: List = emptyList(), // Real per-format video quality variants (resolution/bitrate) flattened @@ -752,6 +1037,8 @@ class TvPlayerViewModel( val pendingSubtitleIdentity: SubtitleIdentity? = null, val subtitleApplying: Boolean = false, val subtitleFailureMessage: String? = null, + /** Distinguishes two failures that happen to read the same. */ + val subtitleFailureId: Long = 0L, // Dialog visibility — owned here so HUD rows can request them and // the screen renders the Popups above the open HUD. val showSubtitleSearchDialog: Boolean = false, @@ -771,6 +1058,8 @@ class TvPlayerViewModel( // intro auto-skip observer and (eventually) the next-up promote. val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, // Chapters from the selected FileVersion (server-extracted via FFprobe // at ingest, mirrors Apple's `VersionChapter` consumption). Empty list // when the file has no embedded chapters. The HUD Chapters pane @@ -791,9 +1080,13 @@ class TvPlayerViewModel( // mini-player pane beside the next-episode panel — in place of the idle // controls. `nextUpVideoEnded` distinguishes "almost finished" (credits // reached, still playing) from "end of playback" (stream ended). - // `nextUpCountdownSeconds` drives the auto-play CountdownRing: non-null - // counts down to 0 and then plays the next episode; null means no - // countdown (auto-play off, pass-out gate hit, or no next episode). + // `nextUpCountdownSeconds` drives the auto-play CountdownRing; null + // means no countdown (auto-play off, pass-out gate hit, or no next + // episode). A card raised at end-of-playback counts a wall clock down + // to 0 and then plays the next episode. A card raised at the credits + // marker instead mirrors the remaining playback time, so reaching 0 + // means "the stream should be over" — it waits for the player to say + // so rather than cutting the tail off. val showNextUp: Boolean = false, val nextUpVideoEnded: Boolean = false, val nextUpCountdownSeconds: Int? = null, @@ -826,8 +1119,28 @@ class TvPlayerViewModel( initialValue = _uiState.value.toPlaybackClock(), ) private var subtitleMountGeneration = 0L - private var pendingSubtitleMountAcknowledgement: TvSubtitleRemountOwner? = null private var lastAdapterMountIdentity: SubtitleIdentity? = null + + /** + * Authority for the NEXT mount the adapter arms, consumed by the snapshot + * callback below. App-derived selections (launch auto-pick, detail-page + * restore) must not be able to evict a user pick that is still applying. + */ + private var nextSubtitleMountPriority = TvSubtitleMountPriority.UserTransaction + + /** + * Identity the app chose automatically, if it is still the committed one. + * + * The adapter cannot tell an automatic pick from a viewer's choice once it + * is committed, and both flow through the same persistence port — so the + * per-item subtitle preference is held back here. An automatic pick must + * never be written back as though the viewer had made it; the next explicit + * selection clears this and persists normally. + */ + private var autoSelectedSubtitleIdentity: SubtitleIdentity? = null + + /** True from issuing an automatic selection until the adapter commits it. */ + private var autoSubtitleSelectionInFlight = false private val unpublishedSubtitleUi = mutableMapOf() private val unpublishedTvLoadUi = TvUnpublishedLoadUiOwnership() @@ -844,6 +1157,11 @@ class TvPlayerViewModel( committed: org.prairieserver.prairie.model.playback.CommittedSubtitle, context: TvSubtitlePlaybackContext, ): Boolean { + // Only when AUDIO was what changed. Every commit carries the + // current audio index — a subtitle-only change included — so + // testing the index for non-null marked the server default as + // the viewer's choice after any successful subtitle change. + if (committed.audioPreferenceSpecified) onAudioSelectionCommitted() val writeScope = context.writeScope ?: return false return userItemStatePort.recordTrackSelection( scope = writeScope, @@ -853,8 +1171,9 @@ class TvPlayerViewModel( committedAudioTrackIndex = committed.audioTrackIndex, audioTracks = context.audioTracks, ), - subtitleUpdate = TrackSelectionFingerprintUpdate.Set( - encodeSubtitleIdentityPreference(committed.identity), + subtitleUpdate = tvSubtitlePersistenceUpdate( + committedIdentity = committed.identity, + automaticIdentity = autoSelectedSubtitleIdentity, ), ) } @@ -863,7 +1182,12 @@ class TvPlayerViewModel( val localMountIdentity = snapshot.localMountIdentity if (localMountIdentity != null && localMountIdentity != lastAdapterMountIdentity) { subtitleMountGeneration += 1 - subtitleRemountReselection.arm(localMountIdentity, subtitleMountGeneration) + subtitleRemountReselection.arm( + identity = localMountIdentity, + generation = subtitleMountGeneration, + priority = nextSubtitleMountPriority, + ) + nextSubtitleMountPriority = TvSubtitleMountPriority.UserTransaction subtitleSnapshotSettlement.reset() lastAdapterMountIdentity = localMountIdentity // In-stream captions can already be present and need no media @@ -874,6 +1198,12 @@ class TvPlayerViewModel( } else if (localMountIdentity == null) { lastAdapterMountIdentity = null } + if (autoSubtitleSelectionInFlight && + !snapshot.subtitleApplying && + snapshot.localMountIdentity == null + ) { + autoSubtitleSelectionInFlight = false + } val committedQuality = snapshot.transition.committed.qualityPreference if (!snapshot.subtitleApplying && committedQuality != null) { qualityOverride = committedQuality @@ -884,6 +1214,17 @@ class TvPlayerViewModel( pendingSubtitleIdentity = snapshot.pendingIdentity, subtitleApplying = snapshot.subtitleApplying, subtitleFailureMessage = snapshot.failureMessage, + subtitleFailureId = when { + snapshot.failureMessage == null -> state.subtitleFailureId + // Any failure that is not the one already on screen is a + // new event. Requiring the previous slot to be empty + // meant a second, different failure inherited the first + // one's id and was therefore never shown — the effect + // keys on the id alone. + snapshot.failureMessage != state.subtitleFailureMessage -> + nextSubtitleFailureId() + else -> state.subtitleFailureId + }, subtitleUrls = authoritativeTvSubtitleRows( snapshotRows = snapshot.subtitleTracks, previousRows = state.subtitleUrls, @@ -891,11 +1232,7 @@ class TvPlayerViewModel( subtitleRefreshNonce = snapshot.subtitleRefreshNonce .coerceAtMost(Int.MAX_VALUE.toLong()) .toInt(), - videoQualities = if (!snapshot.subtitleApplying && committedQuality != null) { - transcodeQualityLadder(state.selectedFileResolution, committedQuality) - } else { - state.videoQualities - }, + videoQualities = state.videoQualities, ) } }, @@ -907,19 +1244,36 @@ class TvPlayerViewModel( }, hasMountableTracks = { _uiState.value.subtitleTracks.isNotEmpty() }, isLocallyMountable = { identity -> - resolveMountedSubtitle( + // Row-aware on purpose: a v3 inventory row describing a track muxed + // into a direct-play stream is still typed `delivery = sidecar`, so + // asking the identity resolver alone answered "not mounted" for the + // track Media3 already had, and every app-derived pick of it took + // the staged-replan path (see tvResolveMountedSubtitleTrack). + val state = _uiState.value + tvResolveMountedSubtitleTrack( identity = identity, - tracks = _uiState.value.subtitleTracks.map { it.toMountedTvSubtitleTrack() }, + subtitleRows = state.subtitleUrls, + mounted = state.subtitleTracks.map { it.toMountedTvSubtitleTrack() }, ) != null }, ) + private val subtitleTransactionLaunchMutex = Mutex() private val playbackMutationFence by lazy { TvPlayerMutationFence(loadOwners, subtitleTransactions::invalidate) } - /** Intro auto-skip banner state. The screen consumes this directly. */ + /** Intro skip pill state. The screen consumes this directly. */ val introSkipState: StateFlow = introAutoSkipController.state + /** Bumps whenever the pill's timer (re)starts, so the fill can re-anchor. */ + val introSkipCountdownRun: StateFlow = introAutoSkipController.countdownRun + + /** False while the pill is up but its timer is frozen by a pause. */ + val introSkipTimerRunning: StateFlow = introAutoSkipController.timerRunning + + /** Total seconds a fresh intro prompt runs for, for the fill's arithmetic. */ + val introSkipTotalSeconds: Int = introAutoSkipController.totalCountdownSeconds + private val seekRequestChannel = Channel(capacity = Channel.BUFFERED) val seekRequests: Flow = seekRequestChannel.receiveAsFlow() @@ -967,12 +1321,19 @@ class TvPlayerViewModel( val aiTranslate: StateFlow = _aiTranslate.asStateFlow() /** - * Ordinal text-group index to select after a subtitle refresh lands. - * Mirrors the seekRequests idiom: the screen collects and calls - * SubtitleManager.selectSubtitle — the VM never touches the controller. + * Mounts the subtitle transaction adapter has asked for, each carrying the + * owner that must be told how it went. Mirrors the seekRequests idiom: the + * screen collects and calls SubtitleManager.selectSubtitle — the VM never + * touches the controller. + * + * This is the ONLY channel that may enable or disable a text track on TV. + * It used to be a bare `SharedFlow` that the legacy auto/persisted/ + * detail-pick paths also emitted into without arming an owner, which is how + * playback and the HUD ended up disagreeing. */ - private val _subtitleSelectRequests = MutableSharedFlow(extraBufferCapacity = 1) - val subtitleSelectRequests: SharedFlow = _subtitleSelectRequests + private val _subtitleMountRequests = + MutableSharedFlow(extraBufferCapacity = 1) + internal val subtitleMountRequests: SharedFlow = _subtitleMountRequests // Remote track-selection latches. A remote command can land before the // screen's video backend attaches OR before Media3 reports its tracks @@ -995,8 +1356,8 @@ class TvPlayerViewModel( // ---- Player settings flows (per-profile, DataStore-backed) ----------------- val playbackSpeed: StateFlow = playerSettingsStore.playbackSpeedFlow .stateIn(viewModelScope, SharingStarted.Eagerly, 1.0) - val autoSkipIntroEnabled: StateFlow = playerSettingsStore.autoSkipIntroFlow - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val introSkipMode: StateFlow = playerSettingsStore.introSkipModeFlow + .stateIn(viewModelScope, SharingStarted.Eagerly, IntroSkipMode.Default) val autoPlayNextEnabled: StateFlow = playerSettingsStore.autoPlayNextFlow .stateIn(viewModelScope, SharingStarted.Eagerly, true) // Per-profile "Still watching?" threshold (default 3; 0 = off). @@ -1025,7 +1386,7 @@ class TvPlayerViewModel( val audioDelayMs: StateFlow = playerSettingsStore.audioSyncMsFlow .stateIn(viewModelScope, SharingStarted.Eagerly, 0) /** - * Per-profile subtitle delay in ms, ±500 clamp. Sourced from + * Per-device subtitle delay in ms, ±10000 clamp. Sourced from * [PlayerSettingsStore.subtitleSyncMsFlow]; mirrored into the active * [org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder] by * [org.prairieserver.prairie.common.player.PrairiePlaybackService] (A.3f T2). @@ -1046,6 +1407,8 @@ class TvPlayerViewModel( private var aiStatusRequested = false private var aiJobPollJob: Job? = null private var activeAiJobId: Long? = null + private var pendingAuthoritativeSubtitleDownloadId: Int? = null + private val authoritativeSubtitleReadyRows = mutableMapOf, PlayerSubtitleInfo>() private val subtitleRemountReselection = SubtitleRemountReselection() private val subtitleSnapshotSettlement = TvSubtitleSnapshotSettlementTracker() @@ -1092,12 +1455,16 @@ class TvPlayerViewModel( } } viewModelScope.launch { - sessionLifecycle.missingSessionEvents.collect { position -> + sessionLifecycle.missingSessionEvents.collect { renewal -> val state = _uiState.value - if (state.sessionId != null) { + if ( + state.sessionId == renewal.staleSessionId && + renewal.startParams.contentId == contentId + ) { loadContent( - startPositionOverride = position, - preferredFileIdOverride = state.selectedFileId ?: state.mediaFileId, + startPositionOverride = renewal.positionSeconds, + preferredFileIdOverride = renewal.startParams.fileId, + recoveryStartParams = renewal.startParams, suppressResumeRewind = true, ) } @@ -1187,18 +1554,40 @@ class TvPlayerViewModel( return transportMountSequence } - private fun subtitlePlaybackContext(state: UiState): TvSubtitlePlaybackContext { + private suspend fun subtitlePlaybackContext(state: UiState): TvSubtitlePlaybackContext { + val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() + val capabilities = capabilityDetector.detect(dolbyVision = dolbyVision) + return subtitlePlaybackContext( + state = state, + capabilities = capabilities, + clientPlaybackContext = capabilityDetector.detectPlaybackContext( + formFactor = "tv", + appVersion = BuildConfig.VERSION_NAME, + dolbyVision = dolbyVision, + capabilities = capabilities, + ), + ) + } + + private fun subtitlePlaybackContext( + state: UiState, + capabilities: ClientCodecCapabilities, + clientPlaybackContext: ClientPlaybackContext, + ): TvSubtitlePlaybackContext { val fileId = state.selectedFileId ?: state.mediaFileId ?: 0 val version = state.fileVersions.firstOrNull { it.fileId == fileId } - val selectedAudio = selectedServerAudioTrackIndex( - selectedPlayerOrdinal = state.audioTracks.firstOrNull { it.isSelected }?.index, - catalogAudioTracks = version?.audioTracks, - currentPlanTrackIndex = state.playbackPlan?.selectedTracks?.audioIndex, - ) - val dolbyVision = DolbyVisionPolicy.Snapshot( - dolbyVisionEnabled = dolbyVisionEnabled.value, - preferProfile7HDR10Fallback = dvProfile7Hdr10Fallback.value, - ) + // The viewer's confirmed choice outranks the plan, exactly as the + // recovery replan already does. A direct-play local switch changes the + // mounted track without replanning, so the plan can still name the + // previous audio — and a subtitle, quality or output-route transaction + // built from it would replan the viewer straight back onto the track + // they had just switched away from. + val selectedAudio = state.desiredAudioOrdinal?.takeIf { state.desiredAudioConfirmed } + ?: selectedServerAudioTrackIndex( + selectedPlayerOrdinal = state.audioTracks.firstOrNull { it.isSelected }?.index, + catalogAudioTracks = version?.audioTracks, + currentPlanTrackIndex = state.playbackPlan?.selectedTracks?.audioIndex, + ) return TvSubtitlePlaybackContext( contentId = contentId, mediaFileId = fileId, @@ -1212,18 +1601,24 @@ class TvPlayerViewModel( subtitleTracks = state.subtitleUrls, audioTracks = version?.audioTracks.orEmpty(), outputRouteGeneration = capabilityDetector.outputRouteGeneration.value, - capabilities = capabilityDetector.detect( - dolbyVision = dolbyVision, - ), - clientPlaybackContext = capabilityDetector.detectPlaybackContext( - formFactor = "tv", - appVersion = BuildConfig.VERSION_NAME, - dolbyVision = dolbyVision, - ), + capabilities = capabilities, + clientPlaybackContext = clientPlaybackContext, writeScope = finalPositionScope, ) } + private fun launchSubtitleTransaction( + state: UiState, + transaction: () -> Unit, + ) { + viewModelScope.launch { + subtitleTransactionLaunchMutex.withLock { + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + transaction() + } + } + } + private suspend fun adoptSubtitlePlayback( adoption: TvSubtitlePlaybackAdoption, ): TvSubtitleAdoptionResult { @@ -1236,41 +1631,38 @@ class TvPlayerViewModel( ?: before.mediaFileId ?: return TvSubtitleAdoptionResult.Superseded val version = before.fileVersions.firstOrNull { it.fileId == fileId } - val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() - val capabilities = capabilityDetector.detect(dolbyVision = dolbyVision) val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = contentId, fileId = fileId, - capabilities = capabilities, + capabilities = ready.capabilities, audioTrackIndex = adoption.committed.audioTrackIndex, subtitleTrackIndex = adoption.committed.identity.serverTrackIndexForTv(), qualityPreference = adoption.committed.qualityPreference, startPosition = ready.session.position, + clientPlaybackContext = ready.clientPlaybackContext, ), session = ready.session, - renewMissingSessionWithLegacyStart = false, deferPublication = true, isCurrent = adoption::isCurrent, ) if (!adopted) return TvSubtitleAdoptionResult.Superseded + // The exit token names what the lifecycle owns, from the moment it owns + // it — not from the UI publication further down. Supersession in the gap + // otherwise leaves teardown naming the predecessor, the ownership guard + // rightly refusing it, and the one-shot gate blocking any retry. + lastAdoptedSessionId = ready.session.sessionId if (!adoption.isCurrent()) return TvSubtitleAdoptionResult.Superseded unpublishedSubtitleUi[ready.session.sessionId] = before - val planned = ready.session.subtitleUrls.orEmpty() - val plannedIndexes = planned.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) - val retained = if (fileId == (before.selectedFileId ?: before.mediaFileId)) { - before.subtitleUrls.filterNot { it.index in plannedIndexes } - } else { - emptyList() - } - val subtitleUrls = buildPlaybackSubtitleChoices( + val subtitleUrls = enrichAuthoritativePlaybackSubtitleChoices( catalogTracks = version?.subtitleTracks.orEmpty(), - plannedTracks = planned + retained, + plannedTracks = ready.session.subtitleUrls.orEmpty(), + ) + val duration = ready.session.durationSeconds ?: 0.0 + val remountPosition = ready.plan.timeline.replanMountPositionForSource( + adoption.requestedSourcePositionSeconds, ) - val duration = ready.session.durationSeconds - ?: version?.duration?.takeIf { it > 0.0 } - ?: before.duration val mountNonce = nextTypedSubtitleMountNonce(adoption.committed.identity) _uiState.update { state -> state.copy( @@ -1286,17 +1678,24 @@ class TvPlayerViewModel( mediaFileId = fileId, selectedFileResolution = version?.resolution ?: ready.plan.effectiveRecipe.height?.let { "${it}p" }, + videoQualities = authoritativePlaybackQualityOptions( + available = ready.plan.availableQualities, + selectedLabel = adoption.committed.qualityPreference, + ), container = ready.plan.stream.container ?: version?.container ?: state.container, duration = duration, serverDuration = duration, subtitleUrls = subtitleUrls, chapters = version?.chapters.orEmpty(), - startPosition = ready.plan.timeline.playerStartSeconds, - position = ready.plan.timeline.sourceStartSeconds - .takeIf { it.isFinite() && it >= 0.0 } - ?: state.position, + startPosition = remountPosition.playerPositionSeconds, + position = remountPosition.sourcePositionSeconds, ) } + Log.i( + TAG, + "subtitle_replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + + "player_seconds=${remountPosition.playerPositionSeconds}", + ) return TvSubtitleAdoptionResult.Adopted } @@ -1312,6 +1711,14 @@ class TvPlayerViewModel( restoreUi: Boolean, ): Boolean { val predecessor = unpublishedSubtitleUi.remove(playback.sessionId) + // The token follows lifecycle ownership in BOTH directions. Rollback + // hands ownership back to the predecessor, so leaving the token on the + // discarded replacement would make teardown name a session the + // lifecycle no longer holds — and be refused. This runs regardless of + // restoreUi: ownership reverts either way. + if (lastAdoptedSessionId == playback.sessionId) { + lastAdoptedSessionId = predecessor?.sessionId + } if (restoreUi && predecessor != null) { val identity = predecessor.committedSubtitleIdentity _uiState.value = predecessor.copy( @@ -1333,6 +1740,11 @@ class TvPlayerViewModel( if (!jointlyRolledBack) { playbackSessionManager.rollbackUnpublishedVideoSession(sessionId) } + // Same rule as the subtitle rollback: ownership reverted to the + // predecessor, so the exit token has to revert with it. + if (lastAdoptedSessionId == sessionId) { + lastAdoptedSessionId = predecessor?.state?.sessionId + } try { if (predecessor != null && _uiState.value.sessionId == sessionId) { val identity = predecessor.state.committedSubtitleIdentity @@ -1390,6 +1802,10 @@ class TvPlayerViewModel( private fun loadContent( startPositionOverride: Double? = null, preferredFileIdOverride: Int? = null, + // A missing server session is a renewal, not a new route. Use the + // lifecycle's adoption-time selection snapshot because Media3 may have + // already cleared its live tracks by the time the 404 is observed. + recoveryStartParams: StartParams? = null, // True for retry: re-load at the current position without nudging back // (a normal first resume keeps the default false so it gets the rewind). suppressResumeRewind: Boolean = false, @@ -1403,16 +1819,45 @@ class TvPlayerViewModel( // Capture this pipeline's generation; a later loadContent bump makes // this one inert before it can touch _uiState. val generation = ++contentLoadGeneration + if (recoveryStartParams != null) { + pendingInitialSubtitleIndex = recoveryStartParams.subtitleTrackIndex + // A recovery restores the selection the session was already + // playing, so it restores that selection's STANDING too. The + // snapshot carries an index and no provenance, so read it off the + // session being replaced — this runs before the flags are cleared + // below. Calling it manual unconditionally promoted a pick the app + // had made into the viewer's: once resolved it set + // manualSubtitleSelectionApplied, and the automatic choice rode the + // next episode's handoff and the durable preference. + pendingInitialSubtitleAutoResolved = !manualSubtitleSelectionApplied + pendingInitialSubtitleAttempts = 0 + } val loadOwner = playbackMutationFence.beginLoad( contentId = contentId, preferredFileId = preferredFileIdOverride ?: preferredFileId, - preferredQuality = qualityOverride ?: preferredQuality, + preferredQuality = recoveryStartParams?.qualityPreference + ?: qualityOverride + ?: preferredQuality, ) hasRenderedFirstFrame = false resetSeekRecoveryForContentChange() transportMountGate.beginLoad() introAutoSkipController.reset() manualSubtitleSelectionApplied = false + launchSubtitleSelectionApplied = false + autoSelectedSubtitleIdentity = null + autoSubtitleSelectionInFlight = false + // Cleared here and raised only if the carried choice actually RESOLVES + // against this episode's tracks. + // + // Seeding it from the intent alone was wrong: resolveEpisodeAudioIntent + // deliberately returns null when nothing matches or the match is + // ambiguous, and that null means the server default plays. Marking it + // manual anyway made the next auto-advance capture that default as a + // deliberate choice — so one unresolvable episode turned a server + // default into a preference that then propagated for the rest of the + // series. + manualAudioSelectionApplied = false _uiState.update { it.copy(isBuffering = false) } _uiState.update { @@ -1424,21 +1869,37 @@ class TvPlayerViewModel( finalPositionScope = finalPlaybackPositionWriter.captureScope() val unpublishedReadySession = TvUnpublishedLoadSessionOwnership(::rollbackUnpublishedTvLoadSession) + var episodeSelectionHandoffLease: TvEpisodeSelectionHandoffLease? = null try { if (!subtitleTransactions.invalidateAndAwaitSettlement()) return@launch runCatching { playerSettingsStore.refreshFromServer() } if (!loadOwners.owns(loadOwner)) return@launch + episodeSelectionHandoffLease = episodeSelectionHandoffSlot.leaseForStart( + ownerGeneration = loadOwner.generation, + ) + val episodeSelectionHandoff = episodeSelectionHandoffLease?.handoff val request = VideoPlaybackStartRequest( contentId = contentId, preferredFileId = preferredFileIdOverride ?: preferredFileId, roomId = roomId, resumePositionOverride = startPositionOverride, - audioTrackIndex = initialAudioTrackIndex, - subtitleTrackIndex = pendingInitialSubtitleIndex, - preferredQualityOverride = preferredQuality, + audioTrackIndex = if (recoveryStartParams != null) { + recoveryStartParams.audioTrackIndex + } else { + initialAudioTrackIndex + }, + subtitleTrackIndex = if (recoveryStartParams != null) { + recoveryStartParams.subtitleTrackIndex + } else { + pendingInitialSubtitleIndex + }, + preferredQualityOverride = recoveryStartParams?.qualityPreference + ?: preferredQuality, playbackQualityIntent = qualityOverride, suppressResumeRewind = suppressResumeRewind, force = force, + episodeSelectionHandoff = episodeSelectionHandoff, + recoveryStartParams = recoveryStartParams, ) val result = loadOwners.withOwner(loadOwner) { videoPlaybackCoordinator.start(request) @@ -1454,10 +1915,22 @@ class TvPlayerViewModel( val allocatedSessionId = result.sessionId ?.takeIf(String::isNotBlank) ?: run { + episodeSelectionHandoffSlot.retainForRetry( + episodeSelectionHandoffLease, + ) fail("Playback start returned no session.") return@launch } unpublishedReadySession.acquire(allocatedSessionId) + // Fresh load is a fourth lifecycle-first path: the + // starter already adopted this session before returning, + // and several suspending hydration steps stand between + // here and the UI publication below. Advance the exit + // token now, or an exit landing in that gap names the + // predecessor, is refused, and permanently claims the + // one-shot gate while this load goes on to publish. + // The rollback paths revert it if this never publishes. + lastAdoptedSessionId = allocatedSessionId if (!loadOwners.owns(loadOwner)) { loadOwners.publishReadyIfOwned( owner = loadOwner, @@ -1467,6 +1940,29 @@ class TvPlayerViewModel( ) return@launch } + val subtitleSelection = resolveTvEpisodeInitialSubtitleSelection( + episodeSelectionHandoff = episodeSelectionHandoff, + resolvedEpisodeSelection = result.resolvedEpisodeSelection, + existingPendingInitialSubtitleIndex = pendingInitialSubtitleIndex, + ) + pendingInitialSubtitleIndex = subtitleSelection.pendingInitialSubtitleIndex + if (episodeSelectionHandoff != null) { + // A carried episode intent is the viewer's, not a preview. + pendingInitialSubtitleAutoResolved = false + } + val resolvedSelection = result.resolvedEpisodeSelection + if (episodeSelectionHandoff != null && resolvedSelection != null) { + pendingInitialSubtitleAttempts = 0 + // The carried audio choice counts as the viewer's + // only once it RESOLVED to a real track here. A + // TRACK intent that matched nothing leaves the + // server default playing, and calling that manual + // would hand it on to the next episode as though it + // had been chosen. + if (resolvedSelection.audioTrackIndex != null) { + manualAudioSelectionApplied = true + } + } val localTrackSelection = result.fileId ?.let { fileId -> userItemStatePort.localTrackSelection(contentId, fileId) } if (!loadOwners.owns(loadOwner)) { @@ -1484,7 +1980,10 @@ class TvPlayerViewModel( .firstOrNull { it.fileId == (result.fileId ?: readyMediaFileId) } ?.subtitleTracks .orEmpty() - val restorePreference = if (pendingInitialSubtitleIndex == null) { + val restorePreference = if ( + pendingInitialSubtitleIndex == null && + !subtitleSelection.suppressDurableSubtitleRestore + ) { localTrackSelection?.subtitleFingerprint } else { null @@ -1502,18 +2001,12 @@ class TvPlayerViewModel( sessionId = readySessionId, serverUrl = result.serverUrl, hydrateDownloadedRows = { - when (val listing = subtitlesRepository.list(readyMediaFileId)) { - is ApiResult.Success -> ApiResult.Success( - mergeDownloadedSubtitles( - existing = emptyList(), - downloaded = listing.data.subtitles, - sessionId = readySessionId, - serverUrl = result.serverUrl, - ), - ) - is ApiResult.Error -> listing - is ApiResult.NetworkError -> listing - } + // V3 subtitle inventory is complete. A + // catalog listing may enrich a row only by + // stable identity; it may never add or + // renumber rows, so publish the plan rows + // unchanged on initial playback. + ApiResult.Success(result.subtitleUrls) }, ) } else { @@ -1541,14 +2034,6 @@ class TvPlayerViewModel( } else { null } - // Keep the persisted subtitle fingerprint even when the - // detail page sent an explicit pick: on TV a pick only - // resolves once Media3 reports its tracks, so an - // unresolvable pick must fall through to persisted (then - // auto) instead of stranding subtitles Off all session. - // The suppression now gates on the pick actually resolving - // (see resolvePendingInitialSubtitle), not the bare intent. - pendingPersistedSubtitleFingerprint = null val committedIdentity = result.playbackPlan ?.selectedTracks ?.subtitleIndex @@ -1559,6 +2044,17 @@ class TvPlayerViewModel( ?: SubtitleIdentity.Off val predecessorUi = _uiState.value val predecessorSubtitleContext = subtitlePlaybackContext(predecessorUi) + val publishedSubtitleContext = subtitlePlaybackContext( + predecessorUi.copy( + sessionId = result.sessionId, + playbackPlan = result.playbackPlan, + selectedFileId = result.fileId, + fileVersions = result.versions, + mediaFileId = result.mediaFileId, + position = result.sourceStartPositionSeconds, + subtitleUrls = hydratedSubtitleUrls, + ), + ) val published = loadOwners.publishReadyIfOwned( owner = loadOwner, sessionId = allocatedSessionId, @@ -1570,6 +2066,11 @@ class TvPlayerViewModel( predecessorSessionId = predecessorUi.sessionId, ) val transportMountNonce = nextTransportMountNonce(null) + // Paired with the UI publication so the exit + // token is never staler than UI state — the + // invariant that lets exitSessionId read it + // first. + result.sessionId?.let { lastAdoptedSessionId = it } _uiState.update { it.copy( isLoading = false, @@ -1594,17 +2095,17 @@ class TvPlayerViewModel( selectedFileId = result.fileId, fileVersions = result.versions, selectedFileResolution = result.fileResolution, - // Server-transcode quality ladder for this source - // (tvOS parity) — replaces adaptive-variant options. - videoQualities = transcodeQualityLadder( - result.fileResolution, - qualityOverride ?: preferredQuality ?: PlaybackQuality.Auto.wireValue, + videoQualities = authoritativePlaybackQualityOptions( + available = result.playbackPlanV3?.availableQualities.orEmpty(), + selectedLabel = qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue, ), mediaFileId = result.mediaFileId, startPosition = result.startPositionSeconds, position = result.sourceStartPositionSeconds, - duration = result.durationSeconds, - serverDuration = result.durationSeconds, + duration = result.durationSeconds ?: 0.0, + serverDuration = result.durationSeconds ?: 0.0, isPaused = false, subtitleUrls = hydratedSubtitleUrls, preferredAudioLanguage = result.preferredAudioLanguage, @@ -1613,6 +2114,8 @@ class TvPlayerViewModel( showForcedSubtitles = result.showForcedSubtitles, intro = result.intro, credits = result.credits, + recap = result.recap, + preview = result.preview, chapters = result.chapters, seriesId = result.seriesId, seasonNumber = result.seasonNumber, @@ -1623,6 +2126,7 @@ class TvPlayerViewModel( showNextUp = false, nextUpVideoEnded = false, nextUpCountdownSeconds = null, + nextUpCountdownTotalSeconds = NEXT_UP_COUNTDOWN_SECONDS, // T11: clear the subtitle-refresh nonce on every // fresh mount. It is bumped once per post-download // refresh; without this reset a later backend @@ -1633,7 +2137,7 @@ class TvPlayerViewModel( ) } subtitleTransactions.resetContent( - context = subtitlePlaybackContext(_uiState.value), + context = publishedSubtitleContext, committedIdentity = committedIdentity, ) freshRestore.resolution?.let { resolution -> @@ -1667,28 +2171,44 @@ class TvPlayerViewModel( } if (!jointlyConfirmed) { unpublishedReadySession.rollbackIfOwned(publishedSessionId) + episodeSelectionHandoffSlot.retainForRetry( + episodeSelectionHandoffLease, + ) fail("Playback publication could not be confirmed.") return@launch } + if (result.resolvedEpisodeSelection != null) { + episodeSelectionHandoffSlot.acknowledgeReady( + episodeSelectionHandoffLease, + ) + } startIntroAutoSkipObserver() resolveNextEpisode() } is VideoPlayerUiState.Error -> { + episodeSelectionHandoffSlot.retainForRetry( + episodeSelectionHandoffLease, + ) if (preserveCurrentPlaybackOnFailure) { _uiState.update { failTvReplacementLoad(it, result.message) } } else { fail(result.message) } } - is VideoPlayerUiState.ServerUnreachable -> _uiState.update { - if (preserveCurrentPlaybackOnFailure) { - failTvReplacementLoad(it, SERVER_UNREACHABLE_MESSAGE) - } else { - it.copy( - isLoading = false, - error = SERVER_UNREACHABLE_MESSAGE, - serverUnreachable = true, - ) + is VideoPlayerUiState.ServerUnreachable -> { + episodeSelectionHandoffSlot.retainForRetry( + episodeSelectionHandoffLease, + ) + _uiState.update { + if (preserveCurrentPlaybackOnFailure) { + failTvReplacementLoad(it, SERVER_UNREACHABLE_MESSAGE) + } else { + it.copy( + isLoading = false, + error = SERVER_UNREACHABLE_MESSAGE, + serverUnreachable = true, + ) + } } } is VideoPlayerUiState.Loading -> Unit @@ -1700,6 +2220,7 @@ class TvPlayerViewModel( unpublishedReadySession.rollbackIfOwned() Log.e(TAG, "Error loading content", e) if (generation != contentLoadGeneration || !loadOwners.owns(loadOwner)) return@launch + episodeSelectionHandoffSlot.retainForRetry(episodeSelectionHandoffLease) val message = "Unexpected error: ${e.message}" if (preserveCurrentPlaybackOnFailure) { _uiState.update { failTvReplacementLoad(it, message) } @@ -1714,14 +2235,16 @@ class TvPlayerViewModel( // Auto-skip is a local transport action: in a Watch Together room only // the host's transport may move position, so never auto-skip in a room // (a guest jump would fight the host's broadcast in a yank-back loop). - // The observer still runs in a room — with enabled pinned false the - // controller only ever surfaces ShowingButton (prompt visible, never - // counts down or auto-fires), keeping the manual Skip Intro button - // alive; its press routes through the screen's gate-checked seek. - val autoSkipEnabled = if (roomId != null) { - flowOf(false) + // The observer still runs in a room — with the mode pinned to `ask` the + // controller never seeks on its own, and the Skip Intro pill it offers + // stays live; its Select routes through the screen's gate-checked seek. + // A room member who chose `never` still gets the pill, which is the + // lesser wrong: the alternative is a mode whose only implementation is + // a seek nobody in the room is allowed to make. + val effectiveMode = if (roomId != null) { + flowOf(IntroSkipMode.ASK) } else { - playerSettingsStore.autoSkipIntroFlow + playerSettingsStore.introSkipModeFlow } introObserveJob?.cancel() introObserveJob = introAutoSkipController.observe( @@ -1731,7 +2254,7 @@ class TvPlayerViewModel( introRange = _uiState .map { it.intro } .distinctUntilChanged(), - autoSkipEnabled = autoSkipEnabled, + mode = effectiveMode, introKey = _uiState .map { state -> state.intro?.let { intro -> @@ -1739,7 +2262,22 @@ class TvPlayerViewModel( } } .distinctUntilChanged(), - onAutoSkipFire = { seekToSec -> seekImmediate(seekToSec) }, + // Only reachable outside a room, where the mode is pinned to `ask`. + onSeek = { seekToSec -> seekImmediate(seekToSec) }, + // Filtered, not raw: isPlaying dips for a rebuffer exactly as it + // does for a deliberate pause, and a pause that reaches the + // controller freezes the timer. Unfiltered, a stuttering stream + // would stall the prompt on every hiccup. + // + // isPaused is the viewer's own press and needs no filtering, so it + // freezes the timer on the frame of the press rather than after + // the grace window. + playbackActive = _uiState + .map { it.isPlaying && !it.isLoading } + .settlingFalseEdges( + graceMillis = PLAYBACK_PAUSE_GRACE_MS, + deliberatelyInactive = _uiState.map { it.isPaused }, + ), ) } @@ -1796,7 +2334,12 @@ class TvPlayerViewModel( return } - startProtocolV3Replan(reason.failureClassification(), notice, state) + startProtocolV3Replan( + classification = reason.failureClassification(), + notice = notice, + state = state, + diagnostics = reason.failureDiagnostics(), + ) } private fun startProtocolV3Replan( @@ -1824,7 +2367,10 @@ class TvPlayerViewModel( val fileId = state.selectedFileId ?: state.mediaFileId ?: return val recoveryContentGeneration = contentLoadGeneration recoveryJob = viewModelScope.launch { - val selectedAudio = selectedServerAudioTrackIndex( + // Locally-confirmed choice first, same reason as the transaction + // context: the plan names the last track the server delivered, so + // a recovery replan would otherwise undo the viewer's pick. + val selectedAudio = state.desiredAudioOrdinal ?: selectedServerAudioTrackIndex( selectedPlayerOrdinal = state.audioTracks.firstOrNull { it.isSelected }?.index, catalogAudioTracks = state.fileVersions.firstOrNull { it.fileId == fileId }?.audioTracks, currentPlanTrackIndex = state.playbackPlan?.selectedTracks?.audioIndex, @@ -1838,6 +2384,7 @@ class TvPlayerViewModel( formFactor = "tv", appVersion = BuildConfig.VERSION_NAME, dolbyVision = dolbyVision, + capabilities = capabilities, ) val result = playbackSessionManager.replanActiveVideoSession( classification = classification, @@ -1853,11 +2400,31 @@ class TvPlayerViewModel( ) // PlaybackRepository's safe-call layer may translate cancellation to an ApiResult. // Re-check both coroutine and content generations before any response can adopt. + // + // Bailing out here is not enough on its own. By the time this + // returns, the manager has already committed and taken ownership of + // the replacement session — so abandoning the result quietly leaves + // a transcode running on the server that nothing will ever stop. + // The viewer sees playback exit; the server keeps the stream slot + // until it times out. Release it explicitly on every abandon path. + val abandonedSessionId = (result as? ApiResult.Success) + ?.data + ?.let { it as? VideoSessionStartV3.Ready } + ?.session + ?.sessionId + if (!isActive || recoveryContentGeneration != contentLoadGeneration) { + // Released on the manager's own scope, which outlives this + // screen: the whole point is to run after the reason for + // abandoning, and this ViewModel's scope may already be gone. + abandonedSessionId?.let(playbackSessionManager::abandonActiveVideoSessionAsync) + } coroutineContext.ensureActive() if (recoveryContentGeneration != contentLoadGeneration) return@launch when (result) { is ApiResult.Success -> when (val decision = result.data) { is VideoSessionStartV3.Ready -> { + val remountPosition = decision.plan.timeline + .replanMountPositionForSource(state.position) val effectiveFileId = decision.session.mediaFileId.takeIf { it > 0 } ?: decision.plan.effectiveMediaFileId ?: fileId @@ -1866,50 +2433,64 @@ class TvPlayerViewModel( } val effectiveResolution = effectiveVersion?.resolution ?: decision.plan.effectiveRecipe.height?.let { "${it}p" } - val plannedSubtitles = decision.session.subtitleUrls.orEmpty() - val plannedSubtitleIndexes = plannedSubtitles - .mapTo(mutableSetOf(), PlayerSubtitleInfo::index) - val preservedSubtitles = if (effectiveFileId == fileId) { - state.subtitleUrls.filterNot { it.index in plannedSubtitleIndexes } - } else { - emptyList() - } - val effectiveSubtitleUrls = buildPlaybackSubtitleChoices( + val effectiveSubtitleUrls = enrichAuthoritativePlaybackSubtitleChoices( catalogTracks = effectiveVersion?.subtitleTracks.orEmpty(), - plannedTracks = plannedSubtitles + preservedSubtitles, + plannedTracks = decision.session.subtitleUrls.orEmpty(), ) + val returnedSubtitleIndex = decision.plan.resolvedSelectedSubtitleIndex() + val returnedSubtitleIdentity = returnedSubtitleIndex + ?.let { index -> effectiveSubtitleUrls.singleOrNull { it.index == index } } + ?.let(::tvSubtitleIdentity) + ?: SubtitleIdentity.Off + val returnedAudioIndex = decision.plan.selectedTracks.audio?.index + ?: decision.session.audioTrackIndex val effectiveContainer = decision.plan.stream.container ?: effectiveVersion?.container ?: state.container.takeIf { effectiveFileId == fileId } - val effectiveDuration = decision.session.durationSeconds - ?: effectiveVersion?.duration?.takeIf { it > 0.0 } - ?: state.duration.takeIf { effectiveFileId == fileId } - ?: 0.0 - val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( - params = StartParams( - contentId = contentId, - fileId = effectiveFileId, - capabilities = capabilities, - audioTrackIndex = decision.session.audioTrackIndex, - subtitleTrackIndex = selectedSubtitle, - startPosition = decision.session.position, - ), - session = decision.session, - renewMissingSessionWithLegacyStart = false, - isCurrent = { - recoveryContentGeneration == contentLoadGeneration && - isActive - }, - ) - if (!adopted) { - runCatching { - playbackSessionManager.stopSession(decision.session.sessionId) + val effectiveDuration = decision.session.durationSeconds ?: 0.0 + var adopted = false + try { + adopted = sessionLifecycle.adoptActiveSessionIfCurrent( + params = StartParams( + contentId = contentId, + fileId = effectiveFileId, + capabilities = decision.capabilities, + audioTrackIndex = returnedAudioIndex, + subtitleTrackIndex = returnedSubtitleIndex ?: -1, + qualityPreference = qualityPreference + ?: qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue, + startPosition = decision.session.position, + clientPlaybackContext = decision.clientPlaybackContext, + ), + session = decision.session, + isCurrent = { + recoveryContentGeneration == contentLoadGeneration && + isActive + }, + ) + } finally { + // Covers refusal AND cancellation while awaiting the + // lifecycle mutex, which throws before isCurrent runs. + // NonCancellable because the usual reason for being + // here is that this coroutine was cancelled, and a + // cancelled one cannot make the releasing call. + if (!adopted) { + withContext(NonCancellable) { + runCatching { + playbackSessionManager.stopSession( + decision.session.sessionId, + ) + } + } } - return@launch } + if (!adopted) return@launch + lastAdoptedSessionId = decision.session.sessionId coroutineContext.ensureActive() if (recoveryContentGeneration != contentLoadGeneration) return@launch - val transportMountNonce = nextTransportMountNonce(selectedSubtitle) + val transportMountNonce = nextTypedSubtitleMountNonce(returnedSubtitleIdentity) _uiState.update { it.copy( error = null, @@ -1923,27 +2504,69 @@ class TvPlayerViewModel( selectedFileId = effectiveFileId, mediaFileId = effectiveFileId, selectedFileResolution = effectiveResolution, + videoQualities = authoritativePlaybackQualityOptions( + available = decision.plan.availableQualities, + selectedLabel = qualityPreference + ?: qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue, + ), container = effectiveContainer, duration = effectiveDuration, serverDuration = effectiveDuration, subtitleUrls = effectiveSubtitleUrls, + committedSubtitleIdentity = returnedSubtitleIdentity, chapters = effectiveVersion?.chapters.orEmpty().ifEmpty { if (effectiveFileId == fileId) state.chapters else emptyList() }, - startPosition = decision.plan.timeline.playerStartSeconds, - position = decision.plan.timeline.sourceStartSeconds - .takeIf { it.isFinite() && it >= 0.0 } - ?: it.position, + startPosition = remountPosition.playerPositionSeconds, + position = remountPosition.sourcePositionSeconds, ) } + val recoveredState = _uiState.value + subtitleTransactions.resetContent( + context = subtitlePlaybackContext( + state = recoveredState, + capabilities = decision.capabilities, + clientPlaybackContext = decision.clientPlaybackContext, + ), + committedIdentity = returnedSubtitleIdentity, + ) + subtitleTransactions.restoreCommittedLocalMount() + Log.i( + TAG, + "replan_mount restored_source_seconds=${remountPosition.sourcePositionSeconds} " + + "player_seconds=${remountPosition.playerPositionSeconds}", + ) } is VideoSessionStartV3.Terminal -> { + val failedSessionId = state.sessionId ?: return@launch + val terminalMessage = + "Playback unavailable (${decision.reason}): ${decision.message}" cancelPendingCatalogSubtitle() + val terminalStillCurrent = sessionLifecycle.stopTerminalSessionIfCurrent( + expectedSessionId = failedSessionId, + isCurrent = { + recoveryContentGeneration == contentLoadGeneration && + _uiState.value.sessionId == failedSessionId + }, + ) + if (!terminalStillCurrent) { + return@launch + } + lastAdoptedSessionId = null _uiState.update { it.copy( - error = "Playback unavailable (${decision.reason}): ${decision.message}", + error = terminalMessage, isLoading = false, isBuffering = false, + isPlaying = false, + isPaused = true, + sessionId = null, + playMethod = null, + playbackPlan = null, + delivery = null, + streamUrl = null, ) } } @@ -2082,11 +2705,15 @@ class TvPlayerViewModel( val rawDurationSec = durationMs / 1000.0 val mappedPositionSec = (timeline?.sourcePositionForPlayer(rawPositionSec) ?: rawPositionSec) .let { position -> serverDuration?.let { position.coerceAtMost(it) } ?: position } - val mappedDurationSec = if (durationMs > 0) { + val mappedDurationSec = if (currentState.playbackPlan != null) { + // V3 forbids substituting a stream-local engine duration when the + // plan omitted source.duration_seconds. + serverDuration ?: 0.0 + } else if (durationMs > 0) { timeline?.sourcePositionForPlayer(rawDurationSec) ?: rawDurationSec } else { 0.0 - }.let { duration -> serverDuration?.let { duration.coerceAtMost(it) } ?: duration } + } val nowMs = SystemClock.elapsedRealtime() val positionDecision = seekPresentationGuard.onPositionReport( positionMs = (mappedPositionSec * 1_000.0).toLong().coerceAtLeast(0L), @@ -2108,8 +2735,8 @@ class TvPlayerViewModel( _uiState.update { it.copy( position = positionSec, - // Grow-only: an engine report may extend an unknown runtime (a - // growing transcode window) but never shrink a known one. + // Offline playback may learn a runtime from Media3. V3's value + // above is always the server-declared duration or unknown (0). duration = maxOf(it.duration, durationSec), ) } @@ -2132,6 +2759,7 @@ class TvPlayerViewModel( positionSec = positionSec, durationSec = _uiState.value.duration, isPaused = _uiState.value.isPaused, + expectedSessionId = _uiState.value.sessionId, ) // Track B: durably record (local resume + outbox sync) for both streaming @@ -2151,6 +2779,221 @@ class TvPlayerViewModel( playbackSessionManager.reportFirstVideoFrame(_uiState.value.stats) } + /** + * An audio change has committed, so it is now the viewer's choice. + * + * Set here rather than when the change was requested: staging, validation, + * adoption, mount and rollback can all fail, and a flag raised on intent + * would carry whatever track survived the failure into the next episode as + * though it had been chosen. + */ + fun onAudioSelectionCommitted() { + manualAudioSelectionApplied = true + } + + // ---- Desired audio ----------------------------------------------------- + // + // One generation-owned intent instead of several nullable fields racing to + // decide the same thing. Every entry point -- the detail page's launch + // pick, a persisted fingerprint, the HUD, the remote -- writes here, and a + // single resolver reconciles it against each track snapshot. + + private var desiredAudioGeneration = if (initialAudioTrackIndex != null) 1L else 0L + + /** Monotonic; makes each local-selection request distinct for StateFlow. */ + private var localAudioAttempt = 0L + + /** The audio the viewer wants, as a CATALOG ordinal. */ + private var desiredAudio: DesiredAudio? = initialAudioTrackIndex?.let { + // The detail page's pick reaches the server in the start request, but a + // direct-play stream carrying every audio track still lets Media3 pick + // its own default — so choosing Dutch and pressing Play mounted English. + // Seeded as a plain value, NOT through setDesiredAudio: an init block + // running before the flow below is declared would dereference null. + DesiredAudio( + generation = 1L, + catalogOrdinal = it, + // A fresh detail-page pick carries to the next episode; a durable + // value seeded onto that page is a restore and must not. + explicit = launchArgs.initialAudioPickedThisSession, + fileId = launchArgs.preferredFileId, + ) + } + + private val _pendingLocalAudioSelection = MutableStateFlow(null) + + /** + * A mounted track the screen should select on the player directly. + * + * The ViewModel has no player handle. The generation lets a stale + * acknowledgement be ignored: rapid Dutch -> English -> Dutch would + * otherwise collapse into indistinguishable requests. + */ + val pendingLocalAudioSelection: StateFlow = + _pendingLocalAudioSelection.asStateFlow() + + private fun catalogAudioTracks(state: UiState): List = state.fileVersions + .firstOrNull { it.fileId == (state.selectedFileId ?: state.mediaFileId) } + ?.audioTracks + .orEmpty() + + /** + * Records what the viewer wants. A newer intent always supersedes an older + * one and voids any request still in flight for it, so a late match can + * never revert a choice made since. + */ + private fun setDesiredAudio(catalogOrdinal: Int, explicit: Boolean) { + // An explicit choice claims the durable restore. Otherwise the pending + // fingerprint is resolved on a later callback, mints a NEWER generation + // for an OLDER decision, and overwrites the pick just made — generation + // order would encode processing order, not decision order. + if (explicit) pendingPersistedAudioFingerprint = null + desiredAudioGeneration += 1 + val state = _uiState.value + desiredAudio = DesiredAudio( + generation = desiredAudioGeneration, + catalogOrdinal = catalogOrdinal, + explicit = explicit, + fileId = state.selectedFileId ?: state.mediaFileId, + ) + _pendingLocalAudioSelection.value = null + _uiState.update { + it.copy(desiredAudioOrdinal = catalogOrdinal, desiredAudioConfirmed = false) + } + _uiState.value.audioTracks.takeIf { it.isNotEmpty() }?.let(::reconcileDesiredAudio) + } + + /** + * Drives the desired audio towards the player on every track snapshot. + * + * The intent is deliberately NOT cleared once read. An empty or partial + * first callback used to discard it permanently, which reproduced the + * original bug: choose Dutch, get English. It stays live until it is + * satisfied or superseded, and because it stays live it doubles as the + * re-application mechanism -- a remount installs a new MediaTrackGroup and + * the override was bound to the old one, so a confirmed choice has to be + * applied again rather than assumed to survive. + */ + private fun reconcileDesiredAudio(audio: List) { + val desired = desiredAudio ?: return + val state = _uiState.value + val action = reconcileDesiredAudioAction( + desired = desired, + activeFileId = state.selectedFileId ?: state.mediaFileId, + catalog = catalogAudioTracks(state), + mounted = audio.map { it.toMountedAudioTrack() }, + selectedOrdinal = audio.firstOrNull { it.isSelected }?.index, + planAudioOrdinal = state.playbackPlan?.selectedTracks?.audioIndex, + ) + when (action) { + AudioReconcileAction.None -> Unit + + AudioReconcileAction.DropForeignFile -> { + desiredAudio = null + _pendingLocalAudioSelection.value = null + _uiState.update { + it.copy(desiredAudioOrdinal = null, desiredAudioConfirmed = false) + } + } + + AudioReconcileAction.Confirm -> { + // Dropped first: the collector would otherwise replay a stale + // ordinal against a replacement backend. + _pendingLocalAudioSelection.value = null + confirmDesiredAudio(desired) + } + + is AudioReconcileAction.Apply -> { + localAudioAttempt += 1 + // Reapplying is not a confirmed state: the row must stop + // claiming the track until the player is back on it. + if (desired.confirmed) desiredAudio = desired.copy(confirmed = false) + _uiState.update { it.copy(desiredAudioConfirmed = false) } + _pendingLocalAudioSelection.value = LocalAudioSelection( + generation = desired.generation, + catalogOrdinal = desired.catalogOrdinal, + targetOrdinal = action.targetOrdinal, + attempt = localAudioAttempt, + ) + } + } + } + + /** The player is on the wanted track: only now is it the viewer's choice. */ + private fun confirmDesiredAudio(desired: DesiredAudio) { + if (desired.confirmed) return + desiredAudio = desired.copy(confirmed = true) + _uiState.update { + it.copy(desiredAudioOrdinal = desired.catalogOrdinal, desiredAudioConfirmed = true) + } + // A launch or persisted intent is a restore, not a fresh decision, so it + // must not mark the session as carrying an explicit pick for episode + // carry-over. + if (desired.explicit) { + onAudioSelectionCommitted() + persistDesiredAudio(desired.catalogOrdinal) + } + } + + private fun persistDesiredAudio(catalogOrdinal: Int) { + val state = _uiState.value + viewModelScope.launch { + val context = subtitlePlaybackContext(state) + val scope = context.writeScope ?: return@launch + val fileId = context.mediaFileId ?: return@launch + runCatching { + userItemStatePort.recordTrackSelection( + scope = scope, + contentId = context.contentId, + fileId = fileId, + audioUpdate = tvAudioTrackPersistenceUpdate( + committedAudioTrackIndex = catalogOrdinal, + audioTracks = context.audioTracks, + ), + // Untouched: this path changed audio only. + subtitleUpdate = TrackSelectionFingerprintUpdate.Preserve, + ) + } + } + } + + /** + * The screen has shown [TvPlayerViewModel.UiState.subtitleFailureMessage]. + * + * Cleared on acknowledgement rather than on a timer so the same failure + * cannot be reported twice, and so a later failure with identical text + * still surfaces. + */ + fun onSubtitleFailureShown(shownId: Long) { + _uiState.update { + // Acknowledged by ID, not by text. Two failures can carry the same + // words — a mount deadline reported twice reads identically — and + // comparing strings would let an old acknowledgement clear a new + // failure that merely said the same thing. Text is what the viewer + // reads; it was never an identity. + // The message clears; the id does NOT reset. Resetting the counter + // let a later re-emission of an old failure manufacture id 1 again + // and replay something already dismissed. + if (it.subtitleFailureId == shownId) it.copy(subtitleFailureMessage = null) else it + } + } + + /** + * Bounded recovery has given up and the picture is not coming back. + * + * The detector reports Failed exactly once and then goes quiet forever, so + * without surfacing it the viewer is left with advancing audio over a + * frozen frame, no message, and no reason to think pressing anything would + * help. Telemetry recorded this; nobody told the person watching. + */ + fun onPlaybackRecoveryExhausted() { + _uiState.update { + if (it.error != null) it else it.copy( + error = "Playback stopped responding. Press Back and try again.", + ) + } + } + fun onRuntimeCorrection(event: String, correctionId: String, stage: String, details: Map = emptyMap()) { playbackSessionManager.reportActiveVideoEvent( event = event, @@ -2191,6 +3034,18 @@ class TvPlayerViewModel( beginAndExecuteSeek(positionSec) } + /** + * Position the in-flight quick-skip burst started from, or null when no + * burst is pending. + * + * Read straight after [onSkipBy] so the skip chip can report the burst + * TOTAL — three fast forward presses coalesce into one +90s seek, and + * labelling that "+30s" three times is the only reason the coalescing + * looks like a dropped press rather than a deliberate one. + */ + val quickSkipBurstOriginSec: Double? + get() = quickSkipAccumulator.pending?.let { quickSkipOriginMs / 1_000.0 } + /** Coalesces rapid remote/button skips into one route-aware seek. */ fun onSkipBy(deltaSeconds: Double): Double { val state = _uiState.value @@ -2528,29 +3383,47 @@ class TvPlayerViewModel( val sourcePosition = decision.plan.timeline.sourceStartSeconds .takeIf { it.isFinite() && it >= 0.0 } ?: requestedSourcePosition + val version = before.fileVersions.firstOrNull { it.fileId == actualFileId } + val effectiveSubtitleUrls = enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks = version?.subtitleTracks.orEmpty(), + plannedTracks = decision.session.subtitleUrls.orEmpty(), + ) + val returnedSubtitleIndex = decision.plan.resolvedSelectedSubtitleIndex() + val returnedSubtitleIdentity = returnedSubtitleIndex + ?.let { index -> effectiveSubtitleUrls.singleOrNull { it.index == index } } + ?.let(::tvSubtitleIdentity) + ?: SubtitleIdentity.Off + val returnedAudioIndex = decision.plan.selectedTracks.audio?.index + ?: decision.session.audioTrackIndex + val committedQualityPreference = qualityOverride + ?: preferredQuality + ?: PlaybackQuality.Auto.wireValue seekRecoveryRollbackInvalidated = false - val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() if (!isCurrentSeekRecovery(request)) return - val selectedSubtitle = selectedSubtitleTrackIndex(before) val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = contentId, - fileId = fileId, - capabilities = capabilityDetector.detect(dolbyVision = dolbyVision), - audioTrackIndex = decision.session.audioTrackIndex, - subtitleTrackIndex = selectedSubtitle, + fileId = actualFileId, + capabilities = decision.capabilities, + audioTrackIndex = returnedAudioIndex, + subtitleTrackIndex = returnedSubtitleIndex ?: -1, + qualityPreference = committedQualityPreference, startPosition = sourcePosition, + clientPlaybackContext = decision.clientPlaybackContext, ), - session = decision.session, - renewMissingSessionWithLegacyStart = false, + session = decision.session.copy(subtitleUrls = effectiveSubtitleUrls), isCurrent = { isCurrentSeekRecovery(request) }, ) - if (!adopted) { - runCatching { playbackSessionManager.stopSession(decision.session.sessionId) } - return - } + // Deliberately no stop on refusal. A seek re-anchor is validated to + // reuse the SAME session id — the manager rejects any response that + // changes it — so this id names the session still playing, not a + // disposable candidate. Refusal normally means a newer seek was queued, + // and that seek needs this very session as its base; stopping it here + // left the manager with no active attempt to re-anchor. + if (!adopted) return + lastAdoptedSessionId = decision.session.sessionId if (!isCurrentSeekRecovery(request)) return - val transportMountNonce = nextTransportMountNonce(selectedSubtitle) + val transportMountNonce = nextTypedSubtitleMountNonce(returnedSubtitleIdentity) _uiState.update { if (!isCurrentSeekRecovery(request)) return@update it it.copy( @@ -2566,8 +3439,20 @@ class TvPlayerViewModel( container = decision.plan.stream.container ?: it.container, startPosition = decision.plan.timeline.playerStartSeconds, position = sourcePosition, + subtitleUrls = effectiveSubtitleUrls, + committedSubtitleIdentity = returnedSubtitleIdentity, ) } + val recoveredState = _uiState.value + subtitleTransactions.resetContent( + context = subtitlePlaybackContext( + state = recoveredState, + capabilities = decision.capabilities, + clientPlaybackContext = decision.clientPlaybackContext, + ), + committedIdentity = returnedSubtitleIdentity, + ) + subtitleTransactions.restoreCommittedLocalMount() } private fun isCurrentSeekRecovery(request: TvSeekRecoveryRequest): Boolean = @@ -2644,10 +3529,21 @@ class TvPlayerViewModel( ) if (selected != null) { _pendingRemoteAudioIndex.compareAndSet(index, null) - pendingPersistedAudioFingerprint = null + // Through the same intent as every other entry point. Going straight + // to a replan left an older launch/persisted/HUD intent authoritative, + // and it would reapply itself afterwards and undo the remote pick. + setDesiredAudio(selected, explicit = true) + if (matchMountedAudioTrack( + catalogAudioTracks(state).getOrNull(selected) ?: return, + state.audioTracks.map { it.toMountedAudioTrack() }, + ) != null + ) { + return + } playbackMutationFence.beginReplan() - subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) - subtitleTransactions.selectAudio(selected) + launchSubtitleTransaction(state) { + subtitleTransactions.selectAudio(selected) + } } else { _pendingRemoteAudioIndex.value = index } @@ -2663,8 +3559,9 @@ class TvPlayerViewModel( if (identity != null) { _pendingRemoteSubtitleIndex.compareAndSet(index, null) playbackMutationFence.beginReplan() - subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(_uiState.value)) - subtitleTransactions.select(identity) + launchSubtitleTransaction(_uiState.value) { + subtitleTransactions.select(identity) + } } else { _pendingRemoteSubtitleIndex.value = index } @@ -2680,8 +3577,8 @@ class TvPlayerViewModel( * Skip-intro and the credits-based F2 trigger read these from UiState, so the * update takes effect immediately; `null` clears a marker the server dropped. */ - fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?) { - _uiState.update { it.copy(intro = intro, credits = credits) } + fun applyUpdatedMarkers(intro: TimeRange?, credits: TimeRange?, recap: TimeRange?, preview: TimeRange?) { + _uiState.update { it.copy(intro = intro, credits = credits, recap = recap, preview = preview) } } // ---- Next-episode auto-advance (F2) ---- @@ -2784,6 +3681,10 @@ class TvPlayerViewModel( _uiState.update { it.copy( showNextUp = true, + // Up Next owns the screen: the HUD is rendered purely on + // hudOpen, so leaving it set draws the tab row and panes + // underneath the overlay. + hudOpen = false, nextUpVideoEnded = true, nextUpCountdownSeconds = null, ) @@ -2811,7 +3712,7 @@ class TvPlayerViewModel( * * Routed through the same commit the automatic timing uses so the overlay, * the countdown gating and the auto-advance accounting behave identically — - * the only difference is what asked for it. Mirrors prairie-apple#86, which + * the only difference is what asked for it. Mirrors silo-apple#86, which * added the equivalent control to the tvOS transport. * * The countdown is deliberately NOT started here: someone who opened this @@ -2824,7 +3725,7 @@ class TvPlayerViewModel( nextUpCountdownJob?.cancel() nextUpCountdownJob = null _uiState.update { - it.copy(showNextUp = true, nextUpCountdownSeconds = null) + it.copy(showNextUp = true, hudOpen = false, nextUpCountdownSeconds = null) } } @@ -2835,12 +3736,26 @@ class TvPlayerViewModel( val threshold = passOutThreshold.value val passOutGated = threshold > 0 && autoAdvanceCount >= threshold val autoCountdown = autoPlayNextEnabled.value && !passOutGated + val current = _uiState.value + // Pre-end commits anchor the countdown to the remaining playback time + // (see startNextUpCountdown); only an at-end commit uses the wall clock. + val initialCountdown = when { + !autoCountdown -> null + videoEnded -> NEXT_UP_COUNTDOWN_SECONDS + else -> ceil((current.duration - current.position).coerceAtLeast(0.0)).toInt() + } _uiState.update { it.copy( showNextUp = true, + hudOpen = false, nextUpVideoEnded = videoEnded, - nextUpCountdownSeconds = if (autoCountdown) NEXT_UP_COUNTDOWN_SECONDS else null, + nextUpCountdownSeconds = initialCountdown, + // The ring draws remaining/total, so a pre-end countdown longer + // than the wall-clock default has to carry its own total or the + // ring renders past full. + nextUpCountdownTotalSeconds = initialCountdown?.coerceAtLeast(1) + ?: NEXT_UP_COUNTDOWN_SECONDS, ) } if (autoCountdown) startNextUpCountdown() @@ -2849,20 +3764,52 @@ class TvPlayerViewModel( private fun startNextUpCountdown() { nextUpCountdownJob?.cancel() nextUpCountdownJob = viewModelScope.launch { - var remaining = NEXT_UP_COUNTDOWN_SECONDS - while (remaining > 0) { + // Two anchors, matching phone and tvOS: + // - Card committed BEFORE the end (credits crossing): the countdown + // mirrors the remaining playback time, so it freezes on pause, + // grows on a backward seek, and the advance fires only once the + // player reports the stream ended. A fixed wall clock here cut off + // the final scene of anything whose credits marker sits more than + // ten seconds from the actual end. + // - Card committed AT the end (stream ended with no earlier + // crossing): there is no playback left to anchor to, so a short + // wall-clock countdown gives the viewer a window to cancel. + val startedAtEnd = _uiState.value.nextUpVideoEnded + var wallRemaining = NEXT_UP_COUNTDOWN_SECONDS + while (true) { delay(1_000) - remaining -= 1 + // Bail if something dismissed the overlay underneath us. + if (!_uiState.value.showNextUp) return@launch + val remaining = if (startedAtEnd) { + wallRemaining -= 1 + wallRemaining.coerceAtLeast(0) + } else { + val state = _uiState.value + ceil((state.duration - state.position).coerceAtLeast(0.0)).toInt() + } _uiState.update { - // Bail if something dismissed the overlay underneath us. - if (!it.showNextUp) it else it.copy(nextUpCountdownSeconds = remaining) + if (!it.showNextUp) { + it + } else { + it.copy( + nextUpCountdownSeconds = remaining, + // A backward seek can push the remaining time past + // where the ring started; grow the total with it. + nextUpCountdownTotalSeconds = + maxOf(it.nextUpCountdownTotalSeconds, remaining, 1), + ) + } } if (!_uiState.value.showNextUp) return@launch + val playbackEnded = + if (startedAtEnd) wallRemaining <= 0 else _uiState.value.nextUpVideoEnded + if (!playbackEnded) continue + // Automatic countdown-expiry advance: increment the pass-out streak + // so a long unattended binge eventually trips the "still watching?" + // gate. An explicit Play Now (below) resets the streak instead. + advanceToNextEpisode(nextAutoAdvanceCount = autoAdvanceCount + 1) + return@launch } - // Automatic countdown-expiry advance: increment the pass-out streak - // so a long unattended binge eventually trips the "still watching?" - // gate. An explicit Play Now (below) resets the streak instead. - advanceToNextEpisode(nextAutoAdvanceCount = autoAdvanceCount + 1) } } @@ -2881,9 +3828,29 @@ class TvPlayerViewModel( nextUpCountdownJob = null val state = _uiState.value val next = state.nextEpisode ?: return - val selectedQuality = state.selectedFileResolution _uiState.update { it.copy(showNextUp = false, nextUpCountdownSeconds = null) } - _playNextRequests.tryEmit(PlayNextRequest(next.contentId, nextAutoAdvanceCount, selectedQuality)) + val activeVersion = state.fileVersions.firstOrNull { version -> + version.fileId == (state.selectedFileId ?: state.mediaFileId) + } + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = activeVersion, + committedSubtitleIdentity = state.committedSubtitleIdentity, + catalogSubtitles = state.subtitleUrls, + selectedAudioTrack = state.audioTracks.firstOrNull { it.isSelected }, + // The catalog row by ordinal — audio's contract — but the viewer's + // CONFIRMED choice first. A direct-play local switch changes the + // mounted track without replanning, so the plan can still name the + // previous audio: reading it alone handed the next episode the + // track the viewer had just switched away from, while + // manualAudioSelectionApplied said a choice had been made. + selectedCatalogAudio = ( + state.desiredAudioOrdinal?.takeIf { state.desiredAudioConfirmed } + ?: state.playbackPlan?.selectedTracks?.audioIndex + )?.let { activeVersion?.audioTracks?.getOrNull(it) }, + hasExplicitAudioSelection = manualAudioSelectionApplied, + hasExplicitSubtitleSelection = manualSubtitleSelectionApplied, + ) + _playNextRequests.tryEmit(PlayNextRequest(next.contentId, nextAutoAdvanceCount, handoff)) } /** Up-Next "Keep Watching" — dismiss the overlay and stay on the current episode. */ @@ -2900,6 +3867,7 @@ class TvPlayerViewModel( */ fun onTracksChanged(audio: List, subtitle: List) { _uiState.update { it.copy(audioTracks = audio, subtitleTracks = subtitle) } + reconcileDesiredAudio(audio) resolveSubtitleRemountReselection(subtitle) // The detail-page explicit pick resolves FIRST so a resolved pick can // suppress the persisted/auto fallback (and an unresolvable one lets it @@ -2908,9 +3876,10 @@ class TvPlayerViewModel( if (_pendingRemoteAudioIndex.value != null) { pendingPersistedAudioFingerprint = null } - resolvePendingPersistedTrackSelection(audio, subtitle) + resolvePendingPersistedTrackSelection(audio) retryPendingRemoteTrackIntents() resolveAutoPreferredTextSubtitle(audio, subtitle) + reconcileExternallySelectedSubtitle(subtitle) } fun onTracksChanged( @@ -2928,6 +3897,7 @@ class TvPlayerViewModel( videoTracks = video, ) } + reconcileDesiredAudio(audio) resolveSubtitleRemountReselection(subtitle) // The detail-page explicit pick resolves FIRST so a resolved pick can // suppress the persisted/auto fallback (and an unresolvable one lets it @@ -2936,54 +3906,40 @@ class TvPlayerViewModel( if (_pendingRemoteAudioIndex.value != null) { pendingPersistedAudioFingerprint = null } - resolvePendingPersistedTrackSelection(audio, subtitle) + resolvePendingPersistedTrackSelection(audio) retryPendingRemoteTrackIntents() resolveAutoPreferredTextSubtitle(audio, subtitle) + reconcileExternallySelectedSubtitle(subtitle) } - private fun resolvePendingPersistedTrackSelection( - audio: List, - subtitle: List, - ) { + /** + * Restores the persisted AUDIO choice once tracks land. + * + * The subtitle half of this used to live here too, resolving a saved + * fingerprint onto a Media3 ordinal and pushing it straight at the player. + * The durable subtitle preference is now restored through the transaction + * adapter at load (`restoreFreshPreference`), which is why the fingerprint + * it read was already being cleared unconditionally on every load — it + * could never fire again. + */ + private fun resolvePendingPersistedTrackSelection(audio: List) { pendingPersistedAudioFingerprint?.let { fingerprint -> if (audio.isNotEmpty()) { - pendingPersistedAudioFingerprint = null - val state = _uiState.value - val catalogAudioTracks = state.fileVersions - .firstOrNull { it.fileId == (state.selectedFileId ?: state.mediaFileId) } - ?.audioTracks - .orEmpty() - resolveTvPersistedAudioPlayerOrdinal( - fingerprint = fingerprint, - catalogAudioTracks = catalogAudioTracks, - mountedAudioTracks = audio, - )?.let { _pendingRemoteAudioIndex.value = it } - } - } - - pendingPersistedSubtitleFingerprint?.let { fingerprint -> - if (fingerprint == SUBTITLE_OFF_FINGERPRINT) { - pendingPersistedSubtitleFingerprint = null - manualSubtitleSelectionApplied = true - _subtitleSelectRequests.tryEmit(-1) - return + // Resolve to a CATALOG ordinal and hand it to the desired-audio + // resolver. This used to resolve a MOUNTED ordinal and push it + // into _pendingRemoteAudioIndex, which is read back as a catalog + // ordinal — so whenever mounted and catalog order disagreed it + // restored the wrong language. + // + // The fingerprint is kept when it does not resolve: clearing it + // on a partial first snapshot silently abandoned the restore. + resolveAudioTrackOrdinal(catalogAudioTracks(_uiState.value), fingerprint) + ?.takeIf { it >= 0 } + ?.let { catalogOrdinal -> + pendingPersistedAudioFingerprint = null + setDesiredAudio(catalogOrdinal, explicit = false) + } } - if (subtitle.isEmpty()) return - pendingPersistedSubtitleFingerprint = null - // Saved subtitle choices are fingerprinted on the STABLE server - // subtitle index (PlayerSubtitleInfo) — see persistSubtitleTrackSelection - // — so resolve against the mounted list, then map the matched server - // track onto the Media3 flat text ordinal SubtitleManager selects by. - // Matching the flat PlayerTrackEntry fingerprint directly would never - // restore, because that ordinal shifts as tracks are discovered. - val mounted = resolveMountedSubtitleOrdinal(_uiState.value.subtitleUrls, fingerprint) - ?.let { _uiState.value.subtitleUrls.getOrNull(it) } - ?: return - resolveMountedSubtitleTrack(mounted, subtitle) - ?.let { - manualSubtitleSelectionApplied = true - _subtitleSelectRequests.tryEmit(it.index) - } } } @@ -2991,58 +3947,185 @@ class TvPlayerViewModel( audio: List, subtitle: List, ) { + // Fallback ONLY. Any launch that carried a decision (detail-page pick or + // its Auto preview, an episode intent, a recovery restore) has already + // applied it, and re-deciding here is exactly the bug: this path can + // only see what Media3 has mounted. + if (launchSubtitleSelectionApplied) return + // A launch pick that has not resolved YET is still the decision: it + // retries across the next few track callbacks (a late sidecar, a + // second Media3 snapshot). Deciding here in the meantime mounted the + // auto pick over the viewer's — seen on a Shield where the detail-page + // "English SRT" resolved a callback late and Auto had already put the + // Forced track on. resolvePendingInitialSubtitle clears the index when + // it resolves or gives up, and both happen before this runs on the + // same callback, so nothing is stranded. + if (pendingInitialSubtitleIndex != null) return // manualSubtitleSelectionApplied is set when a persisted choice OR a // RESOLVED explicit detail-page pick was applied — that (not the bare // launch intent) is what suppresses auto. An explicit pick that failed to // resolve leaves the flag clear, so auto still runs instead of stranding // subtitles Off. if (manualSubtitleSelectionApplied) return + // Reached only by launches with no handoff at all (deep link, cast, + // remote/realtime start). The latch stays because the fallback still + // runs on every onTracksChanged: without it a second snapshot would + // re-run auto over a longer track list and override a selection the + // viewer has since made. if (autoTextSubtitleSelectionAttempted) return - if (subtitle.isEmpty()) return + // Wait for the player to report SOMETHING: an empty snapshot carries no + // selected audio language for the resolver to rank a subtitle against. + if (audio.isEmpty() && subtitle.isEmpty()) return val state = _uiState.value - val selection = resolveAutoSubtitleSelection( + // Media3 only knows what is MOUNTED. A launch whose server inventory is + // all external sidecars has an empty text-track list until one of them + // is mounted, so standing down on that alone left a deep link, cast or + // remote start with subtitles Off even for an Always profile — with the + // intended track sitting in subtitleUrls. Stand down only when neither + // inventory offers anything to choose from. + if (subtitle.isEmpty() && state.subtitleUrls.isEmpty()) return + // Resolve over the SERVER inventory, not the mounted text tracks: an + // external sidecar the initial plan did not mount is invisible to + // Media3, which is how "Auto - " started playing the + // embedded PGS track instead. The adapter mounts the winner if it is + // not mounted yet — a legitimate replan for a launch nobody decided. + val identity = resolveTvAutoSubtitleIdentity( audioTracks = audio, subtitleTracks = subtitle, + subtitleRows = state.subtitleUrls, preferredLanguage = state.preferredTextLanguage, subtitleMode = state.preferredSubtitleMode, showForced = state.showForcedSubtitles, ) autoTextSubtitleSelectionAttempted = true - when (selection) { - SubtitleAutoSelection.Disable -> _subtitleSelectRequests.tryEmit(-1) - is SubtitleAutoSelection.Select -> _subtitleSelectRequests.tryEmit(selection.index) - // Launch-time only: NoChange means Auto picked nothing, but Media3's - // default selector may still have a track on — Apple's engines start - // subs OFF, so the detail preview truthfully shows "Auto - None". - // Disable explicitly so the launch state matches that preview. - SubtitleAutoSelection.NoChange -> _subtitleSelectRequests.tryEmit(-1) + SubDiag.log("AUTO subtitle -> $identity") + applyAutomaticSubtitleSelection(identity, state) + } + + /** + * Drives an app-derived selection through the adapter — the single owner — + * rather than at the player directly. Selecting behind the adapter's back + * is what left the HUD reporting "Off" over subtitles plainly on screen. + */ + private fun applyAutomaticSubtitleSelection( + identity: SubtitleIdentity, + state: UiState, + priority: TvSubtitleMountPriority = TvSubtitleMountPriority.Auto, + ) { + // Provenance is recorded BEFORE the already-committed shortcut below, + // which publishes no transaction and returns. Exit persistence reads + // this marker to tell an app-made choice from the viewer's, so leaving + // it unset there wrote the plan's own pick — commonly the detail row's + // "Auto - None" — back as a durable manual preference, and every later + // launch restored that instead of re-running Auto. + autoSelectedSubtitleIdentity = identity + if (identity == state.committedSubtitleIdentity && state.pendingSubtitleIdentity == null) { + // Committed is what the adapter BELIEVES is on. At load it is seeded + // straight from the plan (resetContent) before the player has + // selected any text track, so "already committed" is not evidence + // the track is mounted — the launch handoff of a plan-selected + // sidecar reached this line and returned, and nobody ever told the + // player. Ask the adapter to mount its committed identity through + // the same local-restore path the replan/recovery loads use. + if (identity != SubtitleIdentity.Off && !playerHasSelectedSubtitle(identity, state)) { + SubDiag.log("AUTO committed-but-unmounted -> restoreCommittedLocalMount $identity") + nextSubtitleMountPriority = priority + subtitleTransactions.restoreCommittedLocalMount() + } + return } + autoSubtitleSelectionInFlight = true + nextSubtitleMountPriority = priority + launchSubtitleTransaction(state) { + subtitleTransactions.selectAuto(identity) + } + } + + /** True when the player's currently selected text track carries [identity]. */ + private fun playerHasSelectedSubtitle(identity: SubtitleIdentity, state: UiState): Boolean { + val selected = state.subtitleTracks.firstOrNull { it.isSelected } ?: return false + return tvMountedSubtitleIdentity(selected, state.subtitleTracks, state.subtitleUrls) == identity + } + + /** + * Applies the detail page's pre-selected subtitle through the adapter. + * + * Restore authority, and deliberately NOT persisted: the pick arrives as a + * launch argument the detail screen already owns the preference for, so + * re-writing it here could only ever overwrite it with a stale echo. It is + * still a resolved decision, which is why it outranks the auto heuristics. + */ + private fun applyRestoredSubtitleSelection(identity: SubtitleIdentity) { + applyAutomaticSubtitleSelection( + identity = identity, + state = _uiState.value, + priority = TvSubtitleMountPriority.Restore, + ) + } + + /** + * Safety net, not the mechanism: if Media3 reports a text track selected + * whose identity is not the adapter's committed one, something outside the + * app enabled it (device caption settings, a selector quirk, a renderer + * default). Adopt it so the HUD cannot disagree with the screen, and say so + * loudly — reaching this means an authority we thought we had removed is + * still selecting subtitles. + */ + private fun reconcileExternallySelectedSubtitle(subtitle: List) { + val state = _uiState.value + // Converge the in-flight latch on observed state as well as on the + // adapter snapshot: an automatic selection the adapter treats as a + // no-op publishes nothing, and a latch that only the snapshot could + // clear would disable this safety net for the rest of the session. + if (autoSubtitleSelectionInFlight && + state.pendingSubtitleIdentity == null && + state.committedSubtitleIdentity == autoSelectedSubtitleIdentity + ) { + autoSubtitleSelectionInFlight = false + } + val observed = tvExternalSubtitleAdoption( + subtitleTracks = subtitle, + subtitleRows = state.subtitleUrls, + committedIdentity = state.committedSubtitleIdentity, + pendingIdentity = state.pendingSubtitleIdentity, + selectionInFlight = autoSubtitleSelectionInFlight || + subtitleRemountReselection.hasPendingOwner, + ) ?: return + + Log.w( + TV_SUBTITLE_LOG_TAG, + "Adopting externally selected text track: " + + "observed=$observed committed=${state.committedSubtitleIdentity}", + ) + applyAutomaticSubtitleSelection(observed, state) } /** * Apply the detail screen's pre-selected subtitle once the player's tracks * land. * - * -1 = Off: emitted immediately; the screen's collector finds no match and - * calls selectSubtitle(null), turning subtitles off. + * -1 = Off, applied immediately. * * A positive value is a COMBINED-space subtitle index (externals first, * embedded after — the identity mounted subtitle_urls carry and * subtitle_track_index requests resolve), not Media3's flattened * text-track ordinal. Resolve it through the mounted server subtitle * metadata first so embedded CEA-608 or other player-discovered tracks do - * not shift the target. + * not shift the target, then hand the resolved track to the transaction + * adapter as a typed identity — it is the only thing that may mount one. */ private fun resolvePendingInitialSubtitle(subtitle: List) { val index = pendingInitialSubtitleIndex ?: return + val autoResolved = pendingInitialSubtitleAutoResolved if (index == -1) { pendingInitialSubtitleIndex = null - // An explicit Off from the detail page is a resolved decision: suppress - // the persisted/auto fallback so it isn't overridden. - manualSubtitleSelectionApplied = true - pendingPersistedSubtitleFingerprint = null - _subtitleSelectRequests.tryEmit(-1) + // Off from the detail page is a resolved decision — the row showed + // it — so it suppresses the auto fallback. Only an EXPLICIT Off is + // also a manual selection; an "Auto - None" preview is not. + launchSubtitleSelectionApplied = true + if (!autoResolved) manualSubtitleSelectionApplied = true + applyRestoredSubtitleSelection(SubtitleIdentity.Off) return } // Wait for a non-empty track list. The pick is only CONSUMED when it @@ -3054,17 +4137,21 @@ class TvPlayerViewModel( subtitleTracks = subtitle, mountedSubtitles = _uiState.value.subtitleUrls, ) - // Suppress the persisted/auto fallback ONLY when the explicit pick actually - // resolves onto a mounted track. An unresolvable pick leaves the persisted - // fingerprint intact and the manual flag clear, so it falls through to - // persisted -> auto instead of being silently dropped (subtitles Off all - // session). + // Suppress the auto fallback ONLY when the explicit pick actually + // resolves onto a mounted track. An unresolvable pick leaves the manual + // flag clear, so it falls through to auto instead of being silently + // dropped (subtitles Off all session). if (resolved != null) { pendingInitialSubtitleIndex = null pendingInitialSubtitleAttempts = 0 - manualSubtitleSelectionApplied = true - pendingPersistedSubtitleFingerprint = null - _subtitleSelectRequests.tryEmit(resolved) + launchSubtitleSelectionApplied = true + if (!autoResolved) manualSubtitleSelectionApplied = true + subtitle.firstOrNull { it.index == resolved } + ?.let { track -> + applyRestoredSubtitleSelection( + tvMountedSubtitleIdentity(track, subtitle, _uiState.value.subtitleUrls), + ) + } return } // Bounded retry: keep the pick pending across a few callbacks so a @@ -3077,29 +4164,54 @@ class TvPlayerViewModel( } } - fun onSubtitleSelectionApplied(index: Int) { - val owner = pendingSubtitleMountAcknowledgement ?: return - pendingSubtitleMountAcknowledgement = null + internal fun onSubtitleSelectionApplied(request: TvSubtitleMountRequest) { + val owner = request.owner + subtitleRemountReselection.acknowledgeResolved(owner.generation) subtitleTransactions.reportMountedSelection( identity = owner.identity, selected = true, - snapshotKey = "tv-mounted:${owner.generation}:$index", + snapshotKey = "tv-mounted:${owner.generation}:${request.trackIndex}", settled = true, ) } - fun selectAudioOption(index: Int) { + /** + * Selects audio by ORDINAL into the active version's `audio_tracks`, which + * is the server's contract for audio (see [selectedServerAudioTrackIndex]). + * + * The ordinal goes to the replan untouched. It used to be mapped through + * `AudioTrack.index`, a field the server never sends for audio, so every + * pick collapsed to 0. + */ + fun selectAudioOption(catalogOrdinal: Int) { val state = _uiState.value - val selected = selectedServerAudioTrackIndex( - selectedPlayerOrdinal = index, - catalogAudioTracks = state.fileVersions - .firstOrNull { it.fileId == (state.selectedFileId ?: state.mediaFileId) } - ?.audioTracks, - currentPlanTrackIndex = null, - ) ?: return + val catalog = catalogAudioTracks(state) + if (catalogOrdinal !in catalog.indices) return + + // If the mounted stream already carries this track, switch it on the + // player. A replan would rebuild the whole session to deliver audio the + // viewer is already receiving -- and because audio selection only ever + // staged a replan, a direct-play stream carrying several audio tracks + // never actually switched: the plan moved, the renderer did not. + // Record the intent first: the resolver applies it locally when the + // mounted stream already carries the track, which is the common + // direct-play case and needs no replan at all. + setDesiredAudio(catalogOrdinal, explicit = true) + if (matchMountedAudioTrack( + catalog[catalogOrdinal], + state.audioTracks.map { it.toMountedAudioTrack() }, + ) != null + ) { + return + } + + // manualAudioSelectionApplied is deliberately NOT raised here: it is + // raised on commit via CommittedSubtitle.audioPreferenceSpecified, so a + // request that fails or rolls back never becomes an episode preference. playbackMutationFence.beginReplan() - subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) - subtitleTransactions.selectAudio(selected) + launchSubtitleTransaction(state) { + subtitleTransactions.selectAudio(catalogOrdinal) + } } /** @@ -3120,9 +4232,14 @@ class TvPlayerViewModel( fun selectSubtitleOption(identity: SubtitleIdentity) { manualSubtitleSelectionApplied = true + // The viewer is choosing: drop the automatic marker so this commit + // writes the durable per-item preference. + autoSelectedSubtitleIdentity = null + autoSubtitleSelectionInFlight = false playbackMutationFence.beginReplan() - subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(_uiState.value)) - subtitleTransactions.select(identity) + launchSubtitleTransaction(_uiState.value) { + subtitleTransactions.select(identity) + } } fun selectSubtitleOption(serverIndex: Int) { @@ -3134,13 +4251,18 @@ class TvPlayerViewModel( selectSubtitleOption(tvSubtitleIdentity(row)) } - fun onSubtitleSelectionFailed(index: Int) { - val owner = pendingSubtitleMountAcknowledgement ?: return - pendingSubtitleMountAcknowledgement = null + internal fun onSubtitleSelectionFailed(request: TvSubtitleMountRequest) { + val owner = request.owner + subtitleRemountReselection.acknowledgeResolved(owner.generation) + Log.w( + TV_SUBTITLE_LOG_TAG, + "Subtitle mount rejected by the player: track=${request.trackIndex} " + + "identity=${owner.identity}", + ) subtitleTransactions.reportMountedSelection( identity = owner.identity, selected = false, - snapshotKey = "tv-mount-failed:${owner.generation}:$index", + snapshotKey = "tv-mount-failed:${owner.generation}:${request.trackIndex}", settled = true, ) } @@ -3152,8 +4274,8 @@ class TvPlayerViewModel( */ fun cancelPendingCatalogSubtitle() { subtitleRemountReselection.clear() + subtitleRemountReselection.releaseResolved() subtitleSnapshotSettlement.reset() - pendingSubtitleMountAcknowledgement = null } private fun resolveSubtitleRemountReselection(subtitle: List) { @@ -3163,14 +4285,14 @@ class TvPlayerViewModel( when ( val event = subtitleRemountReselection.consume( subtitleTracks = subtitle, + subtitleRows = _uiState.value.subtitleUrls, snapshotKey = snapshotKey, settled = subtitleSnapshotSettlement.observe(subtitle), ) ) { - is TvSubtitleRemountEvent.Select -> { - pendingSubtitleMountAcknowledgement = event.owner - _subtitleSelectRequests.tryEmit(event.trackIndex) - } + is TvSubtitleRemountEvent.Select -> _subtitleMountRequests.tryEmit( + TvSubtitleMountRequest(owner = event.owner, trackIndex = event.trackIndex), + ) is TvSubtitleRemountEvent.Failed -> subtitleTransactions.reportMountedSelection( identity = event.owner.identity, selected = false, @@ -3191,8 +4313,7 @@ class TvPlayerViewModel( fun updateScrubPreview(sec: Double) { _uiState.update { - val clamped = sec.coerceIn(0.0, it.duration.coerceAtLeast(0.0)) - it.copy(scrubPreviewSec = clamped) + it.copy(scrubPreviewSec = clampTvScrubPreview(sec, it.duration)) } } @@ -3226,12 +4347,32 @@ class TvPlayerViewModel( } } + /** + * Whether the transport overlay was on screen when the HUD opened. + * + * [openHUD] forces `showControls` true, which is invisible while the HUD is + * up — the overlay is gated on `!hudOpen` — but closing has to put the + * chrome back the way it found it. Without this, a HUD opened from clean + * playback closed onto a transport overlay nobody asked for, and Back had + * to be pressed twice to get back to the picture. + */ + private var controlsVisibleBeforeHud = false + fun openHUD() { + // Only record on a real open. A second openHUD while the HUD is already + // up would otherwise capture the forced `true` and lose the real origin. + if (!_uiState.value.hudOpen) { + controlsVisibleBeforeHud = _uiState.value.showControls + } + Log.d(TAG, "hud open (controlsBefore=$controlsVisibleBeforeHud, wasOpen=${_uiState.value.hudOpen})") _uiState.update { it.copy(hudOpen = true, showSubtitleMenu = false, showControls = true) } } fun closeHUD() { - _uiState.update { it.copy(hudOpen = false) } + Log.d(TAG, "hud close (restoreControls=$controlsVisibleBeforeHud, wasOpen=${_uiState.value.hudOpen})") + _uiState.update { + it.copy(hudOpen = false, showControls = controlsVisibleBeforeHud) + } } fun openSubtitleMenu() { @@ -3261,67 +4402,36 @@ class TvPlayerViewModel( if (wireValue == current) return val state = _uiState.value playbackMutationFence.beginReplan() - subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) - subtitleTransactions.selectQuality(wireValue) - } - - /** - * The server-transcode quality ladder for the current source: Auto + Original - * always, plus each downscale rung whose height is below the source (never - * offer an upscale). Wire values / labels come from [PlaybackQuality]. - */ - private fun transcodeQualityLadder( - sourceResolution: String?, - selectedWire: String, - ): List { - val sourceHeight = sourceResolution?.filter { it.isDigit() }?.toIntOrNull() ?: Int.MAX_VALUE - val rungs = listOf( - PlaybackQuality.P4K, - PlaybackQuality.P1080, - PlaybackQuality.P720, - PlaybackQuality.P480, - ).filter { tierHeight(it) < sourceHeight } - return (listOf(PlaybackQuality.Auto, PlaybackQuality.Original) + rungs).map { - VideoQualityOption( - id = it.wireValue, - label = it.label, - isSelected = it.wireValue == selectedWire, - resolution = it.wireValue, - ) + launchSubtitleTransaction(state) { + subtitleTransactions.selectQuality(wireValue) } } - private fun tierHeight(q: PlaybackQuality): Int = when (q) { - PlaybackQuality.P4K -> 2160 - PlaybackQuality.P1080 -> 1080 - PlaybackQuality.P720 -> 720 - PlaybackQuality.P480 -> 480 - else -> Int.MAX_VALUE - } - /** - * Skip the intro now: returns the seek target in seconds so the screen - * can call MediaController.seekTo. Returns null if there is no active - * intro range. + * The intro pill's Select: skip the intro (`ask`) or play it after all + * (`always`'s undo). Returns the seek target in seconds so the screen can + * call MediaController.seekTo, or null when no pill is showing. * * Returning the value (instead of seeking internally) keeps the VM free - * of MediaController references — the screen owns the controller. + * of MediaController references — the screen owns the controller — and is + * what lets a room route the seek through its transport gate. */ - fun onSkipIntroNow(): Double? { - val intro = _uiState.value.intro ?: return null - introAutoSkipController.cancelCountdown() + fun onSelectIntroPrompt(): Double? { + val target = introAutoSkipController.select() ?: return null // Pre-write the resolved source position so the credits crossing check // treats this as a deliberate jump. The caller routes the actual seek // through either the room controller or seekImmediate; the latter owns // the pending-position guard for solo playback. - _uiState.update { it.copy(position = intro.end) } - return intro.end + _uiState.update { it.copy(position = target) } + return target } - /** Cancel an in-flight auto-skip countdown — banner falls back to manual Skip. */ - fun onCancelIntroAutoSkip() { - introAutoSkipController.cancelCountdown() - } + /** + * Back while the intro pill is showing: take it down and resolve the intro + * without moving playback. True when a pill was actually dismissed, so the + * caller consumes the press only then. + */ + fun onDismissIntroPrompt(): Boolean = introAutoSkipController.dismiss() /** * HUD Chapters pane picked a row. Returns the seek target in seconds; @@ -3431,12 +4541,21 @@ class TvPlayerViewModel( ) when (val r = subtitlesRepository.download(request)) { is ApiResult.Success -> { - refreshSubtitles( + val merged = refreshSubtitles( autoSelectSubtitleId = r.data.subtitle.id, source = TvSubtitleRefreshSource.Download, ) _subtitleSearch.update { - it.copy(downloadingResultId = null, completedNonce = it.completedNonce + 1) + if (merged) { + it.copy(downloadingResultId = null, completedNonce = it.completedNonce + 1) + } else { + // Downloaded on the server, but we could not list it + // back — say so rather than closing as a success. + it.copy( + downloadingResultId = null, + error = "Downloaded, but the subtitle list could not be refreshed.", + ) + } } } is ApiResult.Error, is ApiResult.NetworkError -> _subtitleSearch.update { @@ -3458,22 +4577,45 @@ class TvPlayerViewModel( * label so the rebuild preserves the user's choice (Media3 track-group * overrides don't survive a re-prepare — groups are new instances). */ + /** + * Re-list subtitles after a download or AI job, returning whether it worked. + * + * It used to return Unit, so callers bumped completedNonce regardless — and + * both dialogs read that nonce as "the track merged and was selected" and + * dismissed themselves. A server-side job that succeeded followed by a + * failed list request therefore closed as a success with no new subtitle + * anywhere, which is indistinguishable from the feature not working. + */ internal suspend fun refreshSubtitles( autoSelectSubtitleId: Int?, source: TvSubtitleRefreshSource = TvSubtitleRefreshSource.Realtime, - ) { + ): Boolean { val state = _uiState.value - val mediaFileId = state.mediaFileId ?: return - val sessionId = state.sessionId ?: return + val mediaFileId = state.mediaFileId ?: return false + val sessionId = state.sessionId ?: return false subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) val owner = subtitleTransactions.beginRefresh(source) + if (state.playbackPlan != null) { + pendingAuthoritativeSubtitleDownloadId = autoSelectSubtitleId + val readyRow = autoSelectSubtitleId?.let { id -> + authoritativeSubtitleReadyRows[sessionId to id] + } + if (readyRow != null) { + val selected = subtitleTransactions.selectFromRefresh( + owner, + tvSubtitleIdentity(readyRow), + ) + if (selected) pendingAuthoritativeSubtitleDownloadId = null + } + return true + } val downloaded = try { when (val r = subtitlesRepository.list(mediaFileId)) { is ApiResult.Success -> r.data.subtitles is ApiResult.Error -> { Log.w(TAG, "refreshSubtitles failed: ${r.code} ${r.message}") subtitleTransactions.completeRefreshFailure(owner, r.message) - return + return false } is ApiResult.NetworkError -> { Log.w(TAG, "refreshSubtitles network error", r.exception) @@ -3481,7 +4623,7 @@ class TvPlayerViewModel( owner, r.exception.message ?: "Subtitle refresh failed.", ) - return + return false } } } catch (cancellation: CancellationException) { @@ -3489,18 +4631,57 @@ class TvPlayerViewModel( throw cancellation } val downloadedRows = mergeDownloadedSubtitles( - existing = emptyList(), + existing = state.subtitleUrls, downloaded = downloaded, sessionId = sessionId, serverUrl = state.serverUrl, ) - subtitleTransactions.applyRefresh( + // The adapter's answer, not an assumption. applyRefresh returns false + // when the refresh lost ownership before it could be applied — so a + // list request that SUCCEEDED but went stale in flight would otherwise + // still be reported as merged, and the dialog would close on a track + // that was never installed. + return subtitleTransactions.applyRefresh( owner = owner, subtitleTracks = downloadedRows, autoSelectDownloadId = autoSelectSubtitleId, ) } + /** Applies one exact server-minted V3 inventory row from realtime. */ + internal suspend fun applySubtitleReady(update: PlaybackSubtitleReady): Boolean { + val state = _uiState.value + val sessionId = state.sessionId ?: return false + if (update.sessionId != null && update.sessionId != sessionId) return false + if (update.mediaFileId != null && update.mediaFileId != state.mediaFileId) return false + val rows = applyAuthoritativeSubtitleReadyTrack(state.subtitleUrls, update) + if (rows == null) { + startProtocolV3Replan( + classification = "subtitle_inventory_changed", + notice = "Subtitle inventory changed. Refreshing playback metadata.", + state = state, + ) + return false + } + subtitleTransactions.updatePlaybackContext(subtitlePlaybackContext(state)) + val owner = subtitleTransactions.beginRefresh(TvSubtitleRefreshSource.Realtime) + val subtitleId = update.subtitleId + val added = update.track?.trackId?.let { trackId -> + rows.singleOrNull { it.serverTrackId == trackId } + } + if (subtitleId != null && added != null) { + authoritativeSubtitleReadyRows[sessionId to subtitleId] = added + } + val autoSelectId = subtitleId.takeIf { it == pendingAuthoritativeSubtitleDownloadId } + val applied = subtitleTransactions.applyRefresh( + owner = owner, + subtitleTracks = rows, + autoSelectDownloadId = autoSelectId, + ) + if (applied && autoSelectId != null) pendingAuthoritativeSubtitleDownloadId = null + return applied + } + // ---- Subtitle suite: AI translate / transcribe ------------------------------- fun refreshAiQuota() { @@ -3576,12 +4757,20 @@ class TvPlayerViewModel( activeAiJobId = null when (outcome) { is SubtitlesRepository.SubtitleJobOutcome.Completed -> { - refreshSubtitles( + val merged = refreshSubtitles( autoSelectSubtitleId = outcome.resultSubtitleId, source = TvSubtitleRefreshSource.AiCompletion, ) _aiTranslate.update { - it.copy(phase = AiJobPhase.Idle, completedNonce = it.completedNonce + 1) + if (merged) { + it.copy(phase = AiJobPhase.Idle, completedNonce = it.completedNonce + 1) + } else { + it.copy( + phase = AiJobPhase.Failed( + "Translated, but the subtitle list could not be refreshed.", + ), + ) + } } } is SubtitlesRepository.SubtitleJobOutcome.Failed -> _aiTranslate.update { @@ -3616,8 +4805,8 @@ class TvPlayerViewModel( viewModelScope.launch { playerSettingsStore.setPlaybackSpeed(value) } } - fun onSetAutoSkipIntro(value: Boolean) { - viewModelScope.launch { playerSettingsStore.setAutoSkipIntro(value) } + fun onSetIntroSkipMode(value: IntroSkipMode) { + viewModelScope.launch { playerSettingsStore.setIntroSkipMode(value) } } fun onSetAutoPlayNext(value: Boolean) { @@ -3628,12 +4817,78 @@ class TvPlayerViewModel( viewModelScope.launch { playerSettingsStore.setHdrEnabled(value) } } - /** Applies to track selection immediately; server-side routing (base - * layer vs DV delivery) follows at the next playback start. */ + /** + * Applies to local track selection immediately, but the part that matters + * for a single-track DV file — base layer vs DV delivery — is decided in + * the server's plan from the capability snapshot sent at load. So once the + * setting is written, restart the session in place at the current position + * if the current file is Dolby Vision: the viewer sees the layer they just + * chose instead of having to back out and resume to get it. A non-DV file + * has nothing to re-plan and is left alone. + */ fun onSetDolbyVisionEnabled(value: Boolean) { - viewModelScope.launch { playerSettingsStore.setDolbyVisionEnabled(value) } + viewModelScope.launch { + playerSettingsStore.setDolbyVisionEnabled(value) + val state = _uiState.value + if (state.streamUrl == null) return@launch + val fileId = state.selectedFileId ?: state.mediaFileId + val currentIsDolbyVision = state.fileVersions + .firstOrNull { it.fileId == fileId } + ?.let(org.prairieserver.prairie.tv.ui.screens.detail.TvPlaybackFormatting::isDolbyVision) + ?: (state.playbackPlan?.claims?.video?.dolbyVision == true) + if (!currentIsDolbyVision) return@launch + Log.i(TAG, "dolby_vision_toggle value=$value restart_in_place file_id=$fileId") + // "In flight" until the replacement session is adopted AND has + // frames moving — adoption is quick (~1s) but the viewer's wait is + // the rebuffer after it, so the cue must outlast that. If the + // replacement never arrives (the old session is kept on failure), + // stop claiming progress after a bounded wait. + val previousSessionId = state.sessionId + // The replacement publishes isPaused = false (loadContent) and the + // screen mirrors that to playWhenReady, so changing the setting + // while paused resumed the video behind the HUD. Carry the + // pre-switch intent across and re-assert it once the replacement is + // adopted — the publication that clears it is the same update that + // clears isLoading. + val wasPaused = state.isPaused + _dolbyVisionSwitchInFlight.value = true + dolbyVisionSwitchWatch?.cancel() + dolbyVisionSwitchWatch = launch { + try { + withTimeoutOrNull(OUTPUT_SWITCH_FEEDBACK_TIMEOUT_MS) { + if (wasPaused) { + // A restored pause never reaches isPlaying, so the + // cue ends at adoption rather than at first frames. + _uiState.first { + it.sessionId != previousSessionId && !it.isLoading + } + setPaused(true) + } else { + _uiState.first { + it.sessionId != previousSessionId && + !it.isLoading && !it.isBuffering && it.isPlaying + } + } + } + } finally { + _dolbyVisionSwitchInFlight.value = false + } + } + restartSessionInPlace(fileId) + } } + private val _dolbyVisionSwitchInFlight = MutableStateFlow(false) + private var dolbyVisionSwitchWatch: Job? = null + + /** + * True from a Dolby Vision toggle that restarted the session until the + * replacement is adopted and playing. Drives the row's "Applying…" cue + * and makes a second press a no-op mid-switch — without disabling the row, + * which would drop focus off it. + */ + val dolbyVisionSwitchInFlight: StateFlow = _dolbyVisionSwitchInFlight.asStateFlow() + fun onSetSubtitleAppearance(value: SubtitleAppearance) { viewModelScope.launch { playerSettingsStore.setSubtitleAppearance(value) } } @@ -3650,14 +4905,14 @@ class TvPlayerViewModel( } /** - * HUD Subtitles pane stepper handler. Coerced to ±500ms in the store; the + * HUD Subtitles pane stepper handler. Coerced to ±10000ms in the store; the * service binding (A.3f T2) picks up the new value and pushes it into the * shared [org.prairieserver.prairie.common.player.subtitle.SubtitleOffsetHolder] - * (forcing a flush via `seekTo(currentPosition)` so the change applies - * mid-playback by dropping already-buffered cues). + * while reparsing the current media item so the change applies to already- + * buffered cues. */ fun onSubtitleDelayChanged(delayMs: Int) { - viewModelScope.launch { playerSettingsStore.setSubtitleSyncMsFor(contentId, delayMs) } + viewModelScope.launch { playerSettingsStore.setSubtitleSyncMs(delayMs) } } // ---- Sleep timer setters --------------------------------------------------- @@ -3675,11 +4930,32 @@ class TvPlayerViewModel( @Volatile private var lastAdoptedSessionId: String? = null + /** + * Retained token first, UI second. + * + * The token tracks *lifecycle ownership*, which is what teardown has to + * name, and it moves in both directions: forward at each adoption and at + * the load publication, back to the predecessor on either rollback path. + * That is strictly better than UI state here, because the three adoption + * paths take ownership before they publish and a cancellation in between + * would otherwise leave teardown naming a session the lifecycle has already + * let go of. + */ private val exitSessionId: String? - get() = _uiState.value.sessionId ?: lastAdoptedSessionId + get() = lastAdoptedSessionId ?: _uiState.value.sessionId + + /** + * Keeps this screen's lifecycle teardown to exactly one stop. Without it, + * [onCleared]'s deferred stop lands after the *next* episode's start has + * captured its ownership epoch, bumps stopEpoch, and gets that start + * rejected as "Playback start was superseded" — auto-advance dying on every + * episode transition. + */ + private val lifecycleTeardown = PlaybackTeardownGate(sessionLifecycle) private fun prepareSessionExit() { contentLoadGeneration++ + episodeSelectionHandoffSlot.invalidate() subtitleSnapshotSettlement.reset() resetSeekRecoveryForContentChange() transportMountGate.reset() @@ -3700,7 +4976,12 @@ class TvPlayerViewModel( introObserveJob?.cancel() nextUpCountdownJob?.cancel() introAutoSkipController.reset() - _uiState.value.sessionId?.let { lastAdoptedSessionId = it } + // Only fills a gap; never overwrites. The adoption paths publish this + // token ahead of UI state on purpose, and taking the UI value here would + // put the older id back. + if (lastAdoptedSessionId == null) { + _uiState.value.sessionId?.let { lastAdoptedSessionId = it } + } _uiState.update { it.copy( isLoading = false, @@ -3723,18 +5004,50 @@ class TvPlayerViewModel( playbackMutationFence.invalidateAll() prepareSessionExit() subtitleTransactions.persistCommittedSelectionAndFlush() - sessionLifecycle.stop(expectedSessionId = exitSessionId) + lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId) } /** Ordinary Back/remote-stop path: snapshot locally and return to detail immediately. */ - fun stopSessionForExitAsync() { + fun stopSessionForExitAsync( + positionMs: Long? = null, + durationMs: Long? = null, + ) { + // This is the controller's final sample. It must bypass transient + // seek/mount presentation gates, while still mapping a shortened + // Media3 timeline back onto source/movie time. + _uiState.update { current -> + val snapshot = resolveTvPlaybackExitSnapshot( + currentPositionSeconds = current.position, + currentDurationSeconds = current.duration, + positionMs = positionMs, + durationMs = durationMs, + timeline = current.playbackPlan?.timeline, + serverDurationSeconds = current.serverDuration, + allowPlayerDuration = current.playbackPlan == null, + ) + current.copy( + position = snapshot.positionSeconds, + duration = snapshot.durationSeconds, + ) + } + val subtitlePersistenceReservation = + subtitleTransactions.reserveDurableFinalPersistence() + val state = _uiState.value + TvDetailTrackSelectionSession.rememberPlaybackReturn( + contentId = contentId, + fileId = state.selectedFileId ?: state.mediaFileId, + audio = null, + subtitle = selectedSubtitleTrackIndex(state), + positionSeconds = state.position, + durationSeconds = state.duration.takeIf { it > 0.0 }, + ) subtitleTransactions.invalidate() playbackMutationFence.invalidateAll() prepareSessionExit() - // Final-position durability is owned by the application-scoped - // finalPlaybackPositionWriter; only the subtitle flush needs a scope here. - viewModelScope.launch { subtitleTransactions.persistCommittedSelectionAndFlush() } - sessionLifecycle.stopAsync(expectedSessionId = exitSessionId) + subtitlePersistenceReservation?.let( + subtitleTransactions::requestDurableFinalPersistence, + ) + lifecycleTeardown.stopDetached(expectedSessionId = exitSessionId) } fun onExit() { @@ -3899,13 +5212,31 @@ class TvPlayerViewModel( */ fun onSelectFileVersion(fileId: Int) { val state = _uiState.value + // Validate BEFORE mutating. A no-op or unknown id used to fall through + // after the audio intent had already been dropped, silently losing the + // choice without switching anything. if (fileId == (state.selectedFileId ?: state.mediaFileId)) return if (state.fileVersions.none { it.fileId == fileId }) return + restartSessionInPlace(fileId) + } + + /** + * Restart the session on [fileId] at the current position, keeping the + * current session mounted and playable until the replacement is ready + * (lifecycle adoption replaces A only after B is ready, including when B + * fails). Shared by the in-player version switch and by settings whose + * effect is decided in the server's plan rather than locally. + * + * The audio intent is left alone: it is scoped to the file it was made + * against, so reconciliation rejects it once the replacement publishes, + * and A keeps its choice if the replacement never arrives. + */ + private fun restartSessionInPlace(fileId: Int?) { + val state = _uiState.value + episodeSelectionHandoffSlot.invalidate() resetSeekRecoveryForContentChange() transportMountGate.beginLoad() val resumeAt = state.position.takeIf { it > 0.0 } - // Lifecycle adoption replaces A only after B is ready. Until then A - // remains mounted and playable, including when B fails. versionSwitchJob?.cancel() versionSwitchJob = viewModelScope.launch { coroutineContext.ensureActive() @@ -3960,7 +5291,7 @@ class TvPlayerViewModel( } override fun onCleared() { - val teardownSessionId = exitSessionId + episodeSelectionHandoffSlot.invalidate() val subtitlePersistenceReservation = subtitleTransactions.reserveDurableFinalPersistence() subtitleTransactions.invalidateAndSettleAsync(restoreUi = false) { @@ -3968,7 +5299,18 @@ class TvPlayerViewModel( subtitleTransactions::requestDurableFinalPersistence, ) playbackMutationFence.invalidateAll() - sessionLifecycle.stop(expectedSessionId = teardownSessionId) + // Read AFTER settlement, not snapshotted before it. Settlement can + // roll a subtitle publication back, and that rollback returns + // ownership to the predecessor — so a value captured before this + // callback names the discarded replacement, and the predecessor is + // left running with the one-shot gate already consumed. + // + // Never unqualified either. A null expectedSessionId disables the + // lifecycle's ownership guard entirely, and this callback is + // deliberately delayed behind subtitle settlement — long enough for + // a newer screen to have adopted its own session. A screen that + // never owned one has nothing to tear down. + exitSessionId?.let { lifecycleTeardown.stopDetached(expectedSessionId = it) } } subtitleSnapshotSettlement.reset() org.prairieserver.prairie.common.player.debug.PlaybackDebugState.screenError = null @@ -4000,6 +5342,48 @@ class TvPlayerViewModel( } +internal data class TvPlaybackExitSnapshot( + val positionSeconds: Double, + val durationSeconds: Double, +) + +internal fun resolveTvPlaybackExitSnapshot( + currentPositionSeconds: Double, + currentDurationSeconds: Double, + positionMs: Long?, + durationMs: Long?, + timeline: PlaybackTimeline?, + serverDurationSeconds: Double, + allowPlayerDuration: Boolean = true, +): TvPlaybackExitSnapshot { + if (positionMs == null || durationMs == null || positionMs < 0L) { + return TvPlaybackExitSnapshot(currentPositionSeconds, currentDurationSeconds) + } + + val serverDuration = serverDurationSeconds.takeIf { it.isFinite() && it > 0.0 } + val playerPositionSeconds = positionMs / 1_000.0 + val sourcePositionSeconds = ( + timeline?.sourcePositionForPlayer(playerPositionSeconds) ?: playerPositionSeconds + ).let { position -> serverDuration?.let(position::coerceAtMost) ?: position } + val sourceDurationSeconds = if (!allowPlayerDuration) { + serverDuration ?: 0.0 + } else if (durationMs > 0L) { + val playerDurationSeconds = durationMs / 1_000.0 + timeline?.sourcePositionForPlayer(playerDurationSeconds) ?: playerDurationSeconds + } else { + currentDurationSeconds + }.let { duration -> serverDuration?.let(duration::coerceAtMost) ?: duration } + + return TvPlaybackExitSnapshot( + positionSeconds = sourcePositionSeconds.coerceAtLeast(0.0), + durationSeconds = if (allowPlayerDuration) { + maxOf(currentDurationSeconds, sourceDurationSeconds) + } else { + sourceDurationSeconds + }, + ) +} + data class PlaybackClock( val position: Double, val duration: Double, @@ -4010,3 +5394,4 @@ internal fun TvPlayerViewModel.UiState.withoutPlaybackClock(): TvPlayerViewModel internal fun TvPlayerViewModel.UiState.toPlaybackClock(): PlaybackClock = PlaybackClock(position = position, duration = duration) + diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicy.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicy.kt new file mode 100644 index 000000000..a441cc2bf --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicy.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity + +internal enum class TvQuickSubtitlePickerExit { + Selection, + Back, +} + +internal data class TvQuickSubtitlePickerChromeState( + val pickerVisible: Boolean, + val controlsVisible: Boolean, +) + +internal fun tvQuickSubtitlePickerChromeState( + exit: TvQuickSubtitlePickerExit, +): TvQuickSubtitlePickerChromeState = when (exit) { + TvQuickSubtitlePickerExit.Selection -> TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = false, + ) + TvQuickSubtitlePickerExit.Back -> TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = true, + ) +} + +/** + * Resolves a quick-picker row before changing player chrome, so invalid IDs + * leave the picker visible and a valid selection is applied first. + */ +internal fun dispatchTvQuickSubtitlePickerSelection( + presentation: TvSubtitleHudPresentation, + stableId: String, + onSelect: (SubtitleIdentity) -> Unit, + onSelectionComplete: () -> Unit, +): Boolean { + val row = presentation.rows.firstOrNull { it.stableId == stableId } ?: return false + onSelect(row.identity) + onSelectionComplete() + return true +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt index 8161d6a2b..7069c6ca7 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt @@ -205,8 +205,16 @@ class TvRoomSyncController( while (isActive) { val state = viewModel.uiState.value val sessionId = state.sessionId + val deliveryKey = deliveryLatch.keyOrNull( + repository.connectionState.value, + sessionId, + ) val now = monotonicMs() - if (sessionId != null && + if (deliveryKey != null && + deliveryLatch.isServerAttached( + deliveryKey, + repository.roomDeliveryEcho.value, + ) && tvShouldEmitStateReport( now, lastReportMs, @@ -217,7 +225,7 @@ class TvRoomSyncController( ) { lastReportMs = now repository.stateReport( - sessionId = sessionId, + sessionId = deliveryKey.playbackSessionId, positionSeconds = state.position, isPaused = state.isPaused, ) @@ -243,7 +251,14 @@ class TvRoomSyncController( if (playbackState != RoomPlaybackState.Waiting || key == null) { return@collectLatest } - while (!deliveryLatch.isAttached(key)) delay(10) + while ( + !deliveryLatch.isServerAttached( + key, + repository.roomDeliveryEcho.value, + ) + ) { + delay(10) + } while (isActive && deliveryLatch.needsReadiness(key, buffering)) { val currentState = viewModel.uiState.value val delivered = if (buffering) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadder.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadder.kt new file mode 100644 index 000000000..4898f5445 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadder.kt @@ -0,0 +1,174 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * Speeds for hold-to-seek, and how a sustained press climbs through them. + * + * The rate a viewer sees on the chip is a multiple of real time, and this is + * the only place that decides what that multiple means. It previously did not + * mean anything: the scrubber advanced `2.0 * rate` seconds on a 100ms tick, so + * a chip reading "8×" moved at 160× real time. Every number on screen was a + * twentieth of the truth, which is why the control felt ungovernable — you + * aimed with the number and the number was wrong. + * + * [SECONDS_PER_TICK] is what makes the label honest: rate × tick seconds per + * tick is exactly rate × real time. + * + * A single fixed ceiling cannot serve both ends of this control. Nudging past + * an intro wants single digits; reaching the end of a three-hour film at 32× + * takes five and a half minutes of holding, which is not a seek, it is a + * hostage situation. So the top of the ladder is derived from the runtime + * instead of being a constant: [maxRateFor] targets [TRAVERSE_TARGET_SECONDS] + * to cross the whole item, so "hold until it gets there" costs about the same + * however long the thing is. + */ +internal object TvSeekRateLadder { + + /** Auto-seek tick cadence. Content advanced per tick is rate × this. */ + const val TICK_MILLIS = 100L + private const val SECONDS_PER_TICK = TICK_MILLIS / 1000.0 + + /** + * Slowest speed. A press has to visibly move, so 1× — which scans at + * exactly playback speed — is not a useful first step. + */ + const val BASE_RATE = 2 + + /** + * Steady-state crossing target: the ceiling is derived so that holding at + * it crosses the item in about this long. + * + * NOT the end-to-end figure. A hold ramps up to the ceiling rather than + * starting there, and the early rungs cover almost nothing, so the real + * cost runs ~10.5s for a 22-minute episode to ~17.8s for a three-hour + * film. [traverseSeconds] computes the honest number; that spread is the + * thing being kept small, not the absolute value. + */ + const val TRAVERSE_TARGET_SECONDS = 10.0 + + /** + * Floor for the derived ceiling, so short content still gets a fast top + * gear, and cap, so a very long item does not produce a rate whose single + * tick skips minutes. + */ + const val MIN_TOP_RATE = 32 + const val MAX_TOP_RATE = 1024 + + /** + * The fastest aimable speed. Above this a hold is travelling rather than + * aiming, which is fine — but it should be reached deliberately, by + * continuing to hold, not stumbled into in the first second. + */ + const val AIMABLE_MAX_RATE = 16 + + /** Doubling ladder; the reachable top is bounded by [maxRateFor]. */ + val rates: List = listOf(2, 4, 8, 16, 32, 64, 128, 256, 512, 1024) + + /** + * Cadence of the sustained ramp. The ramp keeps doubling on this beat until + * it reaches the item's ceiling, rather than walking a fixed number of + * steps — so a three-hour film goes on accelerating past the point where a + * twenty-minute episode has already topped out, which is the whole reason + * the ceiling is derived from runtime. + */ + const val RAMP_STEP_MILLIS = 900L + + /** Number of doublings needed to reach [durationSeconds]'s ceiling. */ + fun rampSteps(durationSeconds: Double): Int { + val ceiling = maxRateFor(durationSeconds) + var rate = BASE_RATE + var steps = 0 + while (rate < ceiling) { + rate *= 2 + steps++ + } + return steps + } + + /** + * Wall-clock seconds a sustained hold needs to cross an item of + * [durationSeconds] — INCLUDING the ramp, which is the part + * [TRAVERSE_TARGET_SECONDS] does not describe. + * + * The ceiling is derived so the STEADY-STATE crossing is about + * [TRAVERSE_TARGET_SECONDS], but a hold does not start at the ceiling: it + * doubles every [RAMP_STEP_MILLIS] to get there, and those early rungs + * cover very little. A three-hour item spends 8.1s ramping and covers only + * ~920s of it, so the honest end-to-end figure is ~17.8s, not ~10.5s. + * + * Exposed so the tests can assert what a viewer actually experiences + * rather than re-deriving `duration / topRate`, which is the arithmetic the + * implementation does NOT perform. + */ + fun traverseSeconds(durationSeconds: Double): Double { + if (!durationSeconds.isFinite() || durationSeconds <= 0.0) return 0.0 + val ceiling = maxRateFor(durationSeconds) + val rampStepSeconds = RAMP_STEP_MILLIS / 1000.0 + var covered = 0.0 + var elapsed = 0.0 + var rate = BASE_RATE + while (rate < ceiling && covered < durationSeconds) { + covered += rate * rampStepSeconds + elapsed += rampStepSeconds + rate *= 2 + } + if (covered >= durationSeconds) return elapsed + return elapsed + (durationSeconds - covered) / ceiling + } + + /** Content seconds to advance for one tick at [rate]. */ + fun tickSeconds(rate: Int): Double = rate * SECONDS_PER_TICK + + /** + * Fastest rate offered for an item of [durationSeconds]. + * + * Derived so a sustained hold crosses the item in about + * [TRAVERSE_TARGET_SECONDS], then rounded up to the next ladder rung so the + * chip still shows a familiar number. Unknown or nonsensical durations fall + * back to [MIN_TOP_RATE] rather than guessing. + */ + fun maxRateFor(durationSeconds: Double): Int { + if (!durationSeconds.isFinite() || durationSeconds <= 0.0) return MIN_TOP_RATE + val needed = ceil(durationSeconds / TRAVERSE_TARGET_SECONDS).toInt() + val bounded = min(max(needed, MIN_TOP_RATE), MAX_TOP_RATE) + return rates.firstOrNull { it >= bounded } ?: MAX_TOP_RATE + } + + /** + * The rate a sustained hold reaches at ramp [step] (0-based) in + * [direction], for an item of [durationSeconds]. + * + * Doubles from [BASE_RATE] and stops at whatever that item's ceiling is, so + * a long film keeps accelerating past the point where a short episode has + * already topped out. Step 0 is the first change, [RAMP_STEP_MILLIS] in. + */ + fun sustainedRate(step: Int, direction: Int, durationSeconds: Double): Int { + val sign = if (direction < 0) -1 else 1 + val ceiling = maxRateFor(durationSeconds) + var rate = BASE_RATE + repeat(step + 1) { rate = min(rate * 2, ceiling) } + return rate * sign + } + + /** + * Neighbouring rate after a repeat-press bump, clamped to this item's range. + * + * [delta] is a direction along the signed ladder as the key handlers see it, + * not "faster": +1 is rightwards (faster forwards, or slower backwards) and + * -1 is leftwards. A bump therefore never flips direction — it stops at the + * base rate on the way in. + */ + fun bumped(current: Int, delta: Int, durationSeconds: Double): Int { + val ceiling = maxRateFor(durationSeconds) + val usable = rates.filter { it <= ceiling } + val magnitude = if (current < 0) -current else current + val sign = if (current < 0) -1 else 1 + val index = usable.indexOf(magnitude) + if (index < 0) return current + val next = usable[(index + delta * sign).coerceIn(0, usable.lastIndex)] + return next * sign + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt index 583402a9c..6a82c090e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSkipSeekIndicator.kt @@ -35,17 +35,24 @@ data class SkipSeekFeedback( ) /** - * Transient feedback for hidden-controls D-pad skips: a pill chip with the + * Transient feedback for a D-pad or transport skip: a pill chip with the * signed delta ("+30s" / "−10s") and the landing time, above a thin * read-only progress line anchored where the transport scrubber lives (same * bottom geometry), so the position reads in the place users already look. - * Deliberately NOT the transport overlay: revealing controls would flip - * Left/Right from discrete skips into scrubber nudges mid-sequence. + * + * Set [showTrack] false when the real scrubber is on screen. The chip then + * carries only the delta — the thing the revealed transport cannot say — and + * leaves position to the live bar rather than stacking a second track over it. + * + * The chip itself does NOT reveal the transport: while controls are hidden, + * doing so would flip Left/Right from discrete skips into scrubber nudges + * mid-sequence. */ @Composable fun TvSkipSeekIndicator( feedback: SkipSeekFeedback?, modifier: Modifier = Modifier, + showTrack: Boolean = true, ) { AnimatedVisibility( visible = feedback != null, @@ -86,7 +93,7 @@ fun TvSkipSeekIndicator( } } - if (snapshot.durationSec > 0.0) { + if (showTrack && snapshot.durationSec > 0.0) { val progress = (snapshot.targetSec / snapshot.durationSec).toFloat().coerceIn(0f, 1f) // Mirrors TvPlayerScrubber's resting track exactly: 3.5dp // capsule, White@0.24 rail, solid White fill — so the line is diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt index 91dbdc3dc..cea1aa2a8 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleHudState.kt @@ -3,6 +3,8 @@ package org.prairieserver.prairie.tv.ui.screens.player import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.subtitleMediaIdentityOrNull internal data class TvSubtitleHudOption( val stableId: String, @@ -31,6 +33,49 @@ internal data class TvSubtitleHudPresentation( val onFocused: (String) -> Unit = {}, ) +/** + * Which subtitle-appearance controls actually reach the picture for the + * currently selected track. + * + * Image (PGS/DVB) captions are pre-rendered pixels: Media3's `SubtitlePainter` + * draws the cue's own bitmap and reads none of the caption style, so Font, + * Background, Opacity, Outline and the colour swatches are inert. Position and + * Size still work, because Silo rewrites the cue's geometry before handing it to + * the `SubtitleView` (see `remapBitmapCue` in android-shared). + * + * A server burn-in track has already been composited into the video frames, so + * nothing the client does can change it. + */ +internal data class TvSubtitleAppearanceApplicability( + /** Position and Size — the cue-geometry presets. */ + val geometryApplies: Boolean, + /** Font, Background, Opacity, Outline and the colour swatches. */ + val stylingApplies: Boolean, + /** One-line explanation for the pane, or null when everything applies. */ + val note: String?, +) + +internal fun tvSubtitleAppearanceApplicability( + identity: SubtitleIdentity?, +): TvSubtitleAppearanceApplicability = when { + identity is SubtitleIdentity.ServerBurnIn -> TvSubtitleAppearanceApplicability( + geometryApplies = false, + stylingApplies = false, + note = "Burned-in subtitles are part of the video and keep the server's styling.", + ) + isBitmapSubtitleCodecFamily(identity?.subtitleMediaIdentityOrNull()?.codecFamily) -> + TvSubtitleAppearanceApplicability( + geometryApplies = true, + stylingApplies = false, + note = "Image subtitles keep their own styling — only Position and Size apply.", + ) + else -> TvSubtitleAppearanceApplicability( + geometryApplies = true, + stylingApplies = true, + note = null, + ) +} + internal fun tvSubtitleOptionStableId(identity: SubtitleIdentity): String = encodeSubtitleIdentityPreference(identity) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleIdentity.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleIdentity.kt index 2636ff433..cf8d5a54f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleIdentity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleIdentity.kt @@ -1,86 +1,76 @@ package org.prairieserver.prairie.tv.ui.screens.player -import org.prairieserver.prairie.common.player.downloadedSubtitleArtifactTrackId -import org.prairieserver.prairie.common.player.isBitmapSubtitleCodecOrMime -import org.prairieserver.prairie.common.player.subtitleLabelIndicatesHearingImpaired +import org.prairieserver.prairie.common.player.MountedSubtitleTrack +import org.prairieserver.prairie.common.player.resolveMountedSubtitle import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity -import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily -import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily import org.prairieserver.prairie.playback.canonicalSubtitleLanguage +import org.prairieserver.prairie.playback.canonicalSubtitleCodecFamily +import org.prairieserver.prairie.playback.playbackSubtitleIdentity -internal fun tvSubtitleIdentity(subtitle: PlayerSubtitleInfo): SubtitleIdentity { - val source = subtitle.source?.trim()?.lowercase() - val catalogSource = subtitle.catalogSource?.trim()?.lowercase() - val downloaded = subtitle.downloadId != null || - source == "downloaded" || - catalogSource == "downloaded" - val media = SubtitleMediaIdentity( - trackId = subtitle.downloadId?.let(::downloadedSubtitleArtifactTrackId) - ?: subtitle.mediaTrackId, - label = subtitle.catalogLabel ?: subtitle.label, - language = canonicalSubtitleLanguage(subtitle.language), - codecFamily = canonicalSubtitleCodecFamily( - subtitle.codec ?: subtitle.url - .substringBefore('?') - .substringBefore('#') - .substringAfterLast('.', "") - .takeIf(String::isNotBlank), - ), - forced = subtitle.forced, - hearingImpaired = subtitleLabelIndicatesHearingImpaired( - subtitle.catalogLabel ?: subtitle.label, - ).takeIf { it }, - ) - if (downloaded) { - val downloadedMedia = media.copy( - forced = subtitle.forced ?: false, - hearingImpaired = subtitleLabelIndicatesHearingImpaired( - subtitle.catalogLabel ?: subtitle.label, - ), - ) - return subtitle.downloadId - ?.let { SubtitleIdentity.Downloaded(it, downloadedMedia) } - ?: SubtitleIdentity.LocalMedia3(downloadedMedia) - } +internal fun tvSubtitleIdentity(subtitle: PlayerSubtitleInfo): SubtitleIdentity = + playbackSubtitleIdentity(subtitle) - val embedded = subtitle.url.isBlank() && - (source == "embedded" || (source == null && catalogSource == "embedded")) - if (embedded) { - // A bitmap track cannot become a Media3 TEXT sidecar, so the staged - // transaction must not demand one — that is what made the server's - // correct BURN_IN plan get rejected as "unexpectedly burned in the - // mounted subtitle" and the pick silently revert to Off. - // - // PGS is the exception: the server raw-serves it as a `.sup` sidecar - // which SubtitleManager mounts, so it materialises like extracted text. - // VobSub and DVB have no sidecar route and always burn in. - return if ( - isBitmapSubtitleCodecOrMime(media.codecFamily) && - !isClientMountableBitmapCodecFamily(media.codecFamily) - ) { - SubtitleIdentity.ServerBurnIn(subtitle.index, media) - } else { - SubtitleIdentity.Embedded(subtitle.index, media) - } - } +/** + * Maps a mounted Media3 text track onto the SAME typed identity the HUD options + * carry, so an app-derived selection and a viewer's pick are indistinguishable + * to the transaction adapter. A track the server also describes resolves to its + * server row identity; anything the player discovered on its own (in-stream + * CEA-608, a sidecar the plan does not list) stays [SubtitleIdentity.LocalMedia3]. + */ +internal fun tvMountedSubtitleIdentity( + track: PlayerTrackEntry, + subtitleTracks: List, + subtitleRows: List, +): SubtitleIdentity = + resolveMountedSubtitleRow(track, subtitleTracks, subtitleRows) + ?.let(::tvSubtitleIdentity) + ?: tvSubtitleIdentity(track) + +/** + * Resolves a typed identity onto the Media3 text track that ALREADY carries it, + * or null when the player exposes no such track. + * + * [resolveMountedSubtitle] on its own is not enough for a SERVER-ROW identity. + * Protocol v3 types every non-burn-in inventory row `delivery = sidecar`, + * including a row that merely DESCRIBES a track muxed into a direct-play + * stream — so an embedded PGS track plainly mounted by Media3 maps to + * [SubtitleIdentity.ServerSidecar], and a sidecar identity is matched by its + * authored `silo-subtitle:N` id alone, which a muxed track can never carry. + * The answer came back "not mounted" for the very track on screen, and the + * selection was routed to a server replan that re-extracted the same subtitle + * as a sidecar: new session, media-item swap, rebuffer, restore seek. + * + * The inventory row is the missing evidence: matching through it is the same + * mapping [tvMountedSubtitleIdentity] used to mint the identity in the first + * place, so the two directions can no longer disagree. Only an identity that is + * exactly some row's identity gets that fallback, and the row match still has + * to find a mounted track — a catalog-only row, a sidecar the player has not + * loaded and a burn-in row all still answer null and go on replanning. + */ +internal fun tvResolveMountedSubtitleTrack( + identity: SubtitleIdentity, + subtitleRows: List, + mounted: List, +): MountedSubtitleTrack? { + resolveMountedSubtitle(identity = identity, tracks = mounted)?.let { return it.track } + val row = identity.tvInventoryRow(subtitleRows) ?: return null + return resolveMountedSubtitle(subtitle = row, tracks = mounted)?.track +} - val external = source == "external" || - catalogSource == "external" || - source == "server_artifact" || - subtitle.url.isNotBlank() - val mountableBitmapArtifact = subtitle.url.isNotBlank() && - isClientMountableBitmapCodecFamily(media.codecFamily) - return if ( - external && - isBitmapSubtitleCodecOrMime(media.codecFamily) && - !mountableBitmapArtifact - ) { - SubtitleIdentity.ServerBurnIn(subtitle.index, media) - } else { - SubtitleIdentity.ServerSidecar(subtitle.index, media) +/** The inventory row this identity was minted from, if it is exactly that row's. */ +private fun SubtitleIdentity.tvInventoryRow( + rows: List, +): PlayerSubtitleInfo? { + // Off and burn-in are never a mounted text track, and downloaded or + // player-discovered identities already carry their own exact Media3 id. + val serverIndex = when (this) { + is SubtitleIdentity.Embedded -> serverIndex + is SubtitleIdentity.ServerSidecar -> serverIndex + else -> return null } + return rows.firstOrNull { it.index == serverIndex && tvSubtitleIdentity(it) == this } } internal fun tvSubtitleIdentity(track: PlayerTrackEntry): SubtitleIdentity = diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt index 0672b1ea6..5356ab7ef 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt @@ -2,21 +2,21 @@ package org.prairieserver.prairie.tv.ui.screens.player import org.prairieserver.prairie.common.player.SubDiag import org.prairieserver.prairie.common.player.MountedSubtitleTrack -import org.prairieserver.prairie.common.player.downloadedSubtitleArtifactTrackId -import org.prairieserver.prairie.common.player.resolveMountedSubtitle import org.prairieserver.prairie.common.player.trackIdDenotes import org.prairieserver.prairie.common.player.subtitleArtifactTrackId +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId /** - * Who asked for a subtitle mount, ordered by authority. + * Why a subtitle mount was asked for, ordered by authority. * - * TV runs two mount pipelines at once: the subtitle transaction, and the legacy - * restore/auto machinery that reacts to track changes. Both drive a single - * remount latch and a single request channel, so without an explicit ordering - * the last writer wins — and because a transaction's own replan republishes the - * track list, the legacy pipeline reliably fires *after* the transaction and - * overwrites the selection the user just made. + * Every mount now originates in the subtitle transaction adapter — the legacy + * ordinal pipeline that raced it is gone — but the adapter still serves three + * kinds of intent, and a weaker one arriving late must not evict a stronger one + * that is still being applied. That collision is what used to turn an applied + * subtitle back off: a rollback armed the pre-transaction identity (typically + * Off) over the selection the viewer had just made. * * Higher ordinal wins. */ @@ -37,6 +37,21 @@ internal data class TvSubtitleRemountOwner( val priority: TvSubtitleMountPriority = TvSubtitleMountPriority.UserTransaction, ) +/** + * A mount the screen must apply to the player backend, carrying its owner. + * + * The owner travels WITH the request rather than sitting in a ViewModel field + * the acknowledgement reads back: that field was the reason an app-originated + * selection could be applied to the player and then dropped on the floor, + * because whoever emitted the request had armed no owner and the + * acknowledgement silently returned. An ownerless mount is now unrepresentable. + */ +internal data class TvSubtitleMountRequest( + val owner: TvSubtitleRemountOwner, + /** Media3 flat text-track ordinal, or -1 to disable text entirely. */ + val trackIndex: Int, +) + internal class TvSubtitleSnapshotSettlementTracker { private var previousKey: String? = null @@ -160,8 +175,16 @@ internal class SubtitleRemountReselection( meaningfulSnapshotKeys.clear() } + /** + * @param subtitleRows the authoritative inventory rows, needed to resolve a + * server-row identity whose track is muxed into the stream rather than + * mounted as an authored artifact (see [tvResolveMountedSubtitleTrack]). + * Without them such an identity resolves to no ordinal at all, and a mount + * the adapter committed locally would fail and roll back. + */ fun consume( subtitleTracks: List, + subtitleRows: List = emptyList(), snapshotKey: String?, settled: Boolean, ): TvSubtitleRemountEvent? { @@ -180,21 +203,33 @@ internal class SubtitleRemountReselection( val mounted = subtitleTracks.map(PlayerTrackEntry::toMountedTvSubtitleTrack) val exactTrackId = owner.identity.exactTvMountTrackId() - val matchIndex = if (exactTrackId != null) { + val matchIndex = exactTrackId?.let { expected -> // Every candidate here denotes the SAME authored artifact id, so // multiple hits are the one sidecar merged more than once (Media3 // prefixes each with its MergingMediaSource child index, e.g. - // "1:prairie-subtitle:3" and "2:prairie-subtitle:3"). That is not the + // "1:silo-subtitle:3" and "2:silo-subtitle:3"). That is not the // ambiguity this guard exists for — it cannot select the wrong // language — so refusing on it left the mount unresolved until the // deadline blew and the transaction rolled back to Off. Ambiguity // between genuinely different tracks is still caught by the // metadata path below and by hasAmbiguousTvLabel. - mounted.filter { trackIdDenotes(it.trackId, exactTrackId) } + mounted.filter { trackIdDenotes(it.trackId, expected) } .minByOrNull { it.index } ?.index - } else { - resolveMountedSubtitle(identity = owner.identity, tracks = mounted)?.track?.index + } ?: when { + // An identity carrying a REAL Media3 id stays exact-only: falling + // back to metadata could mount a different track that merely looks + // alike. A sidecar's id is authored by us, not by the stream, and a + // v3 row describing a track muxed into a direct-play stream is + // typed as a sidecar all the same — so that id matches nothing and + // the mount hung until the deadline. Only that case may resolve + // through the row (see tvResolveMountedSubtitleTrack). + exactTrackId != null && owner.identity !is SubtitleIdentity.ServerSidecar -> null + else -> tvResolveMountedSubtitleTrack( + identity = owner.identity, + subtitleRows = subtitleRows, + mounted = mounted, + )?.index } SubDiag.log("REMOUNT consume id=${owner.identity} exact=$exactTrackId mounted=${mounted.map { it.trackId }} settled=$settled match=$matchIndex") if (matchIndex != null) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt index 8d63eb3cf..7ae462225 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt @@ -19,8 +19,6 @@ import org.prairieserver.prairie.common.player.PlaybackSessionLifecycle import org.prairieserver.prairie.common.player.PlaybackTrackSelectionWriteCoordinator import org.prairieserver.prairie.common.player.StagedVideoReplan import org.prairieserver.prairie.common.player.VideoSessionStartV3 -import org.prairieserver.prairie.common.player.downloadedSubtitleArtifactTrackId -import org.prairieserver.prairie.common.player.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.common.player.SubDiag import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.playback.ClientCodecCapabilities @@ -39,9 +37,13 @@ import org.prairieserver.prairie.model.playback.SubtitleTransitionEvent import org.prairieserver.prairie.model.playback.SubtitleTransitionState import org.prairieserver.prairie.model.playback.UpdateAudioPreference import org.prairieserver.prairie.model.playback.UpdateQualityPreference +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle import org.prairieserver.prairie.model.playback.rebaseDownloadedSubtitleUrl import org.prairieserver.prairie.model.playback.reduceSubtitleTransition +import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired import org.prairieserver.prairie.repository.port.PlaybackWriteScope internal data class TvSubtitlePlaybackContext( @@ -103,7 +105,11 @@ internal data class TvSubtitleManagerStageInput( ) internal fun TvSubtitleStageRequest.toManagerStageInput(): ApiResult { - if (clientPlaybackContext.output.outputRouteGeneration != outputRouteGeneration) { + // The contract's output context token is opaque to the server; this client + // mints it from the route generation it is tracking here, so an equality + // check against the stringified generation is the same staleness test the + // server performs. + if (clientPlaybackContext.output.outputContextId != outputRouteGeneration.toString()) { return ApiResult.Error( code = 409, error = "stale_output_route_context", @@ -127,6 +133,8 @@ internal data class TvStagedSubtitleCandidate( val subtitleMode: PlaybackSubtitleModeV3, val hasSidecar: Boolean, val subtitleTracks: List, + val effectiveMediaFileId: Int? = null, + val selectedSubtitleIdentity: SubtitleIdentity? = null, val qualityPreference: String? = null, val outputRouteGeneration: Long = 0L, internal val managerHandle: StagedVideoReplan? = null, @@ -136,6 +144,7 @@ internal data class TvSubtitleCommittedPlayback( val sessionId: String, val subtitleTracks: List, val ready: VideoSessionStartV3.Ready? = null, + val effectiveMediaFileId: Int? = null, val outputRouteGeneration: Long = 0L, ) @@ -220,6 +229,7 @@ internal enum class TvSubtitleAdoptionResult { internal class TvSubtitlePlaybackAdoption internal constructor( val playback: TvSubtitleCommittedPlayback, val committed: CommittedSubtitle, + val requestedSourcePositionSeconds: Double, private val currentOwner: () -> Boolean, private val currentPendingIdentity: () -> SubtitleIdentity?, ) { @@ -615,6 +625,30 @@ internal class TvSubtitleTransactionAdapter( mutate(SelectSubtitle(identity), explicit = true) } + /** + * Applies an APP-DERIVED automatic selection — the launch-time language / + * mode / forced heuristics — through the same commit path as [select], so + * the adapter stays the single owner of subtitle selection and the HUD's + * committed identity always describes what is actually mounted. + * + * Not [select] for two reasons: an automatic pick must not cancel an + * in-flight subtitle refresh (only an explicit intent bumps the refresh + * generation), and it is not the viewer choosing, so the caller keeps it + * out of the durable per-item preference (see + * `TvPlayerViewModel.autoSelectedSubtitleIdentity`). + * + * A no-op when the identity is already committed and nothing is in flight: + * re-selecting what is already on would arm a pointless remount. + */ + fun selectAuto(identity: SubtitleIdentity) { + if (identity == transition.committed.identity && !hasActiveTransaction) { + SubDiag.log("ADAPTER selectAuto NOOP $identity") + return + } + SubDiag.log("ADAPTER selectAuto $identity") + mutate(SelectSubtitle(identity), explicit = false) + } + /** * Restores a saved fresh-load preference without declaring it committed * before both the server replan and the player backend have accepted it. @@ -817,7 +851,11 @@ internal class TvSubtitleTransactionAdapter( fun restoreCommittedLocalMount() { val identity = transition.committed.identity - if (context?.sessionId != null && identity.requiresLocalMountConfirmation()) { + if ( + context?.sessionId != null && + identity.requiresLocalMountConfirmation() && + isLocallyMountable(identity) + ) { beginLocalRestore(identity) } } @@ -868,24 +906,17 @@ internal class TvSubtitleTransactionAdapter( ): Boolean { if (!ownsRefresh(owner)) return false val current = context ?: return false - val retained = current.subtitleTracks.filterNot(PlayerSubtitleInfo::isDownloadedTvRow) - val rebased = subtitleTracks.map { row -> - if (row.isDownloadedTvRow() && owner.sessionId != null) { - row.copy(url = rebaseDownloadedSubtitleUrl(row.url, owner.sessionId)) - } else { - row - } - } - context = current.copy(subtitleTracks = retained + rebased) + // Callers provide the complete authoritative list. Never strip and + // rebuild downloaded rows: V3 owns their ordinals, track IDs, delivery + // modes, and session-scoped URLs. + context = current.copy(subtitleTracks = subtitleTracks) subtitleRefreshNonce += 1 publish() val selectedRow = autoSelectDownloadId - ?.let { id -> rebased.filter { it.downloadId == id }.singleOrNull() } + ?.let { id -> subtitleTracks.filter { it.downloadId == id }.singleOrNull() } if (selectedRow != null) { - tvDownloadedRefreshIdentity(selectedRow)?.let { identity -> - mutate(SelectSubtitle(identity), explicit = false) - } + mutate(SelectSubtitle(tvSubtitleIdentity(selectedRow)), explicit = false) } return true } @@ -1198,6 +1229,7 @@ internal class TvSubtitleTransactionAdapter( ) { val validationFailure = candidate.validationFailure( requested = requested, + requestedMediaFileId = request.mediaFileId, expectedSubtitleIndex = request.subtitleTrackIndex, expectedOutputRouteGeneration = request.outputRouteGeneration, ) @@ -1218,6 +1250,11 @@ internal class TvSubtitleTransactionAdapter( discardCandidateBestEffort(candidate) return } + val validatedState = candidate.authoritativeValidatedState( + requested = requested, + requestedMediaFileId = request.mediaFileId, + validated = validated.state, + ) commitInFlight = true val commitResult = withContext(NonCancellable) { @@ -1235,7 +1272,7 @@ internal class TvSubtitleTransactionAdapter( if (resetDuringCommit) { val owner = installCommittedPublicationOwner( requested = requested, - validatedState = validated.state, + validatedState = validatedState, playback = committed.data, adoptionContext = stagingContext, rollbackIncludesLifecycle = false, @@ -1277,7 +1314,8 @@ internal class TvSubtitleTransactionAdapter( val ownerGeneration = adoptionGeneration val adoption = TvSubtitlePlaybackAdoption( playback = playback, - committed = validated.state.committed, + committed = validatedState.committed, + requestedSourcePositionSeconds = adoptionContext.positionSeconds, currentOwner = { ownerGeneration == adoptionGeneration && !resetDuringCommit @@ -1307,7 +1345,7 @@ internal class TvSubtitleTransactionAdapter( AdoptionOutcome.Adopted -> finishSuccessfulAdoption( requested = requested, requestedGeneration = requested.generation, - validatedState = validated.state, + validatedState = validatedState, playback = playback, adoptionContext = adoptionContext, ) @@ -1317,7 +1355,7 @@ internal class TvSubtitleTransactionAdapter( } else { retainFailedAdoptionPublication( requested = requested, - validatedState = validated.state, + validatedState = validatedState, playback = playback, adoptionContext = adoptionContext, ) @@ -1331,7 +1369,7 @@ internal class TvSubtitleTransactionAdapter( } else { retainFailedAdoptionPublication( requested = requested, - validatedState = validated.state, + validatedState = validatedState, playback = playback, adoptionContext = adoptionContext, ) @@ -1414,6 +1452,10 @@ internal class TvSubtitleTransactionAdapter( ?: adoptionContext val rollbackContext = adoptionContext.withLatestPlanningEvidence(liveContext) context = liveContext.copy( + mediaFileId = playback.effectiveMediaFileId ?: liveContext.mediaFileId, + versionId = playback.effectiveMediaFileId + ?.let { "adapted:$it" } + ?: liveContext.versionId, sessionId = playback.sessionId, subtitleTracks = playback.subtitleTracks, audioTrackIndex = validatedState.committed.audioTrackIndex, @@ -1475,6 +1517,10 @@ internal class TvSubtitleTransactionAdapter( ?: adoptionContext val rollbackContext = adoptionContext.withLatestPlanningEvidence(liveContext) context = liveContext.copy( + mediaFileId = playback.effectiveMediaFileId ?: liveContext.mediaFileId, + versionId = playback.effectiveMediaFileId + ?.let { "adapted:$it" } + ?: liveContext.versionId, sessionId = playback.sessionId, subtitleTracks = playback.subtitleTracks, audioTrackIndex = validatedState.committed.audioTrackIndex, @@ -1937,7 +1983,7 @@ internal class TvSubtitleTransactionAdapter( private fun stageCompensatingRestore(owner: PendingLocalSelection) { val priorState = owner.rollbackState val priorIdentity = priorState.committed.identity - if (priorIdentity.isClientOwnedSubtitle()) { + if (priorIdentity.isClientOwnedSubtitle() && isLocallyMountable(priorIdentity)) { transition = priorState beginLocalRestore(priorIdentity) return @@ -2085,6 +2131,7 @@ internal class TvSubtitleTransactionAdapter( if ( failedLocalOwner?.mountedBeforeAdoption == true && priorIdentity.requiresLocalMountConfirmation() && + isLocallyMountable(priorIdentity) && context?.sessionId != null ) { beginLocalRestore(priorIdentity) @@ -2309,12 +2356,20 @@ internal class PlaybackSessionManagerTvSubtitleStagedReplanPort( id = handle.candidateSessionId, sessionId = handle.candidateSessionId, selectedAudioIndex = ready.plan.selectedTracks.audio?.index, - selectedSubtitleIndex = ready.plan.selectedTracks.subtitle?.index, + selectedSubtitleIndex = ready.plan.resolvedSelectedSubtitleIndex(), subtitleMode = ready.plan.subtitle.mode, hasSidecar = ready.plan.subtitle.artifact?.url?.isNotBlank() == true, subtitleTracks = ready.session.subtitleUrls.orEmpty(), + effectiveMediaFileId = ready.session.mediaFileId.takeIf { it > 0 } + ?: ready.plan.effectiveMediaFileId + ?: request.mediaFileId, + selectedSubtitleIdentity = ready.selectedTvSubtitleIdentity(), qualityPreference = request.qualityPreference, - outputRouteGeneration = handle.outputRouteGeneration, + // This is the local monotonic route generation captured + // by the stage request. The server's output_context_id + // is an opaque equality token and must never be parsed + // or used as a local counter. + outputRouteGeneration = input.outputRouteGeneration, managerHandle = handle, ), ) @@ -2343,6 +2398,8 @@ internal class PlaybackSessionManagerTvSubtitleStagedReplanPort( sessionId = result.data.session.sessionId, subtitleTracks = result.data.session.subtitleUrls.orEmpty(), ready = result.data, + effectiveMediaFileId = result.data.session.mediaFileId.takeIf { it > 0 } + ?: result.data.plan.effectiveMediaFileId, outputRouteGeneration = candidate.outputRouteGeneration, ), ) @@ -2397,7 +2454,8 @@ private fun SubtitleIdentity.serverTrackIndex(): Int = when (this) { } private fun SubtitleIdentity.requiresLocalMountConfirmation(): Boolean = - this is SubtitleIdentity.LocalMedia3 || + this is SubtitleIdentity.ServerSidecar || + this is SubtitleIdentity.LocalMedia3 || this is SubtitleIdentity.Downloaded || this is SubtitleIdentity.Embedded @@ -2409,6 +2467,7 @@ private fun SubtitleIdentity.isClientOwnedSubtitle(): Boolean = private fun TvStagedSubtitleCandidate.validationFailure( requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + requestedMediaFileId: Int, expectedSubtitleIndex: Int, expectedOutputRouteGeneration: Long, ): String? { @@ -2420,11 +2479,17 @@ private fun TvStagedSubtitleCandidate.validationFailure( ) { return "The candidate did not preserve the requested quality." } - if (requested.audioPreferenceSpecified && + val sameFile = effectiveMediaFileId == null || effectiveMediaFileId == requestedMediaFileId + if (sameFile && requested.audioPreferenceSpecified && selectedAudioIndex != requested.audioTrackIndex ) { return "The candidate did not select the requested audio track." } + if (!sameFile) { + val returnedIdentity = selectedSubtitleIdentity + ?: return "The adapted candidate omitted its selected subtitle identity." + return validationFailure(returnedIdentity) + } return when (requested.identity) { is SubtitleIdentity.Embedded, is SubtitleIdentity.Downloaded, @@ -2442,10 +2507,41 @@ private fun TvStagedSubtitleCandidate.validationFailure( } } +private fun TvStagedSubtitleCandidate.authoritativeValidatedState( + requested: org.prairieserver.prairie.model.playback.PendingSubtitle, + requestedMediaFileId: Int, + validated: SubtitleTransitionState, +): SubtitleTransitionState { + val sameFile = effectiveMediaFileId == null || effectiveMediaFileId == requestedMediaFileId + val committedIdentity = if (requested.identity.isClientOwnedSubtitle()) { + validated.committed.identity + } else { + selectedSubtitleIdentity ?: validated.committed.identity + } + return validated.copy( + committed = validated.committed.copy( + identity = committedIdentity, + audioTrackIndex = if (sameFile) { + validated.committed.audioTrackIndex + } else { + selectedAudioIndex ?: validated.committed.audioTrackIndex + }, + ), + ) +} + +private fun VideoSessionStartV3.Ready.selectedTvSubtitleIdentity(): SubtitleIdentity? { + val selected = plan.selectedTracks.subtitle ?: return SubtitleIdentity.Off + return session.subtitleUrls.orEmpty() + .singleOrNull { row -> + row.serverTrackId == selected.id && + (selected.index == null || row.index == selected.index) + } + ?.let(::tvSubtitleIdentity) +} + private fun PlayerSubtitleInfo.isDownloadedTvRow(): Boolean = - downloadId != null || - source.equals("downloaded", ignoreCase = true) || - catalogSource.equals("downloaded", ignoreCase = true) + isLocalDownloadedSubtitle() private fun PlayerSubtitleInfo.toDownloadedTvIdentity(): SubtitleIdentity.Downloaded { val id = requireNotNull(downloadId) @@ -2502,36 +2598,37 @@ private fun TvStagedSubtitleCandidate.validationFailure( private fun TvSubtitleCommittedPlayback.withRebasedDownloads( oldContext: TvSubtitlePlaybackContext, ): TvSubtitleCommittedPlayback { - val downloadedPredicate: (PlayerSubtitleInfo) -> Boolean = { - it.downloadId != null || it.source.equals("downloaded", ignoreCase = true) + val downloadedPredicate: (PlayerSubtitleInfo) -> Boolean = + PlayerSubtitleInfo::isLocalDownloadedSubtitle + val downloaded = if (effectiveMediaFileId == null || effectiveMediaFileId == oldContext.mediaFileId) { + oldContext.subtitleTracks + .filter(downloadedPredicate) + .map { track -> + track.copy(url = rebaseDownloadedSubtitleUrl(track.url, sessionId)) + } + } else { + emptyList() } - val downloaded = oldContext.subtitleTracks - .filter(downloadedPredicate) - .map { track -> - track.copy(url = rebaseDownloadedSubtitleUrl(track.url, sessionId)) - } - val candidateByIndex = subtitleTracks + val oldByIndex = oldContext.subtitleTracks .filterNot(downloadedPredicate) .associateBy(PlayerSubtitleInfo::index) - val retainedCatalog = oldContext.subtitleTracks + val authoritative = subtitleTracks .filterNot(downloadedPredicate) - .map { old -> - candidateByIndex[old.index]?.let { candidate -> - candidate.copy( - language = candidate.language ?: old.language, - codec = candidate.codec ?: old.codec, - label = candidate.label ?: old.label, - forced = candidate.forced ?: old.forced, - catalogLabel = old.catalogLabel ?: candidate.catalogLabel, - catalogSource = old.catalogSource ?: candidate.catalogSource, - isDefault = old.isDefault ?: candidate.isDefault, - ) - } ?: old.copy(url = "") + .distinctBy(PlayerSubtitleInfo::index) + .map { candidate -> + val old = oldByIndex[candidate.index] ?: return@map candidate + candidate.copy( + language = candidate.language ?: old.language, + codec = candidate.codec ?: old.codec, + label = candidate.label ?: old.label, + forced = candidate.forced ?: old.forced, + catalogLabel = old.catalogLabel ?: candidate.catalogLabel, + catalogSource = old.catalogSource ?: candidate.catalogSource, + isDefault = old.isDefault ?: candidate.isDefault, + ) } - val retainedIndexes = retainedCatalog.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) - val additionalCandidates = subtitleTracks.filterNot(downloadedPredicate) - .filterNot { it.index in retainedIndexes } + val authoritativeIndexes = authoritative.mapTo(mutableSetOf(), PlayerSubtitleInfo::index) return copy( - subtitleTracks = retainedCatalog + additionalCandidates + downloaded, + subtitleTracks = authoritative + downloaded.filterNot { it.index in authoritativeIndexes }, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt index a150866e2..d6e94d047 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt @@ -11,14 +11,27 @@ import org.prairieserver.prairie.common.player.video.VideoPlaybackStartRequest import org.prairieserver.prairie.common.player.video.VideoPlaybackStartResult import org.prairieserver.prairie.common.player.video.VideoPlaybackStarter import org.prairieserver.prairie.common.player.video.PlaybackDiagnosticsCode +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.ResolvedEpisodeSelection +import org.prairieserver.prairie.common.player.video.resolveEpisodeSourceIntent +import org.prairieserver.prairie.common.player.video.EpisodeAudioCandidate +import org.prairieserver.prairie.common.player.video.EpisodeAudioIntent +import org.prairieserver.prairie.common.player.video.resolveEpisodeAudioIntent +import org.prairieserver.prairie.common.player.video.resolveEpisodeSubtitleIntent import org.prairieserver.prairie.common.player.video.resolvedPlaybackDelivery import org.prairieserver.prairie.common.player.video.shouldReachServerForPlayback import org.prairieserver.prairie.common.settings.PlayerSettingsStore import org.prairieserver.prairie.common.settings.dolbyVisionPolicySnapshot +import org.prairieserver.prairie.model.catalog.FileVersion import org.prairieserver.prairie.model.playback.applyResumeRewind import org.prairieserver.prairie.model.playback.buildPlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.enrichAuthoritativePlaybackSubtitleChoices +import org.prairieserver.prairie.model.playback.isExplicitStartOver import org.prairieserver.prairie.model.playback.resolvePlaybackStartRequestPosition +import org.prairieserver.prairie.model.playback.resolvePlaybackStartPosition import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.orNullIfBlank import org.prairieserver.prairie.playback.selectPlaybackVersion import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.ProfileRepository @@ -71,13 +84,21 @@ class TvVideoPlaybackStarter( val preferredQuality = request.preferredQualityOverride ?: playerSettingsStore.preferredQualityFlow.first() val playbackQualityIntent = request.playbackQualityIntent ?: preferredQuality - val version = request.preferredFileId - ?.let { id -> watchDetail.versions.firstOrNull { it.fileId == id } } - ?: selectPlaybackVersion( - watchDetail.versions, - watchDetail.userData?.lastFileId, - preferredQuality, - ) + val resolvedEpisodeSelection = resolveTvPlaybackStartSelection( + preferredFileId = request.preferredFileId, + episodeSelectionHandoff = request.episodeSelectionHandoff, + targetVersions = watchDetail.versions, + targetLastFileId = watchDetail.userData?.lastFileId, + preferredQuality = preferredQuality, + ) + val version = watchDetail.versions.first { it.fileId == resolvedEpisodeSelection.fileId } + // The server rejects -1, while the Ready result retains it for the + // client-side Media3 selection that represents explicit Off. + val serverSubtitleTrackIndex = resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff = request.episodeSelectionHandoff, + resolvedEpisodeSelection = resolvedEpisodeSelection, + requestedSubtitleTrackIndex = request.subtitleTrackIndex, + ) val activeProfile = profileRepository.getActiveProfile() val profileId = activeProfile?.id ?: profileRepository.getActiveProfileId() ?: return failure( @@ -95,12 +116,15 @@ class TvVideoPlaybackStarter( ) val dolbyVision = playerSettingsStore.dolbyVisionPolicySnapshot() - val capabilities = capabilityDetector.detect(dolbyVision = dolbyVision) - val playbackContext = capabilityDetector.detectPlaybackContext( - formFactor = "tv", - appVersion = BuildConfig.VERSION_NAME, - dolbyVision = dolbyVision, - ) + val capabilities = request.recoveryStartParams?.capabilities + ?: capabilityDetector.detect(dolbyVision = dolbyVision) + val playbackContext = request.recoveryStartParams?.clientPlaybackContext + ?: capabilityDetector.detectPlaybackContext( + formFactor = "tv", + appVersion = BuildConfig.VERSION_NAME, + dolbyVision = dolbyVision, + capabilities = capabilities, + ) // Skip-back-on-resume — see MobileVideoPlaybackStarter for the rationale. // Suppressed for Start Over / retry (request flag) and Watch Together // (roomId); the one rewound value drives both the server seek and the @@ -129,10 +153,21 @@ class TvVideoPlaybackStarter( profileId = profileId, capabilities = capabilities, clientPlaybackContext = playbackContext, - audioTrackIndex = request.audioTrackIndex, - subtitleTrackIndex = request.subtitleTrackIndex, + // An explicit request wins; otherwise the audio the viewer + // chose in the previous episode travels here. Without this + // the carry-over resolved a track and then threw it away, + // and a dubbed household was returned to the server default + // at every automatic transition. + audioTrackIndex = request.audioTrackIndex + ?: resolvedEpisodeSelection.audioTrackIndex, + subtitleTrackIndex = serverSubtitleTrackIndex, qualityPreference = playbackQualityIntent, startPosition = startRequestPosition, + // The bandwidth half of the quality choice. The server + // applies the cap only from what the request carries, so + // sending the resolution alone lets a capped preset stream + // at the bandwidth the user explicitly declined. + maxBitrateKbps = playerSettingsStore.maxBitrateKbpsFlow.first(), deferPublication = true, ) ) { @@ -175,27 +210,43 @@ class TvVideoPlaybackStarter( // The server may reanchor an HLS stream at a non-zero movie time // while exposing a player timeline that begins at zero. Preserve // both coordinates so Media3 and the UI each receive the right one. - val playerStartPos = readyV3.plan.timeline.playerStartSeconds + val serverPlayerStartPos = readyV3.plan.timeline.playerStartSeconds .takeIf { it.isFinite() && it >= 0.0 } ?: resolved.position.coerceAtLeast(0.0) - val sourceStartPos = readyV3.plan.timeline.sourceStartSeconds + val playerStartPos = resolvePlaybackStartPosition( + // A reanchored stream may legitimately expose player time 0 + // for a non-zero source time. Only Start Over's explicit zero + // may override that server-defined player coordinate. + overridePosition = request.resumePositionOverride + .takeIf(::isExplicitStartOver), + sessionPosition = serverPlayerStartPos, + detailPosition = null, + ) + val serverSourceStartPos = readyV3.plan.timeline.sourceStartSeconds .takeIf { it.isFinite() && it >= 0.0 } - ?: startRequestPosition - ?: playerStartPos + ?: resolved.position.coerceAtLeast(0.0) + val sourceStartPos = resolveTvSourceStartPosition( + startRequestPosition = startRequestPosition, + serverSourceStartPosition = serverSourceStartPos, + playerStartPosition = playerStartPos, + ) val adopted = sessionLifecycle.adoptActiveSessionIfCurrent( params = StartParams( contentId = request.contentId, fileId = effectiveFileId, - capabilities = capabilities, + capabilities = readyV3.capabilities, audioTrackIndex = resolved.audioTrackIndex, - subtitleTrackIndex = request.subtitleTrackIndex, + subtitleTrackIndex = if (request.episodeSelectionHandoff != null) { + serverSubtitleTrackIndex + } else { + request.subtitleTrackIndex + }, qualityPreference = playbackQualityIntent, startPosition = sourceStartPos, - clientPlaybackContext = playbackContext, + clientPlaybackContext = readyV3.clientPlaybackContext, ), session = resolved, - renewMissingSessionWithLegacyStart = false, deferPublication = true, expectedOwnershipEpoch = ownershipEpoch, ) @@ -223,32 +274,41 @@ class TvVideoPlaybackStarter( container = readyV3.plan.stream.container ?: effectiveVersion?.container, title = watchDetail.title, subtitle = null, - artworkUrl = watchDetail.posterUrl?.takeIf { it.isNotBlank() } - ?: watchDetail.backdropUrl?.takeIf { it.isNotBlank() }, + artworkUrl = watchDetail.backdropUrl?.takeIf { it.isNotBlank() } + ?: watchDetail.posterUrl?.takeIf { it.isNotBlank() }, startPositionSeconds = playerStartPos, sourceStartPositionSeconds = sourceStartPos, serverUrl = serverUrl, accessToken = accessToken, mediaFileId = effectiveFileId, - durationSeconds = resolved.durationSeconds ?: effectiveVersion?.duration ?: 0.0, - subtitleUrls = buildPlaybackSubtitleChoices( + // Protocol v3 source duration is authoritative. Unknown stays + // unknown; catalog/player runtimes must not fill this field. + durationSeconds = resolved.durationSeconds, + subtitleUrls = enrichAuthoritativePlaybackSubtitleChoices( catalogTracks = effectiveVersion?.subtitleTracks.orEmpty(), plannedTracks = resolved.subtitleUrls.orEmpty(), ), preferredAudioLanguage = preferredAudioLanguage ?: activeProfile?.language, - preferredTextLanguage = watchDetail.effectiveSubtitleLanguage - ?: activeProfile?.subtitleLanguage, - preferredSubtitleMode = watchDetail.effectiveSubtitleMode - ?: activeProfile?.subtitleMode, + // Blank normalizes to null on every rung: a canonical row + // holding JSON null ("no preference") arrives here as a + // present-but-empty string, and TV auto-selection reads a + // non-null blank language as an explicit "subtitles off". + preferredTextLanguage = watchDetail.effectiveSubtitleLanguage.orNullIfBlank() + ?: activeProfile?.subtitleLanguage.orNullIfBlank(), + preferredSubtitleMode = watchDetail.effectiveSubtitleMode.orNullIfBlank() + ?: activeProfile?.subtitleMode.orNullIfBlank(), showForcedSubtitles = watchDetail.effectiveShowForcedSubtitles ?: activeProfile?.showForcedSubtitles ?: true, intro = watchDetail.intro, credits = watchDetail.credits, + recap = watchDetail.recap, + preview = watchDetail.preview, chapters = effectiveVersion?.chapters.orEmpty(), seriesId = watchDetail.seriesId, seasonNumber = watchDetail.seasonNumber, episodeNumber = watchDetail.episodeNumber, + resolvedEpisodeSelection = resolvedEpisodeSelection, ) } catch (e: CancellationException) { throw e @@ -278,3 +338,82 @@ class TvVideoPlaybackStarter( const val TAG = "TvVideoPlaybackStarter" } } + +internal fun resolveTvSourceStartPosition( + startRequestPosition: Double?, + serverSourceStartPosition: Double, + playerStartPosition: Double, +): Double = resolvePlaybackStartPosition( + overridePosition = startRequestPosition, + sessionPosition = serverSourceStartPosition, + detailPosition = playerStartPosition, +) + +/** + * Resolves session-only episode intent after the target detail is available. + * + * The target subtitle list depends on the chosen version, so source precedence + * is decided first; the existing shared source/subtitle resolvers then provide + * the semantic matching without duplicating their policy here. + */ +fun resolveTvPlaybackStartSelection( + preferredFileId: Int?, + episodeSelectionHandoff: EpisodeSelectionHandoff?, + targetVersions: List, + targetLastFileId: Int?, + preferredQuality: String?, +): ResolvedEpisodeSelection { + require(targetVersions.isNotEmpty()) { "targetVersions must not be empty" } + + val semanticFileId = resolveEpisodeSourceIntent( + intent = episodeSelectionHandoff?.source, + targetVersions = targetVersions, + ) + val selectedVersion = preferredFileId + ?.let { preferredId -> targetVersions.firstOrNull { it.fileId == preferredId } } + ?: semanticFileId + ?.let { handoffFileId -> targetVersions.firstOrNull { it.fileId == handoffFileId } } + ?: selectPlaybackVersion(targetVersions, targetLastFileId, preferredQuality) + val resolvedSubtitle = resolveEpisodeSubtitleIntent( + intent = episodeSelectionHandoff?.subtitle ?: EpisodeSubtitleIntent.auto(), + targetSubtitles = buildPlaybackSubtitleChoices( + catalogTracks = selectedVersion.subtitleTracks.orEmpty(), + plannedTracks = emptyList(), + ), + ) + // Audio travels the same way subtitles do — by what the track IS, not where + // it sat. The next episode's list is a different list, so a remembered + // position would land on whatever happens to occupy it. + val resolvedAudioIndex = resolveEpisodeAudioIntent( + intent = episodeSelectionHandoff?.audio ?: EpisodeAudioIntent.auto(), + // The list position IS the address. Audio tracks carry no index on the + // wire (subtitles do), so AudioTrack.index is its 0 default on every + // row: building candidates from it collapsed every one of them to 0. + candidates = selectedVersion.audioTracks.orEmpty().mapIndexed { ordinal, track -> + EpisodeAudioCandidate( + index = ordinal, + language = track.language, + codecFamily = track.codec, + channelCount = track.channels?.takeIf { it > 0 }, + title = track.title, + ) + }, + ) + return ResolvedEpisodeSelection( + fileId = selectedVersion.fileId, + subtitleTrackIndex = resolvedSubtitle.trackIndex, + subtitleIntentSpecified = resolvedSubtitle.intentSpecified, + audioTrackIndex = resolvedAudioIndex, + ) +} + +/** Converts the client-side selection to the server's non-negative index contract. */ +fun resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff: EpisodeSelectionHandoff?, + resolvedEpisodeSelection: ResolvedEpisodeSelection, + requestedSubtitleTrackIndex: Int?, +): Int? = if (episodeSelectionHandoff != null) { + resolvedEpisodeSelection.subtitleTrackIndex?.takeIf { it >= 0 } +} else { + requestedSubtitleTrackIndex?.takeIf { it >= 0 } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt index 20328e476..dff897acf 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileScreen.kt @@ -41,7 +41,7 @@ fun TvCreateProfileScreen( maxContentRating = state.maxContentRating, pinEnabled = state.pinEnabled, pin = state.pin, - qualityPreference = state.qualityPreference, + qualityPreference = null, subtitleMode = state.subtitleMode, pinHelper = "4-Digit PIN", submitLabel = "Create Profile", @@ -58,7 +58,7 @@ fun TvCreateProfileScreen( onContentRatingSelected = viewModel::onContentRatingSelected, onPinToggled = viewModel::onPinToggled, onPinChanged = viewModel::onPinChanged, - onQualitySelected = viewModel::onQualitySelected, + onQualitySelected = {}, onSubtitleModeSelected = viewModel::onSubtitleModeSelected, onSubmit = viewModel::onCreateClick, onCancel = onNavigateBack, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt index 92a4c23f0..532a2d796 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvCreateProfileViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.CreateProfileRequest import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.profile.canonicalProfileQualityPreference import org.prairieserver.prairie.model.profile.hasProfileNamed import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.ProfileRepository @@ -28,7 +27,6 @@ data class TvCreateProfileUiState( val maxContentRating: String? = null, val pinEnabled: Boolean = false, val pin: String = "", - val qualityPreference: String? = null, val subtitleMode: String? = null, val isLoading: Boolean = false, val error: String? = null, @@ -117,12 +115,6 @@ class TvCreateProfileViewModel( _uiState.update { it.copy(pin = filtered, error = null) } } - fun onQualitySelected(quality: String) { - _uiState.update { - it.copy(qualityPreference = canonicalProfileQualityPreference(quality)) - } - } - fun onSubtitleModeSelected(mode: String) { _uiState.update { // Send the explicit "off" wire value rather than null so an "Off" @@ -156,7 +148,6 @@ class TvCreateProfileViewModel( pin = if (current.pinEnabled) current.pin else null, isChild = if (current.isChild) true else null, maxContentRating = current.maxContentRating, - qualityPreference = current.qualityPreference, subtitleMode = current.subtitleMode, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileScreen.kt index 7ae75d8cd..a33a18db3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileScreen.kt @@ -52,6 +52,7 @@ fun TvEditProfileScreen( subtitle = "Pick a look and update this profile.", name = state.name, selectedAvatar = state.selectedAvatar, + selectedAvatarUrl = state.selectedAvatarUrl, avatarStyleId = state.avatarStyleId, selectedAvatarSeed = state.selectedAvatarSeed, avatarBatch = state.avatarBatch, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileViewModel.kt index bfc3ca6a9..74e9b626f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvEditProfileViewModel.kt @@ -23,6 +23,8 @@ data class TvEditProfileUiState( val profileId: String = "", val name: String = "", val selectedAvatar: String? = null, + /** Server-supplied URL for the loaded [selectedAvatar]; see TvProfileFormState. */ + val selectedAvatarUrl: String? = null, val avatarStyleId: String = TvProfileAvatarPresets.DefaultStyleId, val selectedAvatarSeed: String? = null, val avatarBatch: Int = 0, @@ -77,6 +79,7 @@ class TvEditProfileViewModel( isLoading = false, name = profile.name, selectedAvatar = profile.avatar, + selectedAvatarUrl = profile.avatarUrl, avatarStyleId = preset?.styleId ?: TvProfileAvatarPresets.DefaultStyleId, selectedAvatarSeed = preset?.seed, isChild = profile.isChild, @@ -113,7 +116,7 @@ class TvEditProfileViewModel( } fun onAvatarSelected(emoji: String) { - _uiState.update { it.copy(selectedAvatar = emoji) } + _uiState.update { it.copy(selectedAvatar = emoji, selectedAvatarUrl = null) } } fun onAvatarStyleSelected(styleId: String) { @@ -121,6 +124,7 @@ class TvEditProfileViewModel( it.copy( avatarStyleId = styleId, selectedAvatar = null, + selectedAvatarUrl = null, selectedAvatarSeed = null, avatarBatch = 0, ) @@ -132,6 +136,7 @@ class TvEditProfileViewModel( it.copy( avatarStyleId = preset.styleId, selectedAvatar = preset.ref, + selectedAvatarUrl = null, selectedAvatarSeed = preset.seed, ) } @@ -142,6 +147,7 @@ class TvEditProfileViewModel( it.copy( avatarBatch = it.avatarBatch + 1, selectedAvatar = null, + selectedAvatarUrl = null, selectedAvatarSeed = null, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileForm.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileForm.kt index ee26be312..9ee041fca 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileForm.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileForm.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,12 +17,13 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChildCare @@ -64,15 +64,16 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import coil3.compose.AsyncImage import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef +import org.prairieserver.prairie.common.ui.components.isEmojiAvatar import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill import org.prairieserver.prairie.tv.ui.components.TvPillVariant import org.prairieserver.prairie.tv.ui.components.TvTextInputDialog +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport private val ProfileEditorHeaderPadding = 80.dp private val ProfileEditorContentPadding = 80.dp @@ -103,6 +104,12 @@ data class TvProfileFormState( val subtitle: String = "Pick a look and give it a name.", val name: String, val selectedAvatar: String?, + /** + * Server-supplied URL for [selectedAvatar] as it was LOADED, so an uploaded + * avatar shows in the preview tile. Null for create, and ignored once the + * picker moves the selection off the stored ref. + */ + val selectedAvatarUrl: String? = null, val avatarStyleId: String, val selectedAvatarSeed: String?, val avatarBatch: Int, @@ -284,7 +291,7 @@ fun TvProfileForm( event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown ) { - runCatching { nameFocusRequester.requestFocus() } + nameFocusRequester.claimFocusOrReport(target = "profile_name", action = "dpad_down") true } else { false @@ -319,7 +326,7 @@ fun TvProfileForm( event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown ) { - runCatching { pinFocusRequester.requestFocus() } + pinFocusRequester.claimFocusOrReport(target = "profile_pin", action = "dpad_down") true } else { false @@ -362,7 +369,7 @@ fun TvProfileForm( event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown ) { - runCatching { childFocusRequester.requestFocus() } + childFocusRequester.claimFocusOrReport(target = "profile_child", action = "dpad_down") true } else { false @@ -486,7 +493,7 @@ private fun TvProfileFormSection( @Composable private fun TvProfilePreviewColumn( - avatar: String?, + avatar: ProfileAvatarRef, name: String, hasPin: Boolean, isChild: Boolean, @@ -516,20 +523,15 @@ private fun TvProfilePreviewColumn( @Composable private fun TvProfileTilePreview( - avatar: String?, + avatar: ProfileAvatarRef, name: String, hasPin: Boolean, isChild: Boolean, ) { - val serverUrl = rememberProfileServerUrl() val avatarText = remember(avatar, name) { profileAvatarDisplayText(avatar, name) } - val avatarUrl = remember(avatar, serverUrl) { - avatar - ?.takeIf(::isImageAvatar) - ?.let { resolveAvatarUrl(serverUrl, it) ?: resolveAvatarUrl("", it) } - } + val avatarImage = rememberProfileAvatarImage(avatar) val shape = RoundedCornerShape(18.dp) - val tint = remember(name, avatar) { profilePreviewTint("$name-$avatar") } + val tint = remember(name, avatar) { profilePreviewTint("$name-${avatar.avatar}") } Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( @@ -539,14 +541,16 @@ private fun TvProfileTilePreview( .border(1.dp, Color.White.copy(alpha = 0.14f), shape), contentAlignment = Alignment.Center, ) { - if (avatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = avatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) Box( modifier = Modifier @@ -561,7 +565,7 @@ private fun TvProfileTilePreview( } else { Text( text = avatarText, - fontSize = if (!avatar.isNullOrBlank() && !isImageAvatar(avatar)) 70.sp else 60.sp, + fontSize = if (isEmojiAvatar(avatar)) 70.sp else 60.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.94f), ) @@ -886,14 +890,19 @@ private fun TvProfileBadge(icon: androidx.compose.ui.graphics.vector.ImageVector } } -private fun TvProfileFormState.previewAvatarRef(): String? = - TvProfileAvatarPresets.effectiveAvatarRef( +private fun TvProfileFormState.previewAvatarRef(): ProfileAvatarRef { + val ref = TvProfileAvatarPresets.effectiveAvatarRef( styleId = avatarStyleId, selectedSeed = selectedAvatarSeed, batch = avatarBatch, name = name, fallbackAvatar = selectedAvatar, ) + // The stored URL describes the stored ref only. Once the picker has moved + // the preview onto a preset, pairing it with the old URL would show the + // upload while the form is about to save something else. + return ProfileAvatarRef(ref, selectedAvatarUrl?.takeIf { ref == selectedAvatar }) +} private fun profilePreviewTint(key: String): Color { val palette = listOf( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt index a8cbb477b..d786c07e3 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileFormOptions.kt @@ -15,33 +15,6 @@ val TV_QUALITY_OPTIONS = listOf("Auto", "4K", "1080p", "720p", "480p") /** Available subtitle modes. "Off" maps to a null mode. */ val TV_SUBTITLE_MODES = listOf("Off", "Default", "Always", "Forced Only") -/** - * Pre-defined emoji avatars. Mirrors `AvatarOptions.emojis` on the phone so the - * picker offers the same set; the server stores the chosen string verbatim. - */ -val TV_AVATAR_EMOJIS = listOf( - "😀", // grinning face - "😎", // smiling face with sunglasses - "🤓", // nerd face - "🥸", // disguised face - "👾", // alien monster - "🐱", // cat face - "🐶", // dog face - "🦊", // fox - "🦁", // lion - "🐻", // bear - "🐧", // penguin - "🦉", // owl - "🌟", // glowing star - "🌈", // rainbow - "🎨", // artist palette - "🎬", // clapper board - "🎵", // musical note - "🚀", // rocket - "🌍", // globe - "🍓", // strawberry -) - /** Display label for a stored subtitle-mode value (e.g. "forced_only" -> "Forced Only"). */ fun subtitleModeLabel(stored: String?): String = stored?.replace("_", " ")?.split(" ") diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt index 3ad4c4334..b4735fa5a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionScreen.kt @@ -64,11 +64,20 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.components.isEmojiAvatar import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText -import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.model.profile.Profile +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateOf +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvFocusTargetState +import org.prairieserver.prairie.tv.ui.focus.tvProfileFocusTarget +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts import org.prairieserver.prairie.tv.ui.components.TvAuroraBackdrop import org.prairieserver.prairie.tv.ui.components.AuroraJourneyProgress import org.prairieserver.prairie.tv.ui.components.TvAuroraVariant @@ -81,7 +90,7 @@ import org.prairieserver.prairie.tv.ui.components.TvPillVariant import org.prairieserver.prairie.tv.ui.components.TvPinEntryDialog import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.navRailLabel -import org.prairieserver.prairie.tv.ui.theme.prairieCardDefaults +import org.prairieserver.prairie.tv.ui.theme.siloCardDefaults import org.koin.compose.viewmodel.koinViewModel private const val ProfileGridColumns = 4 @@ -91,9 +100,9 @@ private val AddProfilePlusSize = 44.dp private val AddProfilePlusStrokeWidth = 2.dp private val ProfileUtilityChromeTop = 40.dp private val ProfileUtilityChromeEnd = 64.dp -private val ProfileUtilityChangeServerWidth = 164.dp -private val ProfileUtilityManageWidth = 104.dp -private val ProfileUtilitySignOutWidth = 100.dp +// The utility pills size to their own labels. Fixed widths clipped them: +// "Sign Out" rendered as "Sign" at 100dp, and the label a control shows is the +// one thing about it that cannot be allowed to be wrong. private val ProfileUtilityChipHeight = 28.dp private val ProfileHeaderTop = 92.dp private val ProfileGridWidth = 772.dp @@ -155,7 +164,6 @@ fun TvProfileSelectionScreen( label = if (state.isManageMode) "Done" else "Manage", icon = Icons.Filled.Edit, variant = TvPillVariant.Hollow, - modifier = Modifier.width(ProfileUtilityManageWidth), heightOverride = ProfileUtilityChipHeight, horizontalPaddingOverride = 10.dp, labelStyle = MaterialTheme.typography.labelMedium, @@ -165,7 +173,6 @@ fun TvProfileSelectionScreen( label = "Change Server", icon = Icons.Filled.Dns, variant = TvPillVariant.Hollow, - modifier = Modifier.width(ProfileUtilityChangeServerWidth), heightOverride = ProfileUtilityChipHeight, horizontalPaddingOverride = 10.dp, labelStyle = MaterialTheme.typography.labelMedium, @@ -175,7 +182,6 @@ fun TvProfileSelectionScreen( label = "Sign Out", icon = Icons.Filled.Logout, variant = TvPillVariant.Hollow, - modifier = Modifier.width(ProfileUtilitySignOutWidth), heightOverride = ProfileUtilityChipHeight, horizontalPaddingOverride = 10.dp, labelStyle = MaterialTheme.typography.labelMedium, @@ -188,21 +194,14 @@ fun TvProfileSelectionScreen( modifier = Modifier .fillMaxWidth() .align(Alignment.TopCenter) - // The journey row adds 40dp above the legacy title band; - // offset that addition so the title/grid keep their - // established vertical footprint and utility chips stay - // isolated in the top chrome. + // No journey row here any more, so the title band sits + // at its own top inset rather than offsetting one. .padding( - top = ProfileHeaderTop - 40.dp, + top = ProfileHeaderTop, start = Spacing.safeArea, end = Spacing.safeArea, ), ) { - AuroraJourneyProgress( - currentStep = 3, - modifier = Modifier.width(230.dp), - ) - Spacer(modifier = Modifier.height(16.dp)) Text( text = "Who's watching?", style = MaterialTheme.typography.displayLarge, @@ -217,15 +216,60 @@ fun TvProfileSelectionScreen( Spacer(modifier = Modifier.height(32.dp)) - val firstCardFocus = remember { FocusRequester() } - LaunchedEffect(state.profiles) { - if (state.profiles.isNotEmpty()) { - runCatching { firstCardFocus.requestFocus() } - } + // The screen reloads on every resume, so re-anchoring on + // the first tile overrode the viewer's position on every + // return. Keep a requester per profile ID and move focus + // only where tvProfileFocusTarget says it belongs. + val tileFocusRequesters = remember { mutableMapOf() } + var focusedProfileId by remember { mutableStateOf(null) } + var previousProfileIds by remember { mutableStateOf(emptyList()) } + var hasAnchored by remember { mutableStateOf(false) } + val profileIds = state.profiles.map { it.id } + + LaunchedEffect(profileIds) { + val target = tvProfileFocusTarget( + previousIds = previousProfileIds, + currentIds = profileIds, + focusedId = focusedProfileId, + hasMaterialized = hasAnchored, + ) + previousProfileIds = profileIds + // Requesters are keyed by ID, so a deleted profile's + // would otherwise be retained for the screen's lifetime. + tileFocusRequesters.keys.retainAll(profileIds.toSet()) + if (target == null) return@LaunchedEffect + // Placement can trail the data by a frame or two. + val result = requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { + tileFocusRequesters[target]?.requestFocus() ?: false + }, + isFocused = { focusedProfileId == target }, + targetState = { + // The viewer moved to another tile themselves + // while we were retrying. Stop chasing rather + // than fight them for the rest of the budget. + val focused = focusedProfileId + if (focused != null && focused != target) { + TvFocusTargetState.Disposed + } else { + TvFocusTargetState.Ready + } + }, + ) + // Only a landed anchor counts. Setting this up front + // meant a second list arriving mid-retry cancelled the + // first pass and left the replacement believing the + // screen was already anchored, so it never anchored. + if (result == TvObservedFocusResult.Focused) hasAnchored = true } ProfileTileGrid( profiles = state.profiles, - firstCardFocus = firstCardFocus, + focusRequesterFor = { id -> + tileFocusRequesters.getOrPut(id) { FocusRequester() } + }, + onProfileFocused = { focusedProfileId = it }, isManageMode = state.isManageMode, onProfileSelected = viewModel::onProfileSelected, onEditProfile = { onEditProfile(it.id) }, @@ -251,7 +295,7 @@ fun TvProfileSelectionScreen( if (pinProfile != null) { TvPinEntryDialog( profileName = pinProfile.name, - profileAvatar = pinProfile.avatar, + profileAvatar = pinProfile.avatarRef(), errorMessage = state.pinError, isVerifying = state.isVerifyingPin, onPinEntered = viewModel::onPinEntered, @@ -285,7 +329,10 @@ fun TvProfileSelectionScreen( @Composable private fun ProfileTileGrid( profiles: List, - firstCardFocus: FocusRequester, + focusRequesterFor: (String) -> FocusRequester, + // Null means no profile tile owns focus — the Add tile has it, or focus + // left the grid entirely. Restoration must not re-request a tile then. + onProfileFocused: (String?) -> Unit, isManageMode: Boolean, onProfileSelected: (Profile) -> Unit, onEditProfile: (Profile) -> Unit, @@ -295,7 +342,9 @@ private fun ProfileTileGrid( val itemCount = profiles.size + 1 val rowCount = (itemCount + ProfileGridColumns - 1) / ProfileGridColumns Column( - modifier = Modifier.width(ProfileGridWidth), + modifier = Modifier + .width(ProfileGridWidth) + .onFocusChanged { if (!it.hasFocus) onProfileFocused(null) }, verticalArrangement = Arrangement.spacedBy(56.dp), ) { repeat(rowCount) { rowIndex -> @@ -326,14 +375,19 @@ private fun ProfileTileGrid( } }, onDelete = { onDeleteProfile(profile) }, - modifier = if (itemIndex == 0) { - Modifier.focusRequester(firstCardFocus) - } else { - Modifier - }, + modifier = Modifier + .focusRequester(focusRequesterFor(profile.id)) + .onFocusChanged { + if (it.isFocused) onProfileFocused(profile.id) + }, ) } - itemIndex == profiles.size -> TvAddProfileCard(onClick = onAddProfile) + itemIndex == profiles.size -> TvAddProfileCard( + onClick = onAddProfile, + modifier = Modifier.onFocusChanged { + if (it.isFocused) onProfileFocused(null) + }, + ) } } } @@ -351,18 +405,14 @@ private fun TvProfileCard( onDelete: () -> Unit, modifier: Modifier = Modifier, ) { - val serverUrl = rememberProfileServerUrl() - val avatarText = remember(profile.avatar, profile.name) { - profileAvatarDisplayText(profile.avatar, profile.name) - } - val avatarUrl = remember(profile.avatar, serverUrl) { - profile.avatar - ?.takeIf(::isImageAvatar) - ?.let { resolveAvatarUrl(serverUrl, it) } + val avatar = profile.avatarRef() + val avatarText = remember(avatar, profile.name) { + profileAvatarDisplayText(avatar, profile.name) } + val avatarImage = rememberProfileAvatarImage(avatar) val shape = RoundedCornerShape(ProfileTileCornerRadius) - val cardFocus = prairieCardDefaults(shape = shape) + val cardFocus = siloCardDefaults(shape = shape) val tileTint = profile.tintColor() // Per-profile tinted focus halo ("this profile is alive"), mirroring tvOS // ProfileTile's colored glow. @@ -401,14 +451,16 @@ private fun TvProfileCard( ), contentAlignment = Alignment.Center, ) { - if (avatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = avatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = profile.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) Box( modifier = Modifier @@ -423,7 +475,7 @@ private fun TvProfileCard( } else { Text( text = avatarText, - fontSize = if (!profile.avatar.isNullOrBlank() && !isImageAvatar(profile.avatar)) { + fontSize = if (isEmojiAvatar(avatar)) { 70.sp } else { 60.sp @@ -491,14 +543,14 @@ private fun TvProfileCard( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvAddProfileCard(onClick: () -> Unit) { +private fun TvAddProfileCard(onClick: () -> Unit, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(ProfileTileCornerRadius) val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val surfaceAlpha = if (isFocused) 0.14f else 0.06f val strokeAlpha = if (isFocused) 0.70f else 0.28f val plusAlpha = if (isFocused) 1.0f else 0.60f - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Surface( onClick = onClick, interactionSource = interactionSource, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt index a8da4e2d7..e27850363 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt @@ -3,7 +3,10 @@ package org.prairieserver.prairie.tv.ui.screens.profiles import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.model.profile.authorizedProfileToken import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.repository.ProfileCommitResult import org.prairieserver.prairie.repository.ProfileRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,15 +42,49 @@ class TvProfileSelectionViewModel( private val _uiState = MutableStateFlow(TvProfileSelectionUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Monotonic generation for PIN verification; see [onPinEntered]. */ + private var pinAttempt: Int = 0 + + /** Monotonic generation for profile-list loads; see [loadProfiles]. */ + private var loadAttempt: Int = 0 + init { loadProfiles() } + /** + * The identity the displayed grid was fetched under. + * + * Every selection is qualified with it, protected or not. Passing null for + * unprotected picks disabled the guard for exactly the case with no PIN + * round trip to re-establish scope — so if another account became active + * between the grid being accepted and the commit entering the barrier, one + * account's profile id was written into the other's token slot. + */ + private var gridScope: AuthScopeSnapshot? = null + fun loadProfiles() { + val load = ++loadAttempt viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - when (val result = profileRepository.listProfiles()) { + val scope = profileRepository.captureIdentityScope() + val listed = profileRepository.listProfiles() + // Two separate reasons to drop this response: a newer load + // superseded it, or the identity it was fetched under is gone. + if (load != loadAttempt) return@launch + if (!profileRepository.identityScopeUnchanged(scope)) { + // The displayed grid is gone, so its scope must go with it. + gridScope = null + _uiState.update { it.copy(isLoading = false, profiles = emptyList()) } + return@launch + } + when (val result = listed) { is ApiResult.Success -> { + // The scope moves with the grid, and ONLY with it. See the + // phone ViewModel: assigning it before the result meant a + // failed reload under a NEW identity left the OLD grid + // qualified by the NEW scope. + gridScope = scope _uiState.update { it.copy(isLoading = false, profiles = result.data) } @@ -89,6 +126,16 @@ class TvProfileSelectionViewModel( // Manage-mode taps open edit, handled by the screen composable. return } + // Focus/Select can already be dispatched when a scope mismatch clears + // the grid. A null gridScope deliberately means no grid metadata, but + // it also disables the repository guard, so reject cards that are no + // longer part of the accepted grid. + if (_uiState.value.profiles.none { it.id == profile.id }) return + // Bump before branching: ANY accepted selection supersedes a + // verification still in flight, including choosing an unprotected + // profile while a protected one is mid-verify. + pinAttempt++ + if (profile.hasPin) { // Open the PIN dialog; actual selection happens in onPinEntered. _uiState.update { @@ -96,10 +143,15 @@ class TvProfileSelectionViewModel( } return } - commitSelection(profile) + // Qualified by the grid's scope. An unprotected pick has no PIN round + // trip to re-establish identity, so without this it was the one path + // that committed unguarded. + commitSelection(profile, expectedScope = gridScope) } fun onPinDialogDismissed() { + // Abandon any in-flight verification for the dismissed profile. + pinAttempt++ _uiState.update { it.copy(pinProfile = null, pinError = null, isVerifyingPin = false) } @@ -107,16 +159,28 @@ class TvProfileSelectionViewModel( fun onPinEntered(pin: String) { val profile = _uiState.value.pinProfile ?: return + val attempt = ++pinAttempt _uiState.update { it.copy(isVerifyingPin = true, pinError = null) } viewModelScope.launch { - when (val r = profileRepository.verifyPin(profile.id, pin)) { + // Pin the answer to the identity that was asked. TV can install a + // temporary remote-playback identity mid-flight, and this profile's + // proof must never land in that overlay. + val scope = profileRepository.captureIdentityScope() + val r = profileRepository.verifyPin(profile.id, pin) + // Cancelling (or picking another profile) during the round trip + // must abandon this answer — otherwise Back still entered the + // profile, and on TV a "Change Server" mid-flight could land this + // profile's token on a different server. + if (attempt != pinAttempt) return@launch + + when (r) { is ApiResult.Success -> { // The server returns 200 with valid=false for a wrong PIN, so - // gate selection on .valid (matches phone) — never commit on a - // bare 200. - if (r.data.valid) { - // Repository stores the profile token and active profile. - commitSelection(profile) + // gate on the issued token (matches phone) — never commit on + // a bare 200, nor on a valid=true carrying no proof. + val token = r.data.authorizedProfileToken() + if (token != null) { + commitSelection(profile, token, scope) _uiState.update { it.copy(pinProfile = null, isVerifyingPin = false) } } else { _uiState.update { it.copy(isVerifyingPin = false, pinError = "Incorrect PIN") } @@ -138,9 +202,31 @@ class TvProfileSelectionViewModel( } } - private fun commitSelection(profile: Profile) { + private fun commitSelection( + profile: Profile, + profileToken: String? = null, + expectedScope: AuthScopeSnapshot? = null, + ) { viewModelScope.launch { - profileRepository.selectProfile(profile.id) + val result = profileRepository.selectProfile(profile.id, profileToken, expectedScope) + if (result == ProfileCommitResult.ScopeChanged) { + // Identity moved under us — don't route into this profile, and + // drop the grid with it. A retained grid keeps D-pad focus on + // profiles belonging to a session we no longer hold. + _uiState.update { + it.copy( + profiles = emptyList(), + selectedProfileId = null, + pinProfile = null, + isVerifyingPin = false, + pinError = null, + deleteCandidate = null, + isManageMode = false, + ) + } + loadProfiles() + return@launch + } _uiState.update { it.copy(selectedProfileId = profile.id) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt new file mode 100644 index 000000000..e9358f238 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt @@ -0,0 +1,56 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver + +enum class SavedListSelection { + Watchlist, + Favorites, +} + +data class TvForYouEntryRequest( + val sequence: Int = 0, + val selection: SavedListSelection? = null, +) { + fun next(selection: SavedListSelection?): TvForYouEntryRequest = + TvForYouEntryRequest(sequence = sequence + 1, selection = selection) + + fun nextForTopLevelForYou(): TvForYouEntryRequest = next(null) +} + +/** Saver for the shell's entry-request slot; see the shell for why it is saved. */ +val TvForYouEntryRequestSaver: Saver = listSaver( + save = { listOf(it.sequence, it.selection?.name ?: "") }, + restore = { saved -> + TvForYouEntryRequest( + sequence = saved[0] as Int, + selection = (saved[1] as String).takeIf { it.isNotEmpty() } + ?.let { SavedListSelection.valueOf(it) }, + ) + }, +) + +internal data class AppliedForYouSelection( + val selection: SavedListSelection?, + val lastAppliedSequence: Int, + val appliedRequest: Boolean, +) + +internal fun applyForYouEntryRequest( + currentSelection: SavedListSelection?, + lastAppliedSequence: Int, + request: TvForYouEntryRequest, +): AppliedForYouSelection = + if (request.sequence <= lastAppliedSequence) { + AppliedForYouSelection( + selection = currentSelection, + lastAppliedSequence = lastAppliedSequence, + appliedRequest = false, + ) + } else { + AppliedForYouSelection( + selection = request.selection, + lastAppliedSequence = request.sequence, + appliedRequest = true, + ) + } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt index aef85d1b1..66adf006f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt @@ -5,94 +5,170 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.TvMediaRow import org.prairieserver.prairie.tv.ui.components.TvRowStyle -import org.prairieserver.prairie.tv.ui.components.TvHeroActionPill -import org.prairieserver.prairie.tv.ui.components.TvPillVariant +import org.prairieserver.prairie.tv.ui.components.TvSkylineSectionFeed +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.screens.personal.TvFavoritesInline import org.prairieserver.prairie.tv.ui.screens.personal.TvWatchlistInline import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.prairieserver.prairie.viewmodel.RecommendationsViewModel -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.foundation.layout.width -private val RecommendationsFilterBandHeight = 52.dp +/** Saved-list grids arrive a page at a time; pace the claim to that, not to frames. */ +private const val SavedListFocusRetryDelayMillis = 60L + +/** Room for the fallback caption above the saved-list grid, when it is showing. */ +private val SavedListCaptionInset = 34.dp /** - * "For You" tab. Reuses the shared [RecommendationsViewModel] that drives - * the phone `/recommendations/discover` feed. Layout mirrors [TvHomeScreen] - * (rows down the page) minus the featured hero — the discover API returns - * section-style rows, not a hero card. + * "For You" tab. Reuses the shared [RecommendationsViewModel] that drives the + * phone `/recommendations/discover` feed, and renders it through the same + * `TvSkylineSectionFeed` as Home — focus marquee, ambient backdrop, row band — + * so the two landing surfaces stay identical. + * + * The list switch (For You / Watchlist / Favorites) lives in the top-menu + * dropdown, mirroring tvOS `.recommendations`; this screen only renders the + * selection it is handed through [entryRequest]. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvRecommendationsScreen( - onItemClick: (contentId: String) -> Unit, + onSavedListItemClick: (contentId: String) -> Unit, + onRecommendationItemClick: (contentId: String) -> Unit, onInitialContentFocus: () -> Unit = {}, focusRequest: Int = 0, + detailReturnFocusRequest: Int = 0, + detailReturnCardFocusRequester: FocusRequester? = null, + firstRowFocusRequester: FocusRequester? = null, + firstRowContainerFocusRequester: FocusRequester? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, + entryRequest: TvForYouEntryRequest = TvForYouEntryRequest(), viewModel: RecommendationsViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val visibleSections = remember(state.sections) { state.sections.visibleOnTv() } - val watchlistFocusRequester = remember { FocusRequester() } - var savedListSelection by remember { mutableStateOf(null) } + // rememberSaveable, not remember: opening an item disposes this screen's + // composition, and a plain remember would re-initialise from + // entryRequest.selection on the way back. Top-level For You entry carries + // selection = null, so returning from a Watchlist item did not merely + // forget the list — it actively reselected the recommendations feed. + // + // lastAppliedEntrySequence must survive with it. Resetting it to 0 makes + // the LaunchedEffect below treat the unchanged entry request as new and + // re-apply its selection, which reintroduces the same jump even once the + // selection itself is saved. + var savedListSelection by rememberSaveable { mutableStateOf(entryRequest.selection) } + // True only when the saved list is showing because recommendations came + // back empty (the auto-fallback below), not because the user picked + // Favorites/Watchlist from the dropdown. The explanatory caption is keyed + // on this rather than on "no visible sections", which is also true while + // the feed is still loading on first open. + var savedListIsFallback by rememberSaveable { mutableStateOf(false) } + var lastAppliedEntrySequence by rememberSaveable { mutableIntStateOf(0) } + val savedListFocusRequester = remember { FocusRequester() } + var forYouContentHasFocus by remember { mutableStateOf(false) } + // The Skyline feed already owns the menu→content entry move (band scrolled + // to the top, focus on row 0 / card 0). Picking "For You" in the dropdown + // while a saved list is showing is that same move, so add our own bumps to + // the shell's token rather than hand-rolling a second row-container hop. + var feedEntryFocusRequest by rememberSaveable { mutableIntStateOf(0) } + + suspend fun claimSavedListFocus() { + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { delay(SavedListFocusRetryDelayMillis) }, + requestFocus = savedListFocusRequester::requestFocus, + isFocused = { forYouContentHasFocus }, + ) + // Only report the handover once focus is confirmed: telling the shell + // content owns focus after a dropped claim leaves nothing focused. + if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() + } + + LaunchedEffect(entryRequest.sequence) { + val applied = applyForYouEntryRequest( + currentSelection = savedListSelection, + lastAppliedSequence = lastAppliedEntrySequence, + request = entryRequest, + ) + savedListSelection = applied.selection + lastAppliedEntrySequence = applied.lastAppliedSequence + if (!applied.appliedRequest) return@LaunchedEffect + savedListIsFallback = false + if (applied.selection == null) { + feedEntryFocusRequest++ + } else { + claimSavedListFocus() + } + } // Match tvOS: recommendations remain the landing content when available; // an empty successful response defaults to the inline Watchlist fallback. LaunchedEffect(state.isLoading, state.error, visibleSections) { if (!state.isLoading && state.error == null && visibleSections.isEmpty() && savedListSelection == null) { savedListSelection = SavedListSelection.Watchlist + savedListIsFallback = true + } else if (visibleSections.isNotEmpty()) { + savedListIsFallback = false } } - // The saved-list shortcuts are the stable first row in every state. Focus - // Watchlist once per entry, matching tvOS, without letting later refreshes - // pull focus away from the user's current position. - var initialFocusRequested by remember { mutableStateOf(false) } - var lastAppliedFocusRequest by remember { mutableStateOf(-1) } - LaunchedEffect(focusRequest) { - if (initialFocusRequested && focusRequest == lastAppliedFocusRequest) return@LaunchedEffect - runCatching { watchlistFocusRequester.requestFocus() } - onInitialContentFocus() - initialFocusRequested = true + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + + // Menu→content handover for the saved lists only; the feed answers the same + // token itself. Guarded so a later recomposition (or the return from a + // detail page, where the shell's restorer owns focus) cannot replay it. + var lastAppliedFocusRequest by rememberSaveable { mutableIntStateOf(-1) } + LaunchedEffect(focusRequest, savedListSelection) { + if (savedListSelection == null) return@LaunchedEffect + if (focusRequest == lastAppliedFocusRequest) return@LaunchedEffect lastAppliedFocusRequest = focusRequest + // The shell bumps its token for EVERY menu selection, and during the + // route crossfade this exiting screen is still composed — without this + // gate, selecting Home from a Watchlist/Favorites view let the saved + // list claim focus (its first card or Sort/Filter pill) instead of + // Home's first row. Same gate as TvSkylineSectionFeed: exiting nav + // entries fall to STARTED and never resume, so they park here until + // disposal with the token already consumed. + lifecycleOwner.lifecycle.currentStateFlow.first { it.isAtLeast(Lifecycle.State.RESUMED) } + claimSavedListFocus() } // TV has no pull-to-refresh, so ON_RESUME is the only quiet self-heal path. @@ -100,7 +176,6 @@ fun TvRecommendationsScreen( // an empty discover response otherwise leaves this tab a permanent dead end // until a profile switch/restart. If the feed is still the empty fallback // when the user returns (e.g. after watching and rating content), re-check. - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { @@ -114,19 +189,25 @@ fun TvRecommendationsScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - Box(modifier = Modifier.fillMaxSize()) { + val showFallbackCaption = savedListSelection != null && savedListIsFallback + val savedListTopInset = TvTopMenuLayout.contentTopInset + + if (showFallbackCaption) SavedListCaptionInset else 0.dp + + Box( + modifier = Modifier + .fillMaxSize() + .onFocusChanged { forYouContentHasFocus = it.hasFocus }, + ) { when { savedListSelection == SavedListSelection.Watchlist -> TvWatchlistInline( - onItemClick = onItemClick, - modifier = Modifier.padding( - top = TvTopMenuLayout.contentTopInset + RecommendationsFilterBandHeight, - ), + onItemClick = onSavedListItemClick, + firstItemFocusRequester = savedListFocusRequester, + modifier = Modifier.padding(top = savedListTopInset), ) savedListSelection == SavedListSelection.Favorites -> TvFavoritesInline( - onItemClick = onItemClick, - modifier = Modifier.padding( - top = TvTopMenuLayout.contentTopInset + RecommendationsFilterBandHeight, - ), + onItemClick = onSavedListItemClick, + firstItemFocusRequester = savedListFocusRequester, + modifier = Modifier.padding(top = savedListTopInset), ) state.isLoading && state.sections.isEmpty() -> TvLoadingScreen( modifier = Modifier.background(MaterialTheme.colorScheme.background), @@ -169,46 +250,32 @@ fun TvRecommendationsScreen( onClick = viewModel::loadRecommendations, contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp), ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Check again", style = MaterialTheme.typography.labelLarge) } } } } - else -> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.spacedBy(18.dp), - contentPadding = PaddingValues( - top = TvTopMenuLayout.contentTopInset + RecommendationsFilterBandHeight, - bottom = 24.dp, - ), - ) { - items( - items = visibleSections, - key = { it.id }, - contentType = { "recommendation-section-row" }, - ) { section -> - TvMediaRow( - title = section.title, - items = section.items, - onItemClick = onItemClick, - style = TvRowStyle.Poster, - ) - } - item { Spacer(modifier = Modifier.height(8.dp)) } - } - } + else -> TvSkylineSectionFeed( + surfaceKey = "for_you", + sections = visibleSections, + onItemClick = onRecommendationItemClick, + // Both tokens are monotonic, so their sum is too — which is all + // the feed's "did this request already apply" guard needs. + focusRequest = focusRequest + feedEntryFocusRequest, + detailReturnFocusRequest = detailReturnFocusRequest, + detailReturnCardFocusRequester = detailReturnCardFocusRequester, + firstRowFocusRequester = firstRowFocusRequester, + firstRowContainerRequester = firstRowContainerFocusRequester, + onInitialContentFocus = onInitialContentFocus, + onContentUpFallbackChanged = onContentUpFallbackChanged, + // Discover returns plain section rows: posters throughout, no + // progress bars, and the VM exposes no watched/favorite toggles. + styleForSection = { TvRowStyle.Poster }, + showProgressForSection = { false }, + ) } - if (savedListSelection != null && visibleSections.isEmpty()) { + if (showFallbackCaption) { Text( text = "No recommendations yet — showing your saved titles.", style = MaterialTheme.typography.labelSmall.copy( @@ -218,87 +285,9 @@ fun TvRecommendationsScreen( color = Color.White.copy(alpha = 0.75f), modifier = Modifier.padding( start = Spacing.safeArea, - top = TvTopMenuLayout.contentTopInset + 40.dp, + top = TvTopMenuLayout.contentTopInset, ), ) } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = TvTopMenuLayout.contentTopInset) - .height(RecommendationsFilterBandHeight) - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = Spacing.safeArea), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - TvHeroActionPill( - label = "For You", - icon = Icons.Outlined.AutoAwesome, - variant = TvPillVariant.Hollow, - selected = savedListSelection == null, - heightOverride = 32.dp, - horizontalPaddingOverride = 13.dp, - iconSizeOverride = 10.dp, - iconLabelSpacingOverride = 5.dp, - restBorderWidthOverride = 0.75.dp, - focusedBorderWidthOverride = 1.5.dp, - focusedScaleOverride = 1.045f, - focusedGlowElevationOverride = 9.dp, - labelStyle = MaterialTheme.typography.labelSmall.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.SemiBold, - ), - onClick = { savedListSelection = null }, - ) - TvHeroActionPill( - label = "Watchlist", - icon = Icons.Filled.Bookmark, - variant = TvPillVariant.Hollow, - selected = savedListSelection == SavedListSelection.Watchlist, - focusRequester = watchlistFocusRequester, - heightOverride = 32.dp, - horizontalPaddingOverride = 13.dp, - iconSizeOverride = 10.dp, - iconLabelSpacingOverride = 5.dp, - restBorderWidthOverride = 0.75.dp, - focusedBorderWidthOverride = 1.5.dp, - focusedScaleOverride = 1.045f, - focusedGlowElevationOverride = 9.dp, - labelStyle = MaterialTheme.typography.labelSmall.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.SemiBold, - ), - onClick = { savedListSelection = SavedListSelection.Watchlist }, - ) - TvHeroActionPill( - label = "Favorites", - icon = Icons.Filled.Favorite, - variant = TvPillVariant.Hollow, - selected = savedListSelection == SavedListSelection.Favorites, - heightOverride = 32.dp, - horizontalPaddingOverride = 13.dp, - iconSizeOverride = 10.dp, - iconLabelSpacingOverride = 5.dp, - restBorderWidthOverride = 0.75.dp, - focusedBorderWidthOverride = 1.5.dp, - focusedScaleOverride = 1.045f, - focusedGlowElevationOverride = 9.dp, - labelStyle = MaterialTheme.typography.labelSmall.copy( - fontSize = 14.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.SemiBold, - ), - onClick = { savedListSelection = SavedListSelection.Favorites }, - ) - } } } - -private enum class SavedListSelection { - Watchlist, - Favorites, -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt index 5d90d83c5..474aed2e4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvMyRequestsScreen.kt @@ -27,7 +27,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import org.prairieserver.prairie.tv.ui.focus.rememberTvFlatReturnRestoration import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -53,16 +57,43 @@ fun TvMyRequestsScreen( ) { val state by viewModel.uiState.collectAsState() val visibleRequests = state.requests.filterTvMediaRequests() - val firstItemFocusRequester = remember { FocusRequester() } - val firstRequestId = visibleRequests.firstOrNull()?.id - var initialFocusRequested by remember { mutableStateOf(false) } + val restoreItemFocusRequester = remember { FocusRequester() } + var attachedRequesterId by remember { mutableStateOf(null) } + val listState = rememberLazyListState() - LaunchedEffect(firstRequestId) { - if (initialFocusRequested || firstRequestId == null) return@LaunchedEffect - runCatching { firstItemFocusRequester.requestFocus() } - onInitialContentFocus() - initialFocusRequested = true - } + // Rows are identified by the REQUEST, not by what they open. A row with a + // library item opens that item while every other row opens the request + // detail, so two rows can share a navigation target; keying restoration on + // it would send focus to whichever came first. request.id is the row. + // + // Projected from the FILTERED list, because that is what is rendered — the + // view model's unfiltered list would put every index in a different + // coordinate space from the rows these indices address. + // + // Nothing here paginates, so hasMore is false and the page hunt never runs; + // the restoration is purely resolve, scroll, confirm. + val restoration = rememberTvFlatReturnRestoration( + itemIds = visibleRequests.map { it.id }, + hasMore = false, + isLoadingMore = false, + // Refresh here is a button rather than a resume hook, so it cannot + // collide with entry the way the personal lists' does — but it still + // REPLACES the list, and a viewer who presses it and opens a row + // before it lands would otherwise restore against the outgoing one. + isReplacingContent = state.isRefreshing, + errorMessage = state.error, + surfaceKey = "my-requests", + onLoadMore = {}, + scrollToItem = { itemIndex -> listState.scrollToItem(itemIndex + requestsHeaderSlots(state.error)) }, + requestFocus = restoreItemFocusRequester::requestFocus, + onRestored = onInitialContentFocus, + ) + + // No separate first-entry path. On a fresh arrival the restoration already + // targets row zero, so a second requester would race it — and worse, the + // one that ran first was UNATTACHED whenever restoration owned row zero, + // meaning it reported a content handoff it had not actually made. One + // claimant, and it only reports once focus is confirmed. Column( modifier = Modifier @@ -81,6 +112,7 @@ fun TvMyRequestsScreen( ) visibleRequests.isEmpty() -> EmptyMyRequests() else -> LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() .focusGroup(), @@ -101,9 +133,27 @@ fun TvMyRequestsScreen( } } itemsIndexed(visibleRequests, key = { _, request -> request.id }) { index, request -> + if (index == restoration.requesterItemIndex) { + DisposableEffect(request.id) { + attachedRequesterId = request.id + restoration.onRequesterAttached(request.id) + onDispose { + // Only if this row is still the owner. When the + // requester moves, the new row can attach + // before the old one disposes, and an + // unconditional clear would erase the live + // attachment and lose the restore. + if (attachedRequesterId == request.id) { + attachedRequesterId = null + restoration.onRequesterAttached(null) + } + } + } + } TvRequestListCard( request = request, onClick = { + restoration.onItemClicked(itemId = request.id, index = index) // In-library items open library detail; everything else // opens the request detail (phone parity — rows are always // actionable, not only when a library item exists). @@ -111,7 +161,18 @@ fun TvMyRequestsScreen( if (contentId != null) onOpenLibraryItem(contentId) else onOpenRequestDetail(request.mediaType, request.tmdbId) }, - focusRequester = firstItemFocusRequester.takeIf { index == 0 }, + focusRequester = restoreItemFocusRequester + .takeIf { index == restoration.requesterItemIndex }, + // hasFocus, not isFocused: the card's own Surface owns + // focus below this modifier, so isFocused never fires + // here and the restoration could never confirm. + modifier = Modifier.onFocusChanged { + if (it.hasFocus) { + restoration.onItemFocused(request.id, index) + } else { + restoration.onItemFocusLost(request.id) + } + }, trailing = { if (request.canCancel()) { TvRequestActionPill( @@ -195,3 +256,6 @@ private fun EmptyMyRequests() { } } } + +/** The error banner, when shown, is one list slot ahead of the request rows. */ +private fun requestsHeaderSlots(error: String?): Int = if (error != null) 1 else 0 diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt index ce61c00b4..e124db37e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/requests/TvRequestsScreen.kt @@ -36,11 +36,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -49,6 +51,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.model.request.CreateMediaRequest import org.prairieserver.prairie.model.request.RequestAvailability import org.prairieserver.prairie.model.request.RequestDiscoverySection @@ -58,20 +61,18 @@ import org.prairieserver.prairie.tv.ui.components.TvErrorScreen import org.prairieserver.prairie.tv.ui.components.TvFilterChip import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved import org.prairieserver.prairie.tv.ui.screens.search.TV_SEARCH_QUERY_MAX_LENGTH import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout -import org.prairieserver.prairie.tv.ui.theme.PrairieBlue import org.prairieserver.prairie.tv.ui.theme.DarkSurfaceElevated +import org.prairieserver.prairie.tv.ui.theme.PrairieBlue import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface import org.prairieserver.prairie.tv.ui.theme.Spacing import org.prairieserver.prairie.tv.ui.theme.sectionEyebrow import org.prairieserver.prairie.viewmodel.RequestSearchViewModel import org.prairieserver.prairie.viewmodel.RequestsViewModel -import org.koin.compose.viewmodel.koinViewModel -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.size -import androidx.tv.material3.Icon private val requestMediaFilters = listOf( RequestMediaType.All to "All", @@ -96,6 +97,18 @@ fun TvRequestsScreen( val visibleSearchResults = searchState.results.filterTvRequestResults() val visibleDiscoverSections = state.sections.filterTvRequestSections() val searchFieldFocusRequester = remember { FocusRequester() } + // Observed per REGION. requestFocusUntilObserved tests isFocused() before + // it requests anything, so a screen-wide flag meant the post-search claim + // was skipped outright: you are in the search field when the results land, + // the flag is already true, and focus never moves to them. + var focusedRegion by remember { mutableStateOf(null) } + val setFocusedRegion: (TvRequestsFocusRegion, Boolean) -> Unit = { region, focused -> + if (focused) { + focusedRegion = region + } else if (focusedRegion == region) { + focusedRegion = null + } + } val firstFilterChipFocusRequester = remember { FocusRequester() } val firstResultFocusRequester = remember { FocusRequester() } val hasSubmittedQuery = searchState.hasSubmittedQuery @@ -132,18 +145,36 @@ fun TvRequestsScreen( LaunchedEffect(searchFieldFocusRequester) { if (initialFocusRequested) return@LaunchedEffect - runCatching { searchFieldFocusRequester.requestFocus() } - onInitialContentFocus() + // Seventh false handover: onInitialContentFocus() told the shell content + // had focus regardless of whether the claim landed. + val landed = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = searchFieldFocusRequester::requestFocus, + isFocused = { focusedRegion == TvRequestsFocusRegion.Field }, + ) + if (landed == TvObservedFocusResult.Focused) onInitialContentFocus() initialFocusRequested = true } LaunchedEffect(focusResultsAfterSearch, searchState.isLoading, hasSearchResults) { if (focusResultsAfterSearch && !searchState.isLoading) { - if (hasSearchResults) { - runCatching { firstResultFocusRequester.requestFocus() } + val target = if (hasSearchResults) { + firstResultFocusRequester } else { - runCatching { firstFilterChipFocusRequester.requestFocus() } + firstFilterChipFocusRequester } + val targetRegion = if (hasSearchResults) { + TvRequestsFocusRegion.Results + } else { + TvRequestsFocusRegion.Chips + } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = target::requestFocus, + isFocused = { focusedRegion == targetRegion }, + ) focusResultsAfterSearch = false } } @@ -184,23 +215,11 @@ fun TvRequestsScreen( actionViewModel.submit(item.requestKey(), item.toCreateMediaRequest()) }, ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Request") } }, dismissButton = { Button(onClick = { pendingRequest = null }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) Text("Cancel") } }, @@ -227,6 +246,8 @@ fun TvRequestsScreen( firstFilterChipFocusRequester = firstFilterChipFocusRequester, firstResultFocusRequester = firstResultFocusRequester, hasFocusableResult = hasFocusableResult, + onFieldFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Field, it) }, + onChipsFocusChanged = { setFocusedRegion(TvRequestsFocusRegion.Chips, it) }, onQueryChanged = { query -> searchViewModel.onQueryChanged(query) }, onSearch = { focusResultsAfterSearch = searchState.query.isNotBlank() @@ -251,7 +272,11 @@ fun TvRequestsScreen( message = state.error ?: "Search movies and series to request them.", ) else -> LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onFocusChanged { + setFocusedRegion(TvRequestsFocusRegion.Results, it.hasFocus) + }, verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = PaddingValues(bottom = 56.dp), ) { @@ -392,6 +417,9 @@ private fun RequestSearchEmptyItem(message: String) { ) } +/** Focus regions a claim on this screen can aim at. */ +private enum class TvRequestsFocusRegion { Field, Chips, Results } + @Composable private fun RequestsHeader( query: String, @@ -402,6 +430,8 @@ private fun RequestsHeader( firstFilterChipFocusRequester: FocusRequester, firstResultFocusRequester: FocusRequester, hasFocusableResult: Boolean, + onFieldFocusChanged: (Boolean) -> Unit, + onChipsFocusChanged: (Boolean) -> Unit, onQueryChanged: (String) -> Unit, onSearch: () -> Unit, onMediaTypeChanged: (String?) -> Unit, @@ -462,11 +492,13 @@ private fun RequestsHeader( modifier = Modifier .fillMaxWidth() .height(48.dp) + .onFocusChanged { onFieldFocusChanged(it.isFocused) } .focusRequester(searchFieldFocusRequester) .focusProperties { down = firstFilterChipFocusRequester }, colors = tvOutlinedTextFieldColors(), ) LazyRow( + modifier = Modifier.onFocusChanged { onChipsFocusChanged(it.hasFocus) }, horizontalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(end = Spacing.xs), verticalAlignment = Alignment.CenterVertically, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjection.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjection.kt new file mode 100644 index 000000000..f50f5df63 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjection.kt @@ -0,0 +1,77 @@ +package org.prairieserver.prairie.tv.ui.screens.search + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.tv.ui.focus.TvReturnSection + +/** + * Search is the one surface where two different kinds of thing sit on the same + * screen, so the return target has to say WHICH. + * + * The catalog grid holds library items keyed by content id; the footer row + * holds requestable titles keyed by (mediaType, tmdbId). Those id spaces are + * unrelated, and a requestable title that is already in the library carries + * BOTH — it opens the library item while still being a request card. Left + * un-namespaced, a return could resolve a content id against the request row, + * or match the library twin of the request card the viewer actually opened and + * put focus in the wrong section entirely. + */ +internal const val TvSearchCatalogSectionId: String = "search-catalog" + +internal const val TvSearchRequestSectionId: String = "search-requests" + +/** Namespaced so a content id can never collide with a request id. */ +internal fun tvSearchCatalogItemId(contentId: String): String = "catalog:$contentId" + +/** + * Namespaced on the request's OWN identity, not on the library item it may + * open. Two request cards can point at the same library item; the card is what + * the viewer left. + * + * The media type is canonicalised first. The rendering pipeline already + * accepts case and whitespace variants, and treats "audiobooks" as + * "audiobook" — so the same result can arrive spelled differently across two + * responses. Encoding it raw would give one card two identities and quietly + * lose the return whenever the spelling changed under it. + */ +internal fun tvSearchRequestItemId(mediaType: String, tmdbId: Int): String = + "request:${canonicalTvRequestMediaType(mediaType)}:$tmdbId" + +private fun canonicalTvRequestMediaType(mediaType: String): String = + when (val normalized = mediaType.trim().lowercase()) { + "audiobooks" -> RequestMediaType.Audiobook + else -> normalized + } + +/** + * Sections in rendered order: the grid, then the footer row beneath it. + * + * [catalogComplete] is false while more pages can still arrive, which is what + * lets a target deeper than the loaded results wait rather than settle for a + * near miss. + * + * [requestsComplete] is not the same thing and cannot be assumed from "this + * row does not paginate". Request search CLEARS its results when a query + * starts and installs the response later, so there is a window where the row + * is empty and not yet answered. Resolving then would read absence as final + * and consume the target on a card that was about to come back. Modelled here + * rather than left to the driver, so it cannot be forgotten at the call site. + */ +internal fun tvSearchReturnSections( + catalogItems: List, + requestResults: List, + catalogComplete: Boolean, + requestsComplete: Boolean, +): List = listOf( + TvReturnSection( + id = TvSearchCatalogSectionId, + itemIds = catalogItems.map { tvSearchCatalogItemId(it.contentId) }, + isComplete = catalogComplete, + ), + TvReturnSection( + id = TvSearchRequestSectionId, + itemIds = requestResults.map { tvSearchRequestItemId(it.mediaType, it.tmdbId) }, + isComplete = requestsComplete, + ), +) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt index 9b282e4c3..65d440cd4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchScreen.kt @@ -22,6 +22,12 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.filled.Mic +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.Surface import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Icon as M3Icon @@ -32,19 +38,44 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshotFlow +import org.prairieserver.prairie.tv.ui.focus.TvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.TvReturnTargetSaver +import org.prairieserver.prairie.tv.ui.focus.TvReturnRelocation +import org.prairieserver.prairie.tv.ui.focus.TvReturnResolution +import org.prairieserver.prairie.tv.ui.focus.resolveTvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.TvFocusAcquisitionBudgetMillis +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.withTimeoutOrNull import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color +import androidx.activity.compose.BackHandler import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Button @@ -55,8 +86,10 @@ import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.request.RequestMediaResult import org.prairieserver.prairie.model.request.RequestMediaType +import org.prairieserver.prairie.tv.ui.components.TvHideStockImeOnDispose import org.prairieserver.prairie.tv.ui.components.TvCatalogGrid import org.prairieserver.prairie.tv.ui.components.TvFilterChip +import org.prairieserver.prairie.tv.ui.components.TvSectionHeader import org.prairieserver.prairie.tv.ui.components.tvOutlinedTextFieldColors import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestCard import org.prairieserver.prairie.tv.ui.screens.requests.canOpenLibraryDetail @@ -75,7 +108,7 @@ internal fun shouldFocusSearchField( explicitFieldRequest: Boolean, ): Boolean = explicitFieldRequest || (!hasEnteredSearch && !hasResults) -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Composable fun TvSearchScreen( onResultClick: (BrowseItem) -> Unit, @@ -93,14 +126,90 @@ fun TvSearchScreen( val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() val firstResultFocusRequester = remember { FocusRequester() } val firstRequestResultFocusRequester = remember { FocusRequester() } + // One requester per section, addressed by index rather than pinned to the + // first card. The two sections are separate focus containers, so a single + // shared requester could not name a position in both. + val restoreCatalogFocusRequester = remember { FocusRequester() } + val restoreRequestFocusRequester = remember { FocusRequester() } + + // Search is a root tab, like Calendar: "a target exists" cannot mean + // "returning", because browsing a result and re-selecting Search would look + // identical. The click is the signal, the lifecycle says when. + var returnTarget by rememberSaveable(stateSaver = TvReturnTargetSaver) { + mutableStateOf(null) + } + var returnPending by rememberSaveable { mutableStateOf(false) } + // One refresh per return, and a hard ceiling on how many times a return may + // stand down waiting for content. Both exist because this effect is keyed + // on state its own body changes. + var returnRefreshed by rememberSaveable { mutableStateOf(false) } + var returnStandDowns by remember { mutableIntStateOf(0) } + var resumeGeneration by remember { mutableIntStateOf(0) } + TvSearchResumeSignal { resumeGeneration++ } + var focusedReturnItemId by remember { mutableStateOf(null) } + var restoreCatalogIndex by remember { mutableIntStateOf(-1) } + var restoreRequestIndex by remember { mutableIntStateOf(-1) } + + var pendingSearchFocus by remember { mutableStateOf(false) } + var scrollHeaderIntoView by remember { mutableIntStateOf(0) } + + + val recordReturn: (String, String, Int, Int) -> Unit = { sectionId, itemId, sectionIndex, itemIndex -> + returnPending = true + returnRefreshed = false + returnStandDowns = 0 + // Consumed, not deferred. Merely holding the submit handoff back until + // the return finishes means it becomes eligible the moment restoration + // clears returnPending — and then steals focus off the card that was + // just restored. Opening something ends that handoff's claim outright. + pendingSearchFocus = false + returnTarget = TvReturnTarget( + sectionId = sectionId, + itemId = itemId, + sectionIndex = sectionIndex, + itemIndex = itemIndex, + ) + } val feedbackActionFocusRequester = remember { FocusRequester() } val firstFilterChipFocusRequester = remember { FocusRequester() } val internalSearchFieldFocusRequester = remember { FocusRequester() } val searchGridState = rememberLazyGridState() val activeSearchFieldFocusRequester = searchFieldFocusRequester ?: internalSearchFieldFocusRequester val keyboardController = LocalSoftwareKeyboardController.current - var pendingSearchFocus by remember { mutableStateOf(false) } var hasEnteredSearch by rememberSaveable { mutableStateOf(false) } + var isKeyboardOpen by remember { mutableStateOf(false) } + + // Back closes the keyboard rather than leaving the screen. + // + // Without this, Back from a raised keyboard fell through to the shell and + // popped Search entirely — so the only way to put the keyboard away was + // also the way out, and anyone reaching for the mic lost the screen instead. + BackHandler(enabled = isKeyboardOpen) { + isKeyboardOpen = false + keyboardController?.hide() + // Back from a raised keyboard must answer synchronously, so this is the + // single-shot claim; losing it silently would strand the viewer with + // the keyboard gone and nothing focused. + activeSearchFieldFocusRequester.claimFocusOrReport( + target = "search_field", + action = "keyboard_dismissed", + ) + } + // Which REGION holds focus. A screen-wide flag cannot answer the question + // these claims actually ask. requestFocusUntilObserved tests isFocused() + // before it ever calls requestFocus, so "something on the search screen has + // focus" made every claim below a no-op the moment the screen owned focus + // at all — which is always, once you are on it. Back from a result never + // returned to the field, and a submitted search never handed you its + // results, because in both cases the flag was already true. + var focusedRegion by remember { mutableStateOf(null) } + val setFocusedRegion: (TvSearchFocusRegion, Boolean) -> Unit = { region, focused -> + if (focused) { + focusedRegion = region + } else if (focusedRegion == region) { + focusedRegion = null + } + } val requestMediaType = state.mediaType.toRequestMediaType() val visibleRequestResults = requestState.results .filterTvRequestResults() @@ -113,22 +222,69 @@ fun TvSearchScreen( requestState.error != null || requestState.hasSubmittedQuery) val requestSearchSettled = !canSearchRequests || !requestState.isLoading + // Same precedence as the post-search handoff below: results, then the + // error's "Try again", then the request row. val firstContentFocusRequester = when { state.items.isNotEmpty() -> firstResultFocusRequester - visibleRequestResults.isNotEmpty() -> firstRequestResultFocusRequester state.error != null -> feedbackActionFocusRequester + visibleRequestResults.isNotEmpty() -> firstRequestResultFocusRequester else -> firstFilterChipFocusRequester } val hasContentFocusTarget = state.items.isNotEmpty() || visibleRequestResults.isNotEmpty() || state.error != null + // A spoken query is a submitted query. It goes through exactly the path a + // typed one does — including handing focus to the results afterwards, + // which is the whole point of speaking: nobody dictates a title in order + // to then be left on the search field. + var voiceUnavailableMessage by remember { mutableStateOf(null) } + val voiceSearch = rememberTvVoiceSearch( + prompt = "Speak a title", + onResult = { spoken -> + // The same cap typing obeys. A noisy recognition can run long, and + // the field's own limit does not apply to text that never went + // through it. + val query = spoken.take(TV_SEARCH_QUERY_MAX_LENGTH) + voiceUnavailableMessage = null + viewModel.onQueryChanged(query) + pendingSearchFocus = true + if (requestsEnabled && query.length >= 2) { + requestSearchViewModel.onMediaTypeChanged(requestMediaType) + requestSearchViewModel.onQueryChanged(query) + requestSearchViewModel.search() + } + viewModel.submitSearch() + }, + onUnavailable = { + voiceUnavailableMessage = "Voice search isn't available on this device." + }, + ) + LaunchedEffect(requestsEnabled, state.query, requestMediaType) { val query = state.query.trim() if (!requestsEnabled || query.length < 2) { requestSearchViewModel.onQueryChanged("") return@LaunchedEffect } + // Same query as the view model already answered. This effect re-runs on + // every re-entry, a Back out of a request detail included — and there + // the results are BOTH still on screen and genuinely stale, because + // creating a request in the detail changes the status these cards show. + // + // So refresh rather than skip, and refresh in place rather than through + // the ordinary path, which blanks the row before refetching: a viewer + // would watch it empty and refill, and a return restoration would lose + // the card it was aiming at partway through. + val alreadyAnswered = requestState.submittedQuery == query && + requestState.mediaType == requestMediaType && + !requestState.isLoading && + requestState.error == null + // Nothing to do: the answer on screen is for this exact query. Staleness + // after a return is handled by the restoration effect, which is the only + // place that knows a return happened — this effect is keyed on the query + // and cannot tell a re-entry from a recomposition. + if (alreadyAnswered) return@LaunchedEffect delay(300) requestSearchViewModel.onMediaTypeChanged(requestMediaType) requestSearchViewModel.onQueryChanged(query) @@ -138,44 +294,244 @@ fun TvSearchScreen( LaunchedEffect(activeSearchFieldFocusRequester) { val hasResults = state.items.isNotEmpty() || visibleRequestResults.isNotEmpty() if (shouldFocusSearchField(hasEnteredSearch, hasResults, explicitFieldRequest = false)) { - runCatching { activeSearchFieldFocusRequester.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = activeSearchFieldFocusRequester::requestFocus, + isFocused = { focusedRegion == TvSearchFocusRegion.Field }, + ) } hasEnteredSearch = true } - // The search field auto-shows the soft keyboard on focus, but nothing hid - // it when leaving Search — on Android TV the system IME then floats over - // the next screen (e.g. starting playback from a search result left the - // keyboard on top of the video). Dismiss it when Search leaves composition. - DisposableEffect(Unit) { - onDispose { runCatching { keyboardController?.hide() } } + // Return restoration. Deliberately separate from the pendingSearchFocus + // handoff above: that one belongs to an explicit search submission, and + // this screen goes out of its way NOT to jump focus when results merely + // appear, because doing so yanks the viewer out of text entry mid-query. + // A return is the one case where moving focus onto a result is what was + // asked for. + LaunchedEffect( + resumeGeneration, + state.isLoading, + state.isLoadingMore, + state.items, + visibleRequestResults, + requestSearchSettled, + ) { + // Deliberately NOT gated on requestSearchSettled. A catalog return has + // no reason to wait for the request row, and a request return is held + // by the section's own completeness below — which is what makes that + // flag mean something instead of being unreachable. + if (!returnPending || state.isLoading) return@LaunchedEffect + + // Opening a request detail can create a request, which changes the + // status these cards show — so a return is exactly when this row is + // stale, and it is stale whether or not the screen stayed composed. + // In place, so the cards a restoration is aiming at stay put. + // + // ONCE per return. Refreshing flips requestSearchSettled, which is a + // key of this very effect, so an unguarded call relaunches the effect + // and refreshes again — forever, whenever resolution does not finish + // on the first pass. + // + // Skipped while an error is showing: there the query-keyed effect owns + // recovery and runs a full search, and two refetches racing would have + // one cancel the other and blank the row underneath the restoration. + if (!returnRefreshed && + canSearchRequests && + requestState.hasSubmittedQuery && + !requestState.isLoading && + requestState.error == null + ) { + returnRefreshed = true + requestSearchViewModel.refreshInPlace() + // A request card must be resolved against the REFRESHED row. + // Preserved results still contain it, so it would otherwise match + // Exact, take focus and disarm before the response lands — and if + // that response drops the card, focus goes with it. Incomplete + // only yields Pending when the target is ABSENT, so completeness + // alone does not hold this back. A catalog target is unaffected by + // the request row and carries on immediately. + if (returnTarget?.sectionId == TvSearchRequestSectionId) return@LaunchedEffect + } + + val sections = tvSearchReturnSections( + catalogItems = state.items, + requestResults = visibleRequestResults, + // More pages can still arrive, so a target beyond the loaded + // results is not yet absent. + catalogComplete = !state.hasMore, + requestsComplete = requestSearchSettled, + ) + fun resolve(final: Boolean) = resolveTvReturnTarget( + target = returnTarget, + sections = sections, + // A library item and a requestable title are different things even + // when they are the same film, and namespacing already means an id + // cannot appear in the other section. Following would only buy + // pointless waits. + relocation = TvReturnRelocation.SameSectionOnly, + treatAbsenceAsFinal = final, + ) + + var resolution = resolve(final = false) + if (resolution is TvReturnResolution.Pending) { + // Not loaded yet is not the same as not there, and consuming the + // target on the difference loses a return that was about to become + // possible. Search does not page TOWARD a target the way the flat + // surfaces do, but work already in flight deserves the wait. + // + // If content arrives first this coroutine is cancelled and the + // effect re-resolves against it, which is the outcome we want; the + // delay only elapses when nothing came. + delay(TvSearchReturnPendingBudgetMillis) + // Still fetching. Standing down WITHOUT consuming is the safe move: + // a timer expiring is not evidence that nothing is coming, and no + // latency figure would make it one. This effect is keyed on the + // very signals that change when the fetch lands, so it re-runs and + // resolves properly then. + // + // Bounded all the same. A fetch that never completes would + // otherwise leave the screen armed for good, and an armed return + // keeps suppressing the explicit-submit handoff — so the failure + // would outlive the return and quietly break ordinary searching. + if (requestState.isLoading || state.isLoadingMore) { + val settled = withTimeoutOrNull(TvSearchReturnInFlightBudgetMillis) { + snapshotFlow { requestState.isLoading || state.isLoadingMore } + .first { !it } + } != null + returnStandDowns++ + // Absolute, not per-attempt. A timeout that restarts with the + // effect bounds one stuck fetch and nothing else — a sequence + // of quick successful ones would keep re-arming it while the + // return never resolved and went on suppressing the ordinary + // submit handoff. + if (!settled || returnStandDowns >= TvSearchReturnMaxStandDowns) { + returnTarget = null + returnPending = false + } + return@LaunchedEffect + } + resolution = resolve(final = true) + } + val located = resolution as? TvReturnResolution.Located + + if (located == null) { + returnTarget = null + returnPending = false + return@LaunchedEffect + } + + // Observed on the CARD, not the region. A return is a claim on one + // specific item, so "some result has focus" is the same too-coarse test + // this file just removed everywhere else: any already-focused card in + // the region satisfied it and the saved card was never requested, which + // is precisely the case a return exists to serve. + when (located.sectionId) { + TvSearchCatalogSectionId -> { + restoreCatalogIndex = located.itemIndex + searchGridState.scrollToItem(located.itemIndex) + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { androidx.compose.runtime.withFrameNanos { } }, + requestFocus = restoreCatalogFocusRequester::requestFocus, + isFocused = { focusedReturnItemId == located.itemId }, + ) + } + TvSearchRequestSectionId -> { + restoreRequestIndex = located.itemIndex + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { androidx.compose.runtime.withFrameNanos { } }, + requestFocus = restoreRequestFocusRequester::requestFocus, + isFocused = { focusedReturnItemId == located.itemId }, + ) + } + } + + // Confirmed by watching focus hold the card, not by having asked. + withTimeoutOrNull(TvFocusAcquisitionBudgetMillis) { + snapshotFlow { focusedReturnItemId } + .transformLatest { id -> + if (id == located.itemId) { + delay(TvSearchReturnSettleMillis) + emit(Unit) + } + } + .first() + } + + returnTarget = null + returnPending = false + restoreCatalogIndex = -1 + restoreRequestIndex = -1 + } + + // The search field auto-shows the soft keyboard on focus; leaving Search + // without dismissing it left the system IME floating over the next screen + // (e.g. over the video when starting playback from a result). + TvHideStockImeOnDispose() + LaunchedEffect(scrollHeaderIntoView) { + if (scrollHeaderIntoView > 0) searchGridState.animateScrollToItem(0) } LaunchedEffect(backToSearchFieldRequest) { if (backToSearchFieldRequest <= 0) return@LaunchedEffect searchGridState.animateScrollToItem(0) - androidx.compose.runtime.withFrameNanos { } - runCatching { activeSearchFieldFocusRequester.requestFocus() } - keyboardController?.show() + requestFocusUntilObserved( + maxAttempts = TvFrameRelocationMaxAttempts, + awaitAttempt = { androidx.compose.runtime.withFrameNanos { } }, + requestFocus = activeSearchFieldFocusRequester::requestFocus, + isFocused = { focusedRegion == TvSearchFocusRegion.Field }, + ) } LaunchedEffect( pendingSearchFocus, + returnPending, state.isLoading, requestSearchSettled, state.items.size, visibleRequestResults.size, ) { - if (!pendingSearchFocus || state.isLoading || !requestSearchSettled) return@LaunchedEffect - pendingSearchFocus = false - runCatching { - if (state.items.isNotEmpty()) { - firstResultFocusRequester.requestFocus() - } else if (visibleRequestResults.isNotEmpty()) { - firstRequestResultFocusRequester.requestFocus() - } else if (state.error != null) { - feedbackActionFocusRequester.requestFocus() - } else { - firstFilterChipFocusRequester.requestFocus() - } + if (!pendingSearchFocus || state.isLoading) return@LaunchedEffect + // Only wait on the request lookup when it is the thing focus would + // land on. Library results are the primary target and arrive first; + // holding them hostage to a slow TMDB round-trip left the user parked + // on the search field with results visibly sitting there. + if (state.items.isEmpty() && !requestSearchSettled) return@LaunchedEffect + // A return outranks a stale submit. Submitting, walking down to a card + // that the reset had not yet cleared, and opening it leaves this armed + // on a retained composition — and on the way back both effects would + // otherwise be eligible, one aiming at the restored card and the other + // at the first result. + if (returnPending) return@LaunchedEffect + // A failed library search lands on "Try again", even when the request + // row has something to show. Landing on a request card instead scrolled + // the field, chips and the error itself up under the top menu — the + // screen looked broken rather than merely failed, and the recovery + // action was the one thing not on screen. + val postSearchTarget = when { + state.items.isNotEmpty() -> firstResultFocusRequester + state.error != null -> feedbackActionFocusRequester + visibleRequestResults.isNotEmpty() -> firstRequestResultFocusRequester + else -> firstFilterChipFocusRequester + } + val postSearchRegion = when { + state.items.isNotEmpty() -> TvSearchFocusRegion.CatalogResults + state.error != null -> TvSearchFocusRegion.Feedback + visibleRequestResults.isNotEmpty() -> TvSearchFocusRegion.RequestResults + else -> TvSearchFocusRegion.Chips } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = postSearchTarget::requestFocus, + isFocused = { focusedRegion == postSearchRegion }, + ) + // Consumed AFTER the attempt, not before. This effect is keyed on the + // flag, so clearing it first relaunched the effect and cancelled the + // request at its first frame await — the handoff never actually ran. + // While it is in flight a key change (a page landing) simply retries; + // recordReturn still clears it outright when a card is opened. + pendingSearchFocus = false } // Note: we deliberately do NOT auto-jump focus to the first result when // it appears. Doing so during the debounced as-you-type search yanks @@ -198,7 +554,15 @@ fun TvSearchScreen( isLoading = state.isLoadingMore, hasMore = state.hasMore, onItemClick = { }, - onBrowseItemClick = onResultClick, + onBrowseItemClick = { item -> + recordReturn( + TvSearchCatalogSectionId, + tvSearchCatalogItemId(item.contentId), + 0, + state.items.indexOfFirst { it.contentId == item.contentId }, + ) + onResultClick(item) + }, onLoadMore = viewModel::loadMore, modifier = Modifier .fillMaxWidth() @@ -214,6 +578,27 @@ fun TvSearchScreen( horizontalSpacing = 14.dp, verticalSpacing = 20.dp, firstItemFocusRequester = firstResultFocusRequester, + restoreItemIndex = restoreCatalogIndex, + restoreItemFocusRequester = restoreCatalogFocusRequester, + onItemFocusedAtIndex = { item, _, focused -> + // Guarded on item IDENTITY, not just the region. These callbacks + // are per card, and Compose can deliver the newly focused card + // before the outgoing one reports false — a bare region flag + // would then be cleared by the card that just lost focus, right + // after the new one set it. Recycling an outgoing item has the + // same shape. A stale false whose id is no longer the focused + // one is ignored here. + val id = tvSearchCatalogItemId(item.contentId) + if (focused) { + focusedReturnItemId = id + focusedRegion = TvSearchFocusRegion.CatalogResults + } else if (focusedReturnItemId == id) { + focusedReturnItemId = null + if (focusedRegion == TvSearchFocusRegion.CatalogResults) { + focusedRegion = null + } + } + }, // UP from the first card always lands back on the filter chip rail. // Without this Compose's spatial focus search can prefer the wider // search field above and skip over the smaller chip row. @@ -237,8 +622,11 @@ fun TvSearchScreen( firstFilterChipFocusRequester = firstFilterChipFocusRequester, firstContentFocusRequester = firstContentFocusRequester, onSearchFieldFocusChanged = { focused -> + setFocusedRegion(TvSearchFocusRegion.Field, focused) onSearchFieldFocusChanged(focused) - if (focused) keyboardController?.show() + }, + onChipsFocusChanged = { focused -> + setFocusedRegion(TvSearchFocusRegion.Chips, focused) }, onQueryChanged = viewModel::onQueryChanged, onSearch = { @@ -252,6 +640,10 @@ fun TvSearchScreen( viewModel.submitSearch() }, onMediaTypeChanged = viewModel::onMediaTypeChanged, + voiceSearch = voiceSearch, + voiceUnavailableMessage = voiceUnavailableMessage, + isKeyboardOpen = isKeyboardOpen, + onKeyboardOpenChanged = { isKeyboardOpen = it }, ) }, footer = { @@ -264,8 +656,41 @@ fun TvSearchScreen( results = visibleRequestResults, shouldShow = shouldShowRequestSection, firstItemFocusRequester = firstRequestResultFocusRequester, - firstItemCardModifier = Modifier.focusProperties { - up = if (state.items.isNotEmpty()) firstResultFocusRequester else firstFilterChipFocusRequester + restoreItemIndex = restoreRequestIndex, + restoreItemFocusRequester = restoreRequestFocusRequester, + onItemFocusChanged = { item, _, focused -> + // Identity-guarded for the same reason as the catalog + // cards above: a late false from the card that just + // lost focus must not clear the region the new one set. + val id = tvSearchRequestItemId(item.mediaType, item.tmdbId) + if (focused) { + focusedReturnItemId = id + focusedRegion = TvSearchFocusRegion.RequestResults + } else if (focusedReturnItemId == id) { + focusedReturnItemId = null + if (focusedRegion == TvSearchFocusRegion.RequestResults) { + focusedRegion = null + } + } + }, + onItemClicked = { item, index -> + recordReturn( + TvSearchRequestSectionId, + tvSearchRequestItemId(item.mediaType, item.tmdbId), + 1, + index, + ) + }, + // Same precedence as the handoff: UP from the request row + // goes to the results, else the error's "Try again", else + // the chips. Spatial search alone skipped the button and + // landed on the chips or the field. + cardModifier = Modifier.focusProperties { + up = when { + state.items.isNotEmpty() -> firstResultFocusRequester + state.error != null -> feedbackActionFocusRequester + else -> firstFilterChipFocusRequester + } }, onOpenRequestDetail = onOpenRequestDetail, onOpenLibraryItem = onOpenLibraryItem, @@ -275,7 +700,15 @@ fun TvSearchScreen( when { state.query.isBlank() -> SearchFeedbackMessage( title = "Search your library", - body = availableMediaDescription(state.availableMediaTypes), + // The mic sits left of the field and is only reached by + // pressing Left from it, so say so — nothing else on the + // screen teaches that route. + body = if (voiceSearch.isAvailable) { + availableMediaDescription(state.availableMediaTypes) + + " Press left from the search box to search by voice." + } else { + availableMediaDescription(state.availableMediaTypes) + }, ) state.isLoading -> Box(modifier = Modifier.height(64.dp)) state.error != null -> SearchFeedbackMessage( @@ -284,6 +717,14 @@ fun TvSearchScreen( actionLabel = "Try again", actionFocusRequester = feedbackActionFocusRequester, actionUpFocusRequester = firstFilterChipFocusRequester, + onActionFocusChanged = { focused -> + setFocusedRegion(TvSearchFocusRegion.Feedback, focused) + // Coming back UP from the request row, the button + // is only just on screen and the field, chips and + // error title are still under the top menu. The + // header is item zero; bring the whole thing back. + if (focused) scrollHeaderIntoView++ + }, onAction = viewModel::submitSearch, ) else -> SearchFeedbackMessage( @@ -306,51 +747,75 @@ private fun TvRequestSearchSection( results: List, shouldShow: Boolean, firstItemFocusRequester: FocusRequester, - firstItemCardModifier: Modifier, + /** Applied to every card, so UP is routed the same from any position in the row. */ + cardModifier: Modifier, + restoreItemIndex: Int = -1, + restoreItemFocusRequester: FocusRequester? = null, + onItemFocusChanged: (RequestMediaResult, Int, Boolean) -> Unit = { _, _, _ -> }, + onItemClicked: (RequestMediaResult, Int) -> Unit = { _, _ -> }, onOpenRequestDetail: (mediaType: String, tmdbId: Int) -> Unit, onOpenLibraryItem: (contentId: String) -> Unit, ) { if (!requestsEnabled || query.trim().length < 2 || !shouldShow) return + // This section is a full-span footer item inside TvCatalogGrid, so the + // grid's own contentPadding already supplies the safe-area gutter. Adding + // it again here is what pushed this header out of line with the result + // grid above it. Column( modifier = Modifier .fillMaxWidth() - .padding( - start = Spacing.safeArea, - end = 24.dp, - top = 4.dp, - bottom = 12.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(top = Spacing.md, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = "Available to request", - style = MaterialTheme.typography.titleLarge, - color = Color.White, - ) + TvSectionHeader(title = "Available to request") when { results.isNotEmpty() -> { LazyRow( - modifier = Modifier.focusGroup(), + // A LazyRow clips along its scroll axis, so a focused card + // scaling up at index zero lost its left edge against the + // row's bounds. Let the row bleed into the grid gutter and + // pad the content back by the same amount, the way the + // home rails span the full width — the first card then has + // room to grow without being cut off. + modifier = Modifier + .bleedStart(Spacing.safeArea) + .focusGroup(), horizontalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(end = Spacing.safeArea), + contentPadding = PaddingValues( + start = Spacing.safeArea, + end = Spacing.safeArea, + top = 12.dp, + bottom = 12.dp, + ), ) { itemsIndexed( results, key = { _, item -> "${item.mediaType}-${item.tmdbId}" }, contentType = { _, _ -> "request-search-result" }, ) { index, item -> + val isRestoreTarget = restoreItemFocusRequester != null && + index == restoreItemIndex TvRequestCard( result = item, onClick = { + onItemClicked(item, index) if (item.canOpenLibraryDetail()) { onOpenLibraryItem(item.libraryContentId.orEmpty()) } else { onOpenRequestDetail(item.mediaType, item.tmdbId) } }, - focusRequester = firstItemFocusRequester.takeIf { index == 0 }, - cardModifier = if (index == 0) firstItemCardModifier else Modifier, + // The restore target wins the slot when it is this + // card: index zero can be both, and two requesters + // on one node is one requester too many. + focusRequester = if (isRestoreTarget) { + restoreItemFocusRequester + } else { + firstItemFocusRequester.takeIf { index == 0 } + }, + cardModifier = cardModifier + .onFocusChanged { onItemFocusChanged(item, index, it.hasFocus) }, ) } } @@ -362,6 +827,23 @@ private fun TvRequestSearchSection( } } +/** + * Lets a scrolling row extend [amount] to the left of the slot it was given, + * so content padded back in by the same amount can overflow (scale, glow) + * into that space without being clipped by the row's own bounds. + */ +private fun Modifier.bleedStart(amount: Dp): Modifier = layout { measurable, constraints -> + val extra = amount.roundToPx() + val widened = constraints.copy( + minWidth = if (constraints.hasBoundedWidth) constraints.minWidth + extra else constraints.minWidth, + maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth + extra else constraints.maxWidth, + ) + val placeable = measurable.measure(widened) + layout((placeable.width - extra).coerceAtLeast(0), placeable.height) { + placeable.place(-extra, 0) + } +} + @Composable private fun RequestSearchFeedbackRow( message: String, @@ -386,6 +868,15 @@ private fun RequestSearchFeedbackRow( } @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) +/** + * The focus regions a claim on this screen can aim at. + * + * Each claim is observed on the region it actually asked for, so moving focus + * WITHIN the screen — field to results, results back to field — is a state the + * arrival test can distinguish. A single screen-wide hasFocus could not. + */ +private enum class TvSearchFocusRegion { Field, Chips, CatalogResults, RequestResults, Feedback } + @Composable private fun SearchStage( query: String, @@ -397,27 +888,64 @@ private fun SearchStage( firstFilterChipFocusRequester: FocusRequester, firstContentFocusRequester: FocusRequester, onSearchFieldFocusChanged: (Boolean) -> Unit, + onChipsFocusChanged: (Boolean) -> Unit, onQueryChanged: (String) -> Unit, onSearch: () -> Unit, onMediaTypeChanged: (TvSearchMediaType) -> Unit, + voiceSearch: TvVoiceSearchController, + voiceUnavailableMessage: String?, + isKeyboardOpen: Boolean, + onKeyboardOpenChanged: (Boolean) -> Unit, ) { + val voiceFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current val mediaTypes = availableMediaTypes val fieldShape = RoundedCornerShape(14.dp) + // Rendered as the grid's header item, so the grid's contentPadding already + // provides the horizontal gutters. Insetting again here put the field and + // chips a full gutter to the right of the result cards beneath them. Column( modifier = Modifier .fillMaxWidth() .padding( - start = Spacing.safeArea, - end = 24.dp, top = TvTopMenuLayout.contentTopInset - 12.dp, bottom = Spacing.sm, ), verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + // Hidden outright when nothing can service it, rather than shown and + // inert: a mic that does nothing when pressed is worse than no mic. + if (voiceSearch.isAvailable) { + TvVoiceSearchButton( + onClick = voiceSearch::start, + modifier = Modifier + .focusRequester(voiceFocusRequester) + // RIGHT is stated rather than left to geometry, because the + // route INTO this button comes from below and the way back + // out has to be certain. + .focusProperties { + right = searchFieldFocusRequester + down = firstFilterChipFocusRequester + }, + ) + } OutlinedTextField( value = query, onValueChange = { onQueryChanged(it.take(TV_SEARCH_QUERY_MAX_LENGTH)) }, + // Read-only until Select. This is what actually keeps the keyboard + // down — not withholding a show() call, which was the earlier + // mistake: Compose raises the IME itself whenever an editable field + // takes focus, so the only way to hold a focused field without a + // keyboard is for it not to be editable yet. + // + // Focus can then rest here harmlessly, the D-pad still belongs to + // the screen, and everything beside the field stays reachable. + readOnly = !isKeyboardOpen, singleLine = true, placeholder = { Text( @@ -438,7 +966,19 @@ private fun SearchStage( keyboardType = KeyboardType.Text, imeAction = ImeAction.Search, ), - keyboardActions = KeyboardActions(onSearch = { onSearch() }), + keyboardActions = KeyboardActions( + onSearch = { + // Submitting is the end of typing. Put the IME away here, + // explicitly: focus later moving to a result does not + // reliably dismiss it on TV, and a keyboard left standing + // over the grid was the most-reported oddity of this + // screen. Flip the open flag too so the field is read-only + // again and Back/D-pad go to the screen, not the IME. + onKeyboardOpenChanged(false) + keyboardController?.hide() + onSearch() + }, + ), textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White), shape = fieldShape, modifier = Modifier @@ -448,7 +988,72 @@ private fun SearchStage( // the search field onto the All/Movies/Series filters, // regardless of whether result cards are also rendered below. .focusRequester(searchFieldFocusRequester) - .onFocusChanged { onSearchFieldFocusChanged(it.isFocused) } + .onFocusChanged { state -> + onSearchFieldFocusChanged(state.isFocused) + // Leaving the field puts it back to read-only, so returning + // to it later does not silently raise the keyboard again. + if (!state.isFocused && isKeyboardOpen) onKeyboardOpenChanged(false) + } + // Select opens the keyboard; focus alone does not. + // + // Raising it on focus is what made everything beside this field + // unreachable: the IME is a separate window that owns the + // D-pad, so with it up no key ever reaches this app and the mic + // to the left may as well not exist. Nothing an app can do wins + // that race — the earlier attempt to preview Left here was + // fighting a window that had already taken the event. + // + // With the keyboard closed the D-pad belongs to the screen + // again, and ordinary focus movement reaches the mic with no + // routing at all. Typing costs one Select first, which is the + // trade, and it is the one the Wholphin client makes. + .onPreviewKeyEvent { event -> + val opensKeyboard = event.key == Key.DirectionCenter || event.key == Key.Enter + when { + event.type == KeyEventType.KeyUp && opensKeyboard && !isKeyboardOpen -> { + onKeyboardOpenChanged(true) + keyboardController?.show() + true + } + // LEFT has to be taken from the field as well. Keeping + // the keyboard down was necessary but not sufficient: + // the text field still consumes Left as caret movement, + // even read-only and even with nowhere for the caret to + // go, so the key never becomes a focus move. + // + // Only while the keyboard is closed. Once it is open the + // IME owns the D-pad and this never runs — and Left + // genuinely should walk the caret then. + event.type == KeyEventType.KeyDown && + event.key == Key.DirectionLeft && + !isKeyboardOpen && + voiceSearch.isAvailable -> { + // A key handler answers synchronously, so this is + // the single-shot claim; a miss is reported rather + // than swallowed. + voiceFocusRequester.claimFocusOrReport( + target = "search_voice", + action = "field_left", + ) + } + // DOWN too. With the keyboard closed and a query in the + // field, the read-only text field still swallows Down + // (caret-to-end), so `focusProperties { down = … }` + // never gets a chance and the user is stuck on the + // field after a search — nothing below is reachable. + // Taking it in the preview phase is the same fix as + // Left; UP already belongs to the shell. + event.type == KeyEventType.KeyDown && + event.key == Key.DirectionDown && + !isKeyboardOpen -> { + firstFilterChipFocusRequester.claimFocusOrReport( + target = "search_first_filter_chip", + action = "field_down", + ) + } + else -> false + } + } .focusProperties { down = firstFilterChipFocusRequester }, colors = tvOutlinedTextFieldColors( focusedContainerColor = ElevatedSurface, @@ -457,9 +1062,20 @@ private fun SearchStage( unfocusedBorderColor = Color.White.copy(alpha = 0.12f), ), ) + } + + voiceUnavailableMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.72f), + ) + } LazyRow( - modifier = Modifier.focusRestorer(firstFilterChipFocusRequester), + modifier = Modifier + .onFocusChanged { onChipsFocusChanged(it.hasFocus) } + .focusRestorer(firstFilterChipFocusRequester), horizontalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(end = Spacing.xs), verticalAlignment = Alignment.CenterVertically, @@ -469,6 +1085,17 @@ private fun SearchStage( contentType = { _, _ -> "media-type-chip" }, ) { index, type -> val chipModifier = Modifier + // UP returns to the search field — every chip, no + // exceptions. An earlier attempt sent chip zero to the mic + // instead, to give the button a route that avoided the text + // field. It did not work and made things worse: the shell + // claims DirectionUp in its own preview handler above this + // row, so the chip's property never decides anything, and + // when the move it performs fails the shell hands focus to + // the top menu. Chip zero's Up therefore left the screen + // entirely instead of reaching the field. The mic is + // reached from the field itself now, below. + .focusProperties { up = searchFieldFocusRequester } .then( if (index == 0) { Modifier.focusRequester(firstFilterChipFocusRequester) @@ -519,6 +1146,7 @@ private fun SearchFeedbackMessage( actionLabel: String? = null, actionFocusRequester: FocusRequester? = null, actionUpFocusRequester: FocusRequester? = null, + onActionFocusChanged: (Boolean) -> Unit = {}, onAction: (() -> Unit)? = null, ) { Row( @@ -556,6 +1184,7 @@ private fun SearchFeedbackMessage( onClick = onAction, modifier = Modifier .padding(top = Spacing.sm) + .onFocusChanged { onActionFocusChanged(it.isFocused) } .then( if (actionFocusRequester != null) { Modifier.focusRequester(actionFocusRequester) @@ -637,3 +1266,78 @@ private fun TvSearchMediaType.allowsRequestResult(item: RequestMediaResult): Boo TvSearchMediaType.Series -> item.mediaType == RequestMediaType.Series TvSearchMediaType.Audiobooks -> item.mediaType == RequestMediaType.Audiobook } + +/** + * Fires when this destination resumes — a Back out of a result, and also an + * app foregrounding. Composition identity cannot answer this: a Back during + * the outgoing transition can leave the screen composed. + */ +@Composable +private fun TvSearchResumeSignal(onResume: () -> Unit) { + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val currentOnResume by rememberUpdatedState(onResume) + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) currentOnResume() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + +/** How long a not-yet-loaded target waits on work already in flight. */ +private const val TvSearchReturnPendingBudgetMillis: Long = 1_200L + +/** + * How long a return waits on a fetch that is genuinely still running before it + * gives up and disarms. Long, because the wait itself is harmless and the only + * thing it guards against is a request that never returns at all. + */ +private const val TvSearchReturnInFlightBudgetMillis: Long = 10_000L + +/** How many times a return may stand down before it gives up for good. */ +private const val TvSearchReturnMaxStandDowns: Int = 4 + +/** Focus must hold the card this long to count as arrived rather than passing. */ +private const val TvSearchReturnSettleMillis: Long = 120L + +/** + * The mic beside the search field. + * + * Deliberately a peer of the field rather than an icon inside it: a trailing + * icon in a text field is not focusable, and on a remote a control you cannot + * reach with the D-pad may as well not exist. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvVoiceSearchButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + Surface( + onClick = onClick, + interactionSource = interactionSource, + shape = ClickableSurfaceDefaults.shape(CircleShape), + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.055f), + focusedContainerColor = Color.White, + contentColor = Color.White, + focusedContentColor = Color.Black, + ), + modifier = modifier.size(52.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + M3Icon( + imageVector = Icons.Filled.Mic, + contentDescription = "Search by voice", + tint = if (isFocused) Color.Black else Color.White.copy(alpha = 0.82f), + modifier = Modifier.size(24.dp), + ) + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt index e43f51076..752901349 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchViewModel.kt @@ -7,6 +7,7 @@ import org.prairieserver.prairie.model.catalog.isAudiobookItemType import org.prairieserver.prairie.model.navigation.isAudiobookLikeLibraryType import org.prairieserver.prairie.model.navigation.tvMediaModeCapabilities import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.tv.ui.util.visibleOnTv @@ -294,7 +295,10 @@ class TvSearchViewModel( it.copy( isLoading = false, isLoadingMore = false, - error = "Network error: ${result.exception.message ?: "unknown"}", + // Never the raw exception: it carries the full + // request URL and read like a stack trace on a + // ten-foot screen. + error = result.errorMessage("Search failed"), ) } return diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvVoiceSearch.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvVoiceSearch.kt new file mode 100644 index 000000000..bf531e0b4 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvVoiceSearch.kt @@ -0,0 +1,190 @@ +package org.prairieserver.prairie.tv.ui.screens.search + +import android.app.Activity +import android.content.Context +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.provider.Settings +import android.speech.RecognizerIntent +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext + +/** + * Voice search on TV, spoken into the remote. + * + * This deliberately hands off to the system recogniser rather than recording + * anything itself. On a Shield the recogniser listens through the remote's + * microphone, which is the hardware the viewer expects to be talking into, and + * because the recording happens in that app rather than this one Silo needs no + * RECORD_AUDIO permission at all — nothing here can listen, only ask. + * + * The remote's own mic BUTTON cannot be used to start this: Android TV binds it + * to the system assistant before any app sees it. An on-screen affordance is + * the only way an app can offer voice, which is why the mic lives beside the + * search field. + */ +internal class TvVoiceSearchController( + /** + * False when no recogniser is installed, which is ordinary on a bare AOSP + * TV box. Callers hide the affordance rather than offering a button that + * cannot do anything. + */ + val isAvailable: Boolean, + private val launch: () -> Boolean, + private val onUnavailable: () -> Unit, +) { + fun start() { + // Availability was resolved earlier and can be wrong by now — the + // recogniser may have been disabled or uninstalled since. Say so + // instead of doing nothing: a visible mic that silently ignores a + // press is the worst outcome for someone who does not know what an + // intent is. + if (!isAvailable || !launch()) onUnavailable() + } +} + +@Composable +internal fun rememberTvVoiceSearch( + prompt: String, + onResult: (String) -> Unit, + onUnavailable: () -> Unit, +): TvVoiceSearchController { + val context = LocalContext.current + val currentOnResult by rememberUpdatedState(onResult) + val currentOnUnavailable by rememberUpdatedState(onUnavailable) + + // Resolved once. Installing a recogniser mid-session is not a case worth + // recomposing for, and re-querying the package manager on every frame is. + val isAvailable = remember(context) { isTvSpeechRecognitionAvailable(context) } + val recognizerPackage = remember(context) { preferredRecognizerPackage(context) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + // The list is ordered by confidence, so the first entry is the + // recogniser's own best guess. Silo has no better way to choose + // between alternates than the engine that produced them. + ?.firstOrNull() + ?.trim() + .orEmpty() + // A cancelled or empty recognition must not wipe a query the viewer + // already typed. + if (spoken.isNotEmpty()) currentOnResult(spoken) + } + + return remember(isAvailable, prompt, recognizerPackage, launcher) { + TvVoiceSearchController( + isAvailable = isAvailable, + launch = { + // Narrow, and reported. A blanket runCatching here swallowed + // every reason a launch could fail and left the caller unable + // to tell success from silence. + try { + launcher.launch(tvSpeechRecognizerIntent(prompt, recognizerPackage)) + true + } catch (e: ActivityNotFoundException) { + Log.w(TvVoiceSearchTag, "No activity accepted the speech recognition intent", e) + false + } + }, + onUnavailable = { currentOnUnavailable() }, + ) + } +} + +private const val TvVoiceSearchTag = "TvVoiceSearch" + +/** + * Which package should service the recognition request, or null to leave it to + * the system. + * + * More than one activity commonly claims this intent — a Google TV Streamer + * offers both the TV search app and the text-to-speech package — and with no + * default the launch becomes a disambiguation chooser. Asking someone to pick + * an app with a remote before they can say a film title is not voice search. + * + * The order matters and is not the obvious one. The device's configured + * VOICE_RECOGNITION_SERVICE names a service for programmatic recognition, not + * necessarily the best ACTIVITY to show someone: on a Streamer it points at the + * text-to-speech package, whose activity is not the ten-foot voice UI anyone + * wants. The voice-interaction/assistant package is the system's designated + * spoken front end, and on a TV that is the one with the microphone UI built + * for a remote. So it is asked first, and the recognition service only after. + * + * When nothing matches, null leaves the intent implicit and the system shows + * its chooser — worse, but honest, and better than silently picking whichever + * handler happened to be listed first. + */ +private fun preferredRecognizerPackage(context: Context): String? { + val candidates = context.packageManager.queryIntentActivities( + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH), + PackageManager.MATCH_DEFAULT_ONLY, + ) + if (candidates.size <= 1) { + return candidates.firstOrNull()?.activityInfo?.packageName + } + val resolver = context.contentResolver + val preferred = listOf( + "voice_interaction_service", + "assistant", + // Read by key: the constant is not public API. + "voice_recognition_service", + ).mapNotNull { key -> + Settings.Secure.getString(resolver, key) + ?.substringBefore('/') + ?.takeIf { it.isNotBlank() } + } + return preferred.firstOrNull { pkg -> + candidates.any { it.activityInfo?.packageName == pkg } + } +} + +private fun tvSpeechRecognizerIntent(prompt: String, recognizerPackage: String?): Intent = + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + recognizerPackage?.let(::setPackage) + // Free-form rather than web search: these are film, series and book + // titles, not queries, and the web-search model rewrites them toward + // whatever it thinks you meant to google. + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, + ) + putExtra(RecognizerIntent.EXTRA_PROMPT, prompt) + // EXTRA_MAX_RESULTS is deliberately unset. Only the first result is + // used either way, and leaving the cap off asks nothing unusual of a + // third-party recogniser. + // + // EXTRA_LANGUAGE is deliberately unset too. Unset means the device's + // own speech locale, which is what a household actually configured; + // pinning the app's UI locale would make an English UI work and break + // a family that speaks Dutch. + } + +/** + * Whether anything on this device can handle a recognition request. + * + * Needs the matching `` element in the manifest — from Android 11 an + * app cannot see packages it has not declared an interest in, so without it + * this returns false on every modern device and the mic silently never appears. + */ +private fun isTvSpeechRecognitionAvailable(context: Context): Boolean = + // resolveActivity, not queryIntentActivities(intent, 0). The latter also + // returns handlers whose filter lacks CATEGORY_DEFAULT, which + // startActivityForResult will not launch — so the mic could appear for a + // recogniser that cannot actually be started. + // + // SpeechRecognizer.isRecognitionAvailable is not the check either: it + // reports a recognition SERVICE, and what this needs is an exported + // ACTIVITY. A device can have one without the other. + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .resolveActivity(context.packageManager) != null diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt index ac41c8e6f..247a17569 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt @@ -1,7 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.servers import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -24,7 +23,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -37,8 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -50,10 +46,8 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text -import org.prairieserver.prairie.discovery.DiscoveryHit -import org.prairieserver.prairie.discovery.normalizeDiscoveryUrl import org.prairieserver.prairie.model.server.ServerEntry -import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.components.TvDialogOption import org.prairieserver.prairie.tv.ui.components.TvOptionDialog import org.prairieserver.prairie.tv.ui.theme.Spacing @@ -64,34 +58,24 @@ import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel /** - * Multi-server picker for the TV app — first-run LAN discovery with branding, - * plus focus-aware management of saved servers. Long-press / Menu opens Remove - * (rename is intentionally omitted on TV). - * - * Phone pairing remains reachable via [onAddServer] → [TvServerSetupScreen], - * which hosts the companion pairing flow. + * Multi-server picker for the TV app — focus-aware list of saved servers + * with an "Add Server" tile at the top. Long-press / Menu opens an action + * sheet with Remove (rename is intentionally omitted on TV: easier to + * remove + re-add than to edit a string with the on-screen keyboard). */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvServerListScreen( onAddServer: () -> Unit, onSwitched: (TvServerSwitchDestination) -> Unit, - onBack: (() -> Unit)? = null, - autoScan: Boolean = false, + onBack: () -> Unit, viewModel: TvServerListViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val firstFocus = remember { FocusRequester() } var confirmRemove by remember { mutableStateOf(null) } - val isFirstRun = onBack == null - if (onBack != null) { - BackHandler(enabled = true) { onBack() } - } - - LaunchedEffect(autoScan) { - if (autoScan) viewModel.maybeAutoScan() - } + BackHandler(enabled = true) { onBack() } LaunchedEffect(state.switchedTo) { val destination = state.switchedTo @@ -101,185 +85,98 @@ fun TvServerListScreen( } } - LaunchedEffect(state.emptyRegistry) { - // Active server removed with none left — stay on the list and scan again. - if (state.emptyRegistry) { - viewModel.onEmptyRegistryConsumed() - viewModel.startScan(includeDeep = true) + LaunchedEffect(state.needsServerSetup) { + // The active server was removed and none remain — there is nothing to + // sign into, so bounce to server setup rather than leaving the stale + // shell behind this list pointed at an empty baseUrl. + if (state.needsServerSetup) { + viewModel.onServerSetupConsumed() + onAddServer() } } - val savedUrls = remember(state.servers) { - state.servers.map { normalizeDiscoveryUrl(it.url) }.toSet() - } - val freshHits = remember(state.discovered, savedUrls) { - state.discovered.filter { it.url !in savedUrls } - } - - LaunchedEffect(state.servers.size, freshHits.size, state.isScanning) { - repeat(TvInitialFocusRetryCount) { - if (runCatching { firstFocus.requestFocus() }.isSuccess) return@LaunchedEffect - delay(TvInitialFocusRetryDelayMs) - } - } + // Anchor focus on the first row whenever the list materializes so d-pad + // navigation has somewhere to land. The rows are lazy, so the first request + // lands before placement and is rejected — this retries until focus is + // actually observed rather than until a call merely returns. + val contentInitialFocus = rememberTvContentInitialFocus( + target = firstFocus, + contentKey = state.servers.firstOrNull()?.id, + ) Box( modifier = Modifier .fillMaxSize() + .then(contentInitialFocus) .background(ServerSettingsBackground) .padding(start = 44.dp, top = Spacing.safeArea, end = 44.dp, bottom = Spacing.xxxl), ) { Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) { Column( - modifier = Modifier.width(220.dp), + modifier = Modifier.width(200.dp), verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { - if (isFirstRun) { - Image( - painter = painterResource(id = R.drawable.prairie_wordmark), - contentDescription = "Prairie", - modifier = Modifier - .width(140.dp) - .height(36.dp), - contentScale = ContentScale.Fit, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "CONNECT", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.55f), - ) - Text( - text = "Choose a server", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - Text( - text = "Pick a saved server or one found on your LAN. Sign-in comes next. Pair with phone is available when you add manually.", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), - ) - } else { - Text( - text = "CONNECTION", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.55f), - ) - Text( - text = "Manage Servers", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = Color.White, - ) - Text( - text = "Choose, add, or remove a Prairie server.", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), - ) - } + Text( + text = "CONNECTION", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.55f), + ) + Text( + text = "Manage Servers", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + Text( + text = "Choose, rename, add, or remove a Silo server.", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.62f), + ) } Column( modifier = Modifier.widthIn(max = ServerListMaxWidth), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ActionTile( - label = if (state.isScanning) "Scanning…" else "Scan again", - icon = Icons.Default.Refresh, - onClick = { viewModel.startScan(includeDeep = true) }, - enabled = !state.isScanning && !state.isConnecting, - modifier = Modifier.focusRequester(firstFocus), - ) - ActionTile( - label = "Add manually", - icon = Icons.Default.Add, - onClick = onAddServer, - enabled = !state.isScanning && !state.isConnecting, - ) - } - - state.scanStatus?.takeIf { it.isNotBlank() }?.let { status -> - Text( - text = status, - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.62f), - ) - } - state.scanError?.takeIf { it.isNotBlank() }?.let { error -> - Text( - text = error, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error, - ) - } + Text( + text = "Saved Servers", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.62f), + ) + AddServerTile( + onClick = onAddServer, + modifier = Modifier.focusRequester( + if (state.servers.isEmpty()) firstFocus else FocusRequester.Default, + ), + ) - if (state.servers.isNotEmpty()) { - Text( - text = "Saved", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.62f), - ) - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(state.servers, key = { it.id }) { entry -> - ServerRow( - entry = entry, - isActive = entry.id == state.activeId, - isPending = entry.id == state.pendingSwitchToId || state.isConnecting, - onSelect = { viewModel.onSelect(entry.id) }, - onRemove = { confirmRemove = entry }, - showRemove = !isFirstRun, - ) - } - } - } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(state.servers, key = { it.id }) { entry -> + val rowModifier = if (entry == state.servers.firstOrNull()) { + Modifier.focusRequester(firstFocus) + } else Modifier - if (freshHits.isNotEmpty() || state.isScanning) { - Text( - text = "Discovered", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.62f), - ) - if (freshHits.isEmpty() && state.isScanning) { - Text( - text = "Scanning your network for Prairie…", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), + ServerRow( + entry = entry, + isActive = entry.id == state.activeId, + isPending = entry.id == state.pendingSwitchToId, + onSelect = { viewModel.onSelect(entry.id) }, + onRemove = { confirmRemove = entry }, + modifier = rowModifier, ) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(freshHits, key = { it.url }) { hit -> - DiscoveredRow( - hit = hit, - enabled = !state.isScanning && !state.isConnecting, - onSelect = { - viewModel.selectDiscovered(hit.url, hit.serverName) - }, - ) - } - } } } - - if (!state.isScanning && state.servers.isEmpty() && freshHits.isEmpty()) { - Text( - text = "No servers yet — wait for the scan, or add a URL manually.", - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.62f), - ) - } } } } confirmRemove?.let { target -> + // Removing the active server signs the user out of it — say so + // explicitly instead of showing the same generic confirm as any + // other row, so it isn't a silent footgun. val isActiveTarget = target.id == state.activeId TvOptionDialog( title = if (isActiveTarget) { @@ -310,28 +207,24 @@ fun TvServerListScreen( onDismiss = { confirmRemove = null }, ) } + } -private const val TvInitialFocusRetryCount = 4 -private const val TvInitialFocusRetryDelayMs = 50L private val ServerSettingsBackground = Color(0xFF17181A) private val ServerListMaxWidth = 620.dp private val ServerRowShape = RoundedCornerShape(8.dp) @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ActionTile( - label: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, +private fun AddServerTile( onClick: () -> Unit, - enabled: Boolean = true, modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val foreground = if (isFocused) FocusedContent else Color.White Card( - onClick = { if (enabled) onClick() }, + onClick = onClick, interactionSource = interactionSource, colors = CardDefaults.colors( containerColor = Color.White.copy(alpha = 0.055f), @@ -340,68 +233,24 @@ private fun ActionTile( ), shape = CardDefaults.shape(shape = ServerRowShape), scale = CardDefaults.scale(focusedScale = 1f), - modifier = modifier, + modifier = modifier.fillMaxWidth(), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = icon, + imageVector = Icons.Default.Add, contentDescription = null, - tint = if (enabled) foreground else foreground.copy(alpha = 0.4f), + tint = foreground, modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(Spacing.sm)) Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - fontFamily = InterFamily, - color = if (enabled) foreground else foreground.copy(alpha = 0.4f), - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun DiscoveredRow( - hit: DiscoveryHit, - enabled: Boolean, - onSelect: () -> Unit, -) { - val interactionSource = remember(hit.url) { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val foreground = if (isFocused) FocusedContent else Color.White - Card( - onClick = { if (enabled) onSelect() }, - interactionSource = interactionSource, - colors = CardDefaults.colors( - containerColor = Color.White.copy(alpha = 0.055f), - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - ), - shape = CardDefaults.shape(shape = ServerRowShape), - scale = CardDefaults.scale(focusedScale = 1f), - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { - Text( - text = hit.serverName.trim().ifBlank { hit.url }, + text = "Add Server", style = MaterialTheme.typography.bodyMedium, fontFamily = InterFamily, color = foreground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = "Found · ${hit.url}", - style = MaterialTheme.typography.labelSmall, - fontFamily = InterFamily, - color = foreground.copy(alpha = if (isFocused) 0.68f else 0.62f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, ) } } @@ -415,7 +264,6 @@ private fun ServerRow( isPending: Boolean, onSelect: () -> Unit, onRemove: () -> Unit, - showRemove: Boolean, modifier: Modifier = Modifier, ) { Row( @@ -440,6 +288,9 @@ private fun ServerRow( ), shape = CardDefaults.shape(shape = ServerRowShape), scale = CardDefaults.scale(focusedScale = 1f), + // The TV Card is already focusable; adding .focusable() here creates + // a dead second focus stop (no visual, OK does nothing). Keep only + // the weight, matching AddServerTile. modifier = Modifier.weight(1f), ) { Row( @@ -483,30 +334,28 @@ private fun ServerRow( } } - if (showRemove) { - Surface( - onClick = onRemove, - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.055f), - contentColor = MaterialTheme.colorScheme.error, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - ), - shape = ClickableSurfaceDefaults.shape(shape = ServerRowShape), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + Surface( + onClick = onRemove, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.055f), + contentColor = MaterialTheme.colorScheme.error, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + ), + shape = ClickableSurfaceDefaults.shape(shape = ServerRowShape), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + ) { + Box( + modifier = Modifier + .size(width = 48.dp, height = 48.dp), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .size(width = 48.dp, height = 48.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp), - ) - } + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvCardOverlaySettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvCardOverlaySettingsScreen.kt deleted file mode 100644 index d999745b6..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvCardOverlaySettingsScreen.kt +++ /dev/null @@ -1,1077 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.settings - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import org.prairieserver.prairie.common.overlays.CardOverlayVariant -import org.prairieserver.prairie.common.overlays.CardOverlays -import org.prairieserver.prairie.common.settings.OverlayPrefsStore -import org.prairieserver.prairie.overlays.CardOverlayPrefs -import org.prairieserver.prairie.overlays.OverlayAccentPalette -import org.prairieserver.prairie.overlays.OverlayCategory -import org.prairieserver.prairie.overlays.OverlayData -import org.prairieserver.prairie.overlays.OverlayDef -import org.prairieserver.prairie.overlays.OverlayId -import org.prairieserver.prairie.overlays.OverlayItemConfig -import org.prairieserver.prairie.overlays.OverlayPosition -import org.prairieserver.prairie.overlays.OverlayRegistry -import org.prairieserver.prairie.overlays.OverlaySchema -import org.prairieserver.prairie.tv.ui.components.TvCardOverlayScale -import org.prairieserver.prairie.tv.ui.shell.TvTopMenuLayout -import org.prairieserver.prairie.tv.ui.theme.FocusedContainer -import org.prairieserver.prairie.tv.ui.theme.FocusedContent -import org.prairieserver.prairie.tv.ui.theme.Spacing -import kotlinx.coroutines.launch - -/** - * TV "Card Overlays" settings sub-screen — the Compose-for-TV port of - * prairie-apple `TVCardOverlaySettingsView`. Two-pane, focus-driven: - * - * - LEFT: a large live preview poster that re-renders as the user edits, - * a movie/show sample switcher, and the preset chips (with the active - * preset's description). - * - RIGHT: a vertical list of overlay tiles grouped by category header. - * Each tile (status dot | name + description | live badge preview | - * position chip | chevron) opens a per-overlay detail panel with big - * focusable buttons for Visibility, Position (2×2 grid + rows), Accent - * Color, and Icon. A Reset-to-Defaults button sits at the bottom. - * - * When the store is disabled (admin kill-switch) the admin-disabled notice - * shows and editing is disabled. Back dismisses the whole sub-screen - * (handled by the caller's [onDismiss]); Back inside the detail panel - * returns to the tile list. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -internal fun TvCardOverlaySettingsScreen( - store: OverlayPrefsStore, - onDismiss: () -> Unit, -) { - BackHandler(onBack = onDismiss) - val scope = rememberCoroutineScope() - LaunchedEffect(store) { store.hydrateIfNeeded() } - - val enabled by store.enabled.collectAsState() - val prefs by store.prefs.collectAsState() - - var sampleVariant by remember { mutableStateOf(OverlaySampleVariant.Movie) } - var detailOverlay by remember { mutableStateOf(null) } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding( - start = 72.dp, - top = TvTopMenuLayout.contentTopInset, - end = 72.dp, - bottom = Spacing.xxxl, - ), - horizontalArrangement = Arrangement.spacedBy(30.dp), - ) { - OverlayPreviewPane( - enabled = enabled, - prefs = prefs, - sampleVariant = sampleVariant, - onSampleVariantChange = { sampleVariant = it }, - onPresetSelected = { preset -> - store.setPrefs(prefs.copy(preset = preset)) - }, - modifier = Modifier.width(260.dp), - ) - OverlayControlsPane( - enabled = enabled, - prefs = prefs, - sampleData = sampleVariant.data, - hasUserOverride = store.hasUserOverride, - onTileClick = { detailOverlay = it }, - onReset = { scope.launch { store.resetToDefaults() } }, - modifier = Modifier.weight(1f), - ) - } - } - - detailOverlay?.let { id -> - OverlayDetailPanel( - overlayId = id, - prefs = prefs, - sampleData = sampleVariant.data, - onMutate = { store.setPrefs(it) }, - onDismiss = { detailOverlay = null }, - ) - } -} - -// --------------------------------------------------------------------------- -// Sample variant -// --------------------------------------------------------------------------- - -internal enum class OverlaySampleVariant(val label: String) { - Movie("Movie"), - Show("Show"), - ; - - val data: OverlayData - get() = when (this) { - Movie -> OverlayData( - resolution = "2160p", - hdr = "Dolby Vision", - audio = "TrueHD Atmos", - audioChannels = "7.1", - videoCodec = "H.265", - container = "MKV", - aspectRatio = "2.39:1", - releaseType = "REMUX", - edition = "Director's Cut", - multiAudio = true, - multiSub = true, - ratingImdb = 8.6, - ratingTmdb = 8.4, - ratingRtCritic = 94, - ratingRtAudience = 91, - contentRating = "PG-13", - year = 2014, - runtime = 169, - originalLanguage = "en", - studio = "Warner Bros.", - showStatus = null, - imdbTop250 = 17, - rtCertifiedFresh = true, - ) - Show -> OverlayData( - resolution = "1080p", - hdr = "HDR10", - audio = "EAC3", - audioChannels = "5.1", - videoCodec = "H.264", - container = "MP4", - aspectRatio = "16:9", - releaseType = "WEB-DL", - edition = null, - multiAudio = true, - multiSub = true, - ratingImdb = 9.2, - ratingTmdb = 8.9, - ratingRtCritic = 96, - ratingRtAudience = 88, - contentRating = "TV-MA", - year = 2011, - runtime = 58, - originalLanguage = "en", - network = "HBO", - showStatus = "ended", - imdbTop250 = null, - rtCertifiedFresh = true, - ) - } -} - -// --------------------------------------------------------------------------- -// Left pane: live preview -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayPreviewPane( - enabled: Boolean, - prefs: CardOverlayPrefs, - sampleVariant: OverlaySampleVariant, - onSampleVariantChange: (OverlaySampleVariant) -> Unit, - onPresetSelected: (org.prairieserver.prairie.overlays.PresetId) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - OverlayPreviewPoster( - enabled = enabled, - prefs = prefs, - data = sampleVariant.data, - modifier = Modifier.width(210.dp), - ) - - // Sample variant switcher (movie / show). - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OverlaySampleVariant.entries.forEach { variant -> - OverlayChip( - label = variant.label, - selected = sampleVariant == variant, - onClick = { onSampleVariantChange(variant) }, - ) - } - } - - // Preset chips + the active preset's description. - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - text = "Style", - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.6f), - ) - Text( - text = prefs.preset.description, - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.6f), - ) - LazyColumn( - modifier = Modifier.heightInChips(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(org.prairieserver.prairie.overlays.PresetId.entries.toList()) { preset -> - OverlayChip( - label = preset.label, - selected = prefs.preset == preset, - onClick = { onPresetSelected(preset) }, - fillWidth = true, - ) - } - } - } - } -} - -/** Height cap so the preset chip list stays inside the pane without overscroll. */ -private fun Modifier.heightInChips(): Modifier = this.height(150.dp) - -@Composable -private fun OverlayPreviewPoster( - enabled: Boolean, - prefs: CardOverlayPrefs, - data: OverlayData, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .aspectRatio(2f / 3f) - .clip(RoundedCornerShape(11.dp)) - .background( - Brush.linearGradient( - colors = listOf( - Color(0xFF4D4D4D), - Color(0xFF292929), - Color(0xFF101010), - ), - ), - ), - ) { - if (enabled) { - CardOverlays( - data = data, - prefs = prefs, - variant = CardOverlayVariant.Poster, - scale = TvCardOverlayScale, - forceOpaqueBackground = false, - ) - } - } -} - -// --------------------------------------------------------------------------- -// Right pane: overlay tiles grouped by category -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayControlsPane( - enabled: Boolean, - prefs: CardOverlayPrefs, - sampleData: OverlayData, - hasUserOverride: Boolean, - onTileClick: (OverlayId) -> Unit, - onReset: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (!enabled) { - OverlayDisabledNotice() - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .alpha(if (enabled) 1f else 0.35f), - verticalArrangement = Arrangement.spacedBy(14.dp), - contentPadding = PaddingValues(bottom = 20.dp), - ) { - OverlayCategory.entries.forEach { category -> - item(key = "header-${category.raw}") { - OverlayCategoryHeader(category) - } - items( - OverlayRegistry.defs(category), - key = { it.id.raw }, - ) { def -> - OverlayTile( - def = def, - config = prefs.items[def.id] ?: def.toDefaultConfig(), - prefs = prefs, - sampleData = sampleData, - enabled = enabled, - onClick = { if (enabled) onTileClick(def.id) }, - ) - } - } - item(key = "reset") { - OverlayResetRow( - enabled = enabled && hasUserOverride, - onClick = onReset, - ) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayDisabledNotice() { - Row( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = OverlayRowMaxWidth) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.06f)) - .padding(horizontal = 24.dp, vertical = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Card overlays have been disabled by your server administrator.", - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.7f), - ) - } -} - -@Composable -private fun OverlayCategoryHeader(category: OverlayCategory) { - Column( - modifier = Modifier.padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = category.displayName, - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = category.description, - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.6f), - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayTile( - def: OverlayDef, - config: OverlayItemConfig, - prefs: CardOverlayPrefs, - sampleData: OverlayData, - enabled: Boolean, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(14.dp)), - colors = overlayRowColors(), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = OverlayRowMaxWidth) - .alpha(if (config.enabled) 1f else 0.55f), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(20.dp), - ) { - // Status dot. - Box( - modifier = Modifier - .size(14.dp) - .clip(CircleShape) - .background( - if (config.enabled) Color(0xFF34C759) - else Color.White.copy(alpha = 0.18f), - ), - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = def.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = if (isFocused) FocusedContent else Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = def.description, - style = MaterialTheme.typography.bodyMedium, - color = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.6f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Live badge preview — render the single overlay forced-on. - OverlayBadgePreview(def = def, prefs = prefs, data = sampleData) - // Position chip. - Text( - text = config.position.displayName, - style = MaterialTheme.typography.bodyMedium, - color = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.6f), - ) - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = (if (isFocused) FocusedContent else Color.White).copy(alpha = 0.6f), - ) - } - } -} - -/** - * Renders one overlay's live badge by building a tiny prefs document that - * disables every overlay except [def] (forced enabled) so only this badge - * shows. Uses the public [CardOverlays] renderer so the accent/icon/preset - * shape all reflect what the user would actually get. - */ -@Composable -private fun OverlayBadgePreview( - def: OverlayDef, - prefs: CardOverlayPrefs, - data: OverlayData, -) { - val previewPrefs = remember(def.id, prefs, data) { - singleOverlayPrefs(def.id, prefs, position = OverlayPosition.TopLeft) - } - Box( - modifier = Modifier - .width(96.dp) - .height(40.dp), - contentAlignment = Alignment.Center, - ) { - CardOverlays( - data = data, - prefs = previewPrefs, - variant = CardOverlayVariant.Poster, - scale = TvCardOverlayScale, - forceOpaqueBackground = false, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayResetRow( - enabled: Boolean, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = { if (enabled) onClick() }, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), - colors = overlayRowColors(), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.0f), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = OverlayRowMaxWidth) - .height(64.dp) - .alpha(if (enabled) 1f else 0.4f) - .padding(top = 12.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Reset to Defaults", - style = MaterialTheme.typography.bodyLarge, - color = when { - isFocused -> FocusedContent - else -> MaterialTheme.colorScheme.error - }, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(16.dp)) - } - } -} - -// --------------------------------------------------------------------------- -// Per-overlay detail panel (full-screen focus trap; Back returns to list) -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayDetailPanel( - overlayId: OverlayId, - prefs: CardOverlayPrefs, - sampleData: OverlayData, - onMutate: (CardOverlayPrefs) -> Unit, - onDismiss: () -> Unit, -) { - BackHandler(onBack = onDismiss) - val def = OverlayRegistry.def(overlayId) ?: run { - onDismiss() - return - } - val config = prefs.items[overlayId] ?: def.toDefaultConfig() - - fun patch(mutate: (OverlayItemConfig) -> OverlayItemConfig) { - val base = prefs.items[overlayId] ?: def.toDefaultConfig() - val items = prefs.items.toMutableMap() - items[overlayId] = mutate(base) - onMutate(prefs.copy(items = items)) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 50.dp, - top = TvTopMenuLayout.contentTopInset, - end = 50.dp, - bottom = Spacing.xxxl, - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - item { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = def.label, - style = MaterialTheme.typography.displaySmall, - color = Color.White, - fontWeight = FontWeight.Bold, - ) - Text( - text = def.description, - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.6f), - ) - } - } - - // Focused preview: ONLY this overlay enabled. - item { - OverlayPreviewPoster( - enabled = true, - prefs = singleOverlayPrefs(overlayId, prefs, config.position), - data = sampleData, - modifier = Modifier.width(160.dp), - ) - } - - // Visibility - item { - OverlayDetailSection(title = "Visibility") { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - OverlayBigButton( - label = "On", - active = config.enabled, - onClick = { patch { it.copy(enabled = true) } }, - ) - OverlayBigButton( - label = "Off", - active = !config.enabled, - onClick = { patch { it.copy(enabled = false) } }, - ) - } - } - } - - // Position — 2×2 grid + rows - item { - OverlayDetailSection(title = "Position") { - Row( - horizontalArrangement = Arrangement.spacedBy(25.dp), - verticalAlignment = Alignment.Top, - ) { - OverlayPositionGrid( - selection = config.position, - accent = config.accentColor?.let { tvOverlayColorFromHex(it) } - ?: Color.White, - width = 110.dp, - onSelect = { pos -> patch { it.copy(position = pos) } }, - ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OverlayPosition.entries.forEach { pos -> - OverlayBigButton( - label = pos.displayName, - active = config.position == pos, - onClick = { patch { it.copy(position = pos) } }, - ) - } - } - } - } - } - - // Accent Color - item { - OverlayDetailSection(title = "Accent Color") { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 60.dp), - modifier = Modifier - .fillMaxWidth() - .height(130.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(OverlayAccentPalette.entries, key = { it.hex }) { entry -> - OverlayAccentSwatch( - label = entry.label, - hex = entry.hex, - selected = config.accentColor - ?.equals(entry.hex, ignoreCase = true) == true, - onClick = { patch { it.copy(accentColor = entry.hex) } }, - ) - } - } - OverlayBigButton( - label = if (def.defaultAccent == null) "No Accent" else "Default", - active = config.accentColor == null, - onClick = { patch { it.copy(accentColor = null) } }, - ) - } - } - } - - // Icon (only when icon-capable) - if (def.iconCapable) { - item { - OverlayDetailSection(title = "Icon") { - val presetPrefersIcon = prefs.preset.preferIcon - val resolvedShow = config.showIcon ?: presetPrefersIcon - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - OverlayBigButton( - label = "Show Icon", - active = resolvedShow, - onClick = { - patch { - it.copy( - showIcon = if (presetPrefersIcon) null else true, - ) - } - }, - ) - OverlayBigButton( - label = "Hide Icon", - active = !resolvedShow, - onClick = { - patch { - it.copy( - showIcon = if (!presetPrefersIcon) null else false, - ) - } - }, - ) - } - } - } - } - - def.availabilityNote?.let { note -> - item { - Text( - text = note, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.6f), - ) - } - } - - item { - OverlayBigButton( - label = "Done", - active = true, - onClick = onDismiss, - ) - } - } - } -} - -/** Whether the preset prefers icons by default — mirrors the renderer preset. */ -private val org.prairieserver.prairie.overlays.PresetId.preferIcon: Boolean - get() = when (this) { - org.prairieserver.prairie.overlays.PresetId.Vibrant, - org.prairieserver.prairie.overlays.PresetId.Pill, - -> true - else -> false - } - -@Composable -private fun OverlayDetailSection( - title: String, - content: @Composable () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 480.dp) - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = Color.White.copy(alpha = 0.6f), - fontWeight = FontWeight.SemiBold, - ) - content() - } -} - -// --------------------------------------------------------------------------- -// 2×2 corner position picker (Compose-for-TV port of OverlayPositionGrid) -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayPositionGrid( - selection: OverlayPosition, - accent: Color, - width: Dp, - onSelect: (OverlayPosition) -> Unit, -) { - val height = width * 1.5f - Box( - modifier = Modifier - .width(width) - .height(height) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.06f)), - ) { - OverlayPosition.entries.forEach { position -> - OverlayCornerDot( - selected = selection == position, - accent = accent, - onClick = { onSelect(position) }, - modifier = Modifier - .align(cornerAlignment(position)) - .padding(14.dp), - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayCornerDot( - selected: Boolean, - accent: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val fill = when { - selected -> accent - isFocused -> Color.White.copy(alpha = 0.55f) - else -> Color.White.copy(alpha = 0.18f) - } - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = CircleShape), - colors = ClickableSurfaceDefaults.colors( - containerColor = fill, - contentColor = Color.White, - focusedContainerColor = if (selected) accent else Color.White.copy(alpha = 0.55f), - focusedContentColor = Color.White, - pressedContainerColor = if (selected) accent else Color.White.copy(alpha = 0.7f), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.3f), - modifier = modifier.size(34.dp), - ) { - Box(modifier = Modifier.fillMaxSize()) - } -} - -private fun cornerAlignment(position: OverlayPosition): Alignment = - when (position) { - OverlayPosition.TopLeft -> Alignment.TopStart - OverlayPosition.TopRight -> Alignment.TopEnd - OverlayPosition.BottomLeft -> Alignment.BottomStart - OverlayPosition.BottomRight -> Alignment.BottomEnd - } - -// --------------------------------------------------------------------------- -// Shared small primitives -// --------------------------------------------------------------------------- - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayChip( - label: String, - selected: Boolean, - onClick: () -> Unit, - fillWidth: Boolean = false, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(10.dp)), - colors = overlayRowColors(), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - modifier = if (fillWidth) Modifier.fillMaxWidth().widthIn(max = 360.dp) else Modifier, - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, - color = when { - isFocused -> FocusedContent - selected -> Color.White - else -> Color.White.copy(alpha = 0.6f) - }, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayBigButton( - label: String, - active: Boolean, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(6.dp)), - colors = overlayRowColors(), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.04f), - ) { - Row( - modifier = Modifier - .widthIn(min = 80.dp) - .padding(horizontal = 12.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - if (active) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = if (isFocused) FocusedContent else MaterialTheme.colorScheme.primary, - modifier = Modifier.size(12.dp), - ) - } - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = when { - isFocused -> FocusedContent - active -> Color.White - else -> Color.White.copy(alpha = 0.6f) - }, - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun OverlayAccentSwatch( - label: String, - hex: String, - selected: Boolean, - onClick: () -> Unit, -) { - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - Surface( - onClick = onClick, - interactionSource = interactionSource, - shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(7.dp)), - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.Transparent, - contentColor = Color.White, - focusedContainerColor = Color.White.copy(alpha = 0.10f), - focusedContentColor = Color.White, - pressedContainerColor = Color.White.copy(alpha = 0.10f), - pressedContentColor = Color.White, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.03f), - ) { - Column( - modifier = Modifier.padding(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(tvOverlayColorFromHex(hex)), - contentAlignment = Alignment.Center, - ) { - if (selected || isFocused) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(12.dp), - ) - } - } - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = if (isFocused) Color.White else Color.White.copy(alpha = 0.6f), - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun overlayRowColors() = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.06f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, -) - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -private const val OverlayRowMaxWidthValue = 480 -private val OverlayRowMaxWidth = OverlayRowMaxWidthValue.dp - -private fun OverlayDef.toDefaultConfig(): OverlayItemConfig = - OverlayItemConfig( - enabled = defaultEnabled, - position = defaultPosition, - accentColor = null, - showIcon = null, - ) - -/** - * Build a standalone prefs document where only [id] is enabled (forced on) - * and placed at [position], with the user's per-overlay config preserved so - * the preview reflects accent/icon overrides. Other overlays are disabled. - */ -private fun singleOverlayPrefs( - id: OverlayId, - source: CardOverlayPrefs, - position: OverlayPosition, -): CardOverlayPrefs { - val base = OverlaySchema.buildDefaults().copy(preset = source.preset) - val items = base.items.mapValues { (_, cfg) -> cfg.copy(enabled = false) }.toMutableMap() - val def = OverlayRegistry.def(id) - val userCfg = source.items[id] ?: def?.toDefaultConfig() - items[id] = (userCfg ?: OverlayItemConfig(enabled = true, position = position)) - .copy(enabled = true, position = position) - return base.copy(items = items) -} - -/** - * Parse a 6-digit hex color into a Compose [Color] for the TV settings - * surface. Mirrors `overlayColorFromHex` in android-shared (which is - * internal to that module). Falls back to white for malformed input. - */ -private fun tvOverlayColorFromHex(hex: String?): Color { - if (hex.isNullOrBlank()) return Color.White - val cleaned = (if (hex.startsWith("#")) hex.substring(1) else hex).trim() - val expanded = if (cleaned.length == 3) cleaned.map { "$it$it" }.joinToString("") else cleaned - return when (expanded.length) { - 6 -> { - val rgb = expanded.toLongOrNull(16) ?: return Color.White - Color(0xFF000000.toInt() or (rgb.toInt() and 0x00FFFFFF)) - } - 8 -> { - val argb = expanded.toLongOrNull(16) ?: return Color.White - Color(argb.toInt()) - } - else -> Color.White - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsScreen.kt deleted file mode 100644 index 5385ff5e5..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsScreen.kt +++ /dev/null @@ -1,206 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.settings - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Devices -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.tv.material3.Card -import androidx.tv.material3.CardDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Icon -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import org.prairieserver.prairie.model.auth.AuthSession -import org.prairieserver.prairie.tv.ui.components.TvDialogOption -import org.prairieserver.prairie.tv.ui.components.TvErrorScreen -import org.prairieserver.prairie.tv.ui.components.TvLoadingScreen -import org.prairieserver.prairie.tv.ui.components.TvOptionDialog -import org.koin.compose.viewmodel.koinViewModel - -/** - * TV "Manage Sessions" — the signed-in user's own active login sessions with a - * revoke action. Mirrors the phone Settings → Manage Sessions (AuthRepository - * getSessions/deleteSession). Each session is a focusable Card; selecting it - * opens a confirm dialog to revoke that device's session. - */ -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -fun TvManageSessionsScreen( - onBack: () -> Unit, - viewModel: TvManageSessionsViewModel = koinViewModel(), -) { - val state by viewModel.uiState.collectAsState() - var revokeTarget by remember { mutableStateOf(null) } - var lastMessage by remember { mutableStateOf(null) } - - BackHandler(enabled = true) { onBack() } - - LaunchedEffect(state.message) { - if (state.message != null) { - lastMessage = state.message - viewModel.consumeMessage() - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - ) { - Header(title = "Manage Sessions", subtitle = lastMessage) - - when { - state.isLoading && state.sessions.isEmpty() -> TvLoadingScreen() - - state.error != null && state.sessions.isEmpty() -> TvErrorScreen( - message = state.error!!, - onRetry = viewModel::load, - ) - - state.sessions.isEmpty() -> TvErrorScreen(message = "No active sessions.") - - else -> LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 48.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(state.sessions, key = { it.id }) { session -> - SessionRow( - session = session, - busy = session.id in state.busyIds, - onClick = { revokeTarget = session }, - ) - } - item { Spacer(Modifier.height(24.dp)) } - } - } - } - - revokeTarget?.let { session -> - TvOptionDialog( - title = "Revoke session?", - options = listOf( - TvDialogOption( - key = "revoke", - title = "Revoke", - subtitle = "Sign out ${session.deviceName}", - onClick = { - revokeTarget = null - viewModel.revoke(session.id) - }, - ), - TvDialogOption( - key = "cancel", - title = "Keep", - onClick = { revokeTarget = null }, - ), - ), - onDismiss = { revokeTarget = null }, - ) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun Header(title: String, subtitle: String?) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 48.dp, vertical = 32.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Icon( - imageVector = Icons.Filled.Devices, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(40.dp), - ) - Column { - Text( - text = title, - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.onBackground, - ) - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun SessionRow(session: AuthSession, busy: Boolean, onClick: () -> Unit) { - Card( - onClick = onClick, - shape = CardDefaults.shape(shape = RoundedCornerShape(16.dp)), - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 480.dp) - .heightIn(min = 48.dp), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text( - text = session.deviceName, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = session.ipAddress, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Signed in ${session.createdAt}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (busy) { - Text( - text = "Revoking…", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsViewModel.kt deleted file mode 100644 index 37e8a0fd6..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvManageSessionsViewModel.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.settings - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.auth.AuthSession -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AuthRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class TvManageSessionsUiState( - val isLoading: Boolean = true, - val sessions: List = emptyList(), - val busyIds: Set = emptySet(), - val error: String? = null, - val message: String? = null, -) - -/** - * TV "Manage Sessions" — lists the signed-in user's own active login sessions - * and revokes them, via [AuthRepository.getSessions]/[AuthRepository.deleteSession] - * (the same shared API the phone's Settings → Manage Sessions uses). - */ -class TvManageSessionsViewModel( - private val authRepository: AuthRepository, -) : ViewModel() { - - private val _uiState = MutableStateFlow(TvManageSessionsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun load() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - when (val result = authRepository.getSessions()) { - is ApiResult.Success -> _uiState.update { - it.copy( - isLoading = false, - // Only active (non-revoked) sessions are manageable. - sessions = result.data.filter { s -> s.revokedAt == null }, - error = null, - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load sessions")) - } - } - } - } - - fun revoke(id: String) { - if (id in _uiState.value.busyIds) return - viewModelScope.launch { - _uiState.update { it.copy(busyIds = it.busyIds + id) } - when (val result = authRepository.deleteSession(id)) { - is ApiResult.Success -> { - _uiState.update { - it.copy( - sessions = it.sessions.filterNot { s -> s.id == id }, - busyIds = it.busyIds - id, - message = "Session revoked", - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(busyIds = it.busyIds - id, message = result.errorMessage("Failed to revoke session")) - } - } - } - } - - fun consumeMessage() = _uiState.update { it.copy(message = null) } -} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt index 4c130e382..22e547c08 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.tv.ui.screens.settings import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource @@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.MonitorHeart import androidx.compose.material.icons.filled.PlayCircle import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable @@ -47,10 +49,18 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import org.prairieserver.prairie.tv.ui.components.TvDialogOption +import org.prairieserver.prairie.tv.ui.components.TvOptionDialog +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.TvContentInitialFocusMaxAttempts import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester @@ -62,6 +72,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -76,43 +87,44 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.prairieserver.prairie.common.network.clientVersionLabel +import org.prairieserver.prairie.model.settings.LanguageOptions +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.QualityPresets +import org.prairieserver.prairie.model.settings.SettingKeys import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.model.settings.SubtitlePositionPreset import org.prairieserver.prairie.model.settings.pointSize import org.prairieserver.prairie.tv.BuildConfig -import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality +import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleAppearanceOptions +import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsSettingsPane import org.prairieserver.prairie.tv.ui.screens.settings.diagnostics.TvDiagnosticsViewModel import org.prairieserver.prairie.tv.ui.theme.FocusedContainer import org.prairieserver.prairie.tv.ui.theme.FocusedContent import org.prairieserver.prairie.tv.ui.theme.Spacing -import org.prairieserver.prairie.update.changelogUrlOrNull -import org.prairieserver.prairie.update.latestVersionLabel -import org.prairieserver.prairie.update.releaseUrlOrNull -import org.prairieserver.prairie.update.statusLabel import org.koin.compose.viewmodel.koinViewModel import kotlinx.coroutines.delay -import androidx.compose.ui.platform.LocalUriHandler /** * TV Settings — a tvOS-style split rail/detail surface modeled on * `iosApp/.../tvOS/Screens/Settings/TVSettingsView.swift`. * * Requests/watch-together routes stay compiled elsewhere without normal menu - * rows. The stats-only Admin dashboard remains role-gated in this surface. + * rows. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvSettingsScreen( - onNavigateToAdmin: () -> Unit = {}, onManageServers: () -> Unit = {}, onSignedOut: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, onNavigateHome: () -> Unit = {}, - onNavigateToDiagnostics: () -> Unit = {}, + onOpenDiagnosticsReport: (reportId: String) -> Unit = {}, onInitialContentFocus: () -> Unit = {}, initialManageServersFocus: Boolean = false, onManageServersReturnFocusConsumed: () -> Unit = {}, @@ -130,40 +142,77 @@ fun TvSettingsScreen( } val detailFocusRequester = remember { FocusRequester() } - var selectedCategory by remember { + // Saveable so a drill-out to the pending-report route and back returns to + // the category the viewer was reading, not to General. + var selectedCategory by rememberSaveable { mutableStateOf( if (initialManageServersFocus) TvSettingsCategory.Server else TvSettingsCategory.General, ) } var detailHasFocus by remember { mutableStateOf(false) } + var categoryColumnHasFocus by remember { mutableStateOf(false) } var detailFocusRequest by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - val requester = if (initialManageServersFocus) { - detailFocusRequester - } else { - categoryFocusRequesters.getValue(TvSettingsCategory.General) - } - var focusRestored = false - for (attempt in 0 until 4) { - if (runCatching { requester.requestFocus() }.getOrDefault(false)) { - focusRestored = true - break - } - delay(50) - } + // Was four attempts judged on requestFocus() returning true — that is + // acceptance, not arrival. onInitialContentFocus() hands content focus + // to the shell, so firing it regardless told the shell focus had landed + // even when the loop had just failed four times. + val focusRestored = requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + // Resolved per attempt rather than captured up front: rememberSaveable + // may have restored a category other than General, and the eligibility + // fallback below can retarget it on the same frame. Claiming General + // unconditionally undid the restore, because that row's onFocused + // resets the selection on the way in. + requestFocus = { + if (initialManageServersFocus) { + detailFocusRequester.requestFocus() + } else { + categoryFocusRequesters.getValue(selectedCategory).requestFocus() + } + }, + isFocused = { categoryColumnHasFocus }, + ) == TvObservedFocusResult.Focused if (initialManageServersFocus && focusRestored) onManageServersReturnFocusConsumed() - onInitialContentFocus() + if (focusRestored) onInitialContentFocus() + } + + // tvOS parity: eligibility can flip while Settings is open (profile switch, + // server capability refresh). Falling back keeps the pane and the rail in + // agreement instead of stranding focus in a category that just vanished. + LaunchedEffect(diagnosticsState.profileEligible) { + val fallback = tvSettingsCategoryForEligibility( + selectedCategory, + diagnosticsState.profileEligible, + ) + if (fallback == selectedCategory) return@LaunchedEffect + selectedCategory = fallback + // Swapping the model is not enough: the row (or detail control) holding + // focus is the one that just left the rail, and Compose clears focus + // rather than re-homing it, which leaves the remote with nothing to + // move from. The fallback's row is always composed, so claim it here. + categoryFocusRequesters.getValue(fallback) + .claimFocusOrReport(target = "settings_category", action = "eligibility_fallback") } LaunchedEffect(detailFocusRequest) { - if (detailFocusRequest > 0) runCatching { detailFocusRequester.requestFocus() } + if (detailFocusRequest > 0) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = detailFocusRequester::requestFocus, + isFocused = { detailHasFocus }, + ) + } } BackHandler { if (detailHasFocus) { - runCatching { categoryFocusRequesters.getValue(selectedCategory).requestFocus() } + categoryFocusRequesters.getValue(selectedCategory) + .claimFocusOrReport(target = "settings_category", action = "back_from_detail") } else { onNavigateHome() } @@ -186,10 +235,12 @@ fun TvSettingsScreen( SettingsSplitLayout( state = state, diagnosticsState = diagnosticsState, + diagnosticsViewModel = diagnosticsViewModel, selectedCategory = selectedCategory, categoryFocusRequesters = categoryFocusRequesters, detailFocusRequester = detailFocusRequester, onDetailFocusChanged = { detailHasFocus = it }, + onRailCategoryFocusChanged = { categoryColumnHasFocus = it }, onCategorySelected = { selectedCategory = it }, onEnterCategory = { selectedCategory = it @@ -197,13 +248,12 @@ fun TvSettingsScreen( }, onSwitchProfile = viewModel::onSwitchProfile, onManageServers = onManageServers, - onNavigateToDiagnostics = onNavigateToDiagnostics, + onOpenDiagnosticsReport = onOpenDiagnosticsReport, onRequestSignOut = { showSignOutConfirm = true }, - onNavigateToAdmin = onNavigateToAdmin, - onQualityChanged = viewModel::onPlaybackQualityChanged, + onQualityPresetSelected = viewModel::onQualityPresetSelected, onAudioLanguageChanged = viewModel::onAudioLanguageChanged, onAutoPlayNextChanged = viewModel::onAutoPlayNextChanged, - onAutoSkipIntroChanged = viewModel::onAutoSkipIntroChanged, + onIntroSkipModeChanged = viewModel::onIntroSkipModeChanged, onAutoSkipCreditsChanged = viewModel::onAutoSkipCreditsChanged, onMatchContentFrameRateChanged = viewModel::onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = viewModel::onDolbyVisionEnabledChanged, @@ -246,7 +296,7 @@ fun TvSettingsScreen( } } -private enum class TvSettingsCategory( +internal enum class TvSettingsCategory( val title: String, val eyebrow: String, val blurb: String, @@ -270,6 +320,15 @@ private enum class TvSettingsCategory( blurb = "Language, behavior, and subtitle appearance.", icon = Icons.Filled.ClosedCaption, ), + // tvOS `TVSettingsCategory` puts Diagnostics fourth, ahead of Server, under + // its own SUPPORT eyebrow. `stethoscope` has no Material twin; MonitorHeart + // is the nearest "check the patient" glyph. + Diagnostics( + title = "Diagnostics", + eyebrow = "SUPPORT", + blurb = "Review and send diagnostics to this Silo server.", + icon = Icons.Filled.MonitorHeart, + ), Server( title = "Server", eyebrow = "CONNECTION", @@ -278,6 +337,30 @@ private enum class TvSettingsCategory( ), } +/** + * tvOS `visibleCategories`: Diagnostics is hidden outright for a profile that + * may not manage diagnostics (a kids profile, or a server that hides it). + */ +internal fun tvSettingsVisibleCategories(diagnosticsEligible: Boolean): List = + TvSettingsCategory.entries.filter { + it != TvSettingsCategory.Diagnostics || diagnosticsEligible + } + +/** + * tvOS `.onChange(of: shouldShowSettings)`: if the category being shown stops + * being visible, fall back to General rather than leaving the pane rendering a + * category the rail no longer offers. + */ +internal fun tvSettingsCategoryForEligibility( + current: TvSettingsCategory, + diagnosticsEligible: Boolean, +): TvSettingsCategory = + if (current in tvSettingsVisibleCategories(diagnosticsEligible)) { + current + } else { + TvSettingsCategory.General + } + private val LocalSettingsDetailFocusReporter = staticCompositionLocalOf<(Boolean) -> Unit> { {} } // --------------------------------------------------------------------------- @@ -289,22 +372,26 @@ private val LocalSettingsDetailFocusReporter = staticCompositionLocalOf<(Boolean private fun SettingsSplitLayout( state: TvSettingsViewModel.UiState, diagnosticsState: org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState, + diagnosticsViewModel: TvDiagnosticsViewModel, selectedCategory: TvSettingsCategory, categoryFocusRequesters: Map, detailFocusRequester: FocusRequester, onDetailFocusChanged: (Boolean) -> Unit, + // Observed arrival of the entry claim, so the shell handover below only + // fires when a category actually took focus. + onRailCategoryFocusChanged: (Boolean) -> Unit, onCategorySelected: (TvSettingsCategory) -> Unit, onEnterCategory: (TvSettingsCategory) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, onSwitchProfile: () -> Unit, onManageServers: () -> Unit, - onNavigateToDiagnostics: () -> Unit, + onOpenDiagnosticsReport: (reportId: String) -> Unit, onRequestSignOut: () -> Unit, - onNavigateToAdmin: () -> Unit, - onQualityChanged: (PlaybackQuality) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, - onAutoSkipIntroChanged: (Boolean) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, @@ -349,30 +436,34 @@ private fun SettingsSplitLayout( ) { SettingsRail( state = state, + visibleCategories = tvSettingsVisibleCategories(diagnosticsState.profileEligible), selectedCategory = selectedCategory, categoryFocusRequesters = categoryFocusRequesters, detailFocusRequester = detailFocusRequester, onCategorySelected = onCategorySelected, onEnterCategory = onEnterCategory, - onRailCategoryFocused = { onDetailFocusChanged(false) }, + onRailCategoryFocused = { + onDetailFocusChanged(false) + onRailCategoryFocusChanged(true) + }, onSwitchProfile = onSwitchProfile, - onNavigateToAdmin = onNavigateToAdmin, onRequestSignOut = onRequestSignOut, modifier = Modifier.width(200.dp), ) SettingsDetailPane( state = state, diagnosticsState = diagnosticsState, + diagnosticsViewModel = diagnosticsViewModel, selectedCategory = selectedCategory, detailFocusRequester = detailFocusRequester, onDetailFocusChanged = onDetailFocusChanged, onShowAudiobooksTabChanged = onShowAudiobooksTabChanged, onManageServers = onManageServers, - onNavigateToDiagnostics = onNavigateToDiagnostics, - onQualityChanged = onQualityChanged, + onOpenDiagnosticsReport = onOpenDiagnosticsReport, + onQualityPresetSelected = onQualityPresetSelected, onAudioLanguageChanged = onAudioLanguageChanged, onAutoPlayNextChanged = onAutoPlayNextChanged, - onAutoSkipIntroChanged = onAutoSkipIntroChanged, + onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, @@ -421,6 +512,7 @@ private fun SettingsSplitLayout( @Composable private fun SettingsRail( state: TvSettingsViewModel.UiState, + visibleCategories: List, selectedCategory: TvSettingsCategory, categoryFocusRequesters: Map, detailFocusRequester: FocusRequester, @@ -429,7 +521,6 @@ private fun SettingsRail( onRailCategoryFocused: () -> Unit, onSwitchProfile: () -> Unit, onRequestSignOut: () -> Unit, - onNavigateToAdmin: () -> Unit, modifier: Modifier = Modifier, ) { var railActionHasFocus by remember { mutableStateOf(false) } @@ -452,7 +543,7 @@ private fun SettingsRail( onClick = onSwitchProfile, ) Spacer(modifier = Modifier.height(9.dp)) - TvSettingsCategory.entries.forEach { category -> + visibleCategories.forEach { category -> SettingsRailCategoryRow( category = category, selected = category == selectedCategory && !railActionHasFocus, @@ -469,15 +560,6 @@ private fun SettingsRail( ) } Spacer(modifier = Modifier.weight(1f)) - // Apple-parity admin surface: the stats dashboard only, role-gated. - if (state.adminVisible) { - SettingsRailActionRow( - label = "Admin", - icon = Icons.Filled.Settings, - onClick = onNavigateToAdmin, - onFocused = { railActionHasFocus = true }, - ) - } SettingsRailActionRow( label = "Sign Out", icon = Icons.AutoMirrored.Filled.Logout, @@ -486,7 +568,7 @@ private fun SettingsRail( onFocused = { railActionHasFocus = true }, ) Text( - text = "Prairie ${state.appVersionName.ifBlank { BuildConfig.VERSION_NAME }}", + text = "Silo ${clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER)}", style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 14.sp, @@ -569,7 +651,7 @@ private fun SettingsRailCategoryRow( } } -/** Rail action row (Admin, Sign Out) — same transparent rest chrome as categories. */ +/** Rail action row (Sign Out) — same transparent rest chrome as categories. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun SettingsRailActionRow( @@ -632,16 +714,18 @@ private fun SettingsRailActionRow( private fun SettingsDetailPane( state: TvSettingsViewModel.UiState, diagnosticsState: org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState, + diagnosticsViewModel: TvDiagnosticsViewModel, selectedCategory: TvSettingsCategory, detailFocusRequester: FocusRequester, onDetailFocusChanged: (Boolean) -> Unit, onShowAudiobooksTabChanged: (Boolean) -> Unit, onManageServers: () -> Unit, - onNavigateToDiagnostics: () -> Unit, - onQualityChanged: (PlaybackQuality) -> Unit, + onOpenDiagnosticsReport: (reportId: String) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, - onAutoSkipIntroChanged: (Boolean) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, @@ -699,10 +783,10 @@ private fun SettingsDetailPane( TvSettingsCategory.Playback -> TvPlaybackSettingsPane( state = state, firstFocusRequester = detailFocusRequester, - onQualityChanged = onQualityChanged, + onQualityPresetSelected = onQualityPresetSelected, onAudioLanguageChanged = onAudioLanguageChanged, onAutoPlayNextChanged = onAutoPlayNextChanged, - onAutoSkipIntroChanged = onAutoSkipIntroChanged, + onIntroSkipModeChanged = onIntroSkipModeChanged, onAutoSkipCreditsChanged = onAutoSkipCreditsChanged, onMatchContentFrameRateChanged = onMatchContentFrameRateChanged, onDolbyVisionEnabledChanged = onDolbyVisionEnabledChanged, @@ -733,12 +817,25 @@ private fun SettingsDetailPane( onSubtitleDeviceOverrideEnabledChanged = onSubtitleDeviceOverrideEnabledChanged, onSubtitleMatchesDeviceChanged = onSubtitleMatchesDeviceChanged, ) + TvSettingsCategory.Diagnostics -> TvDiagnosticsSettingsPane( + state = diagnosticsState, + serverName = state.serverName, + firstFocusRequester = detailFocusRequester, + onSetDestination = diagnosticsViewModel::setDestination, + onSetConsent = diagnosticsViewModel::setConsent, + onSetDebugLogging = diagnosticsViewModel::setDebugLogging, + onCaptureNow = { diagnosticsViewModel.captureNow(onOpenDiagnosticsReport) }, + onStartTimedCapture = diagnosticsViewModel::startTimedCapture, + onStopTimedCapture = { + diagnosticsViewModel.stopTimedCapture(onOpenDiagnosticsReport) + }, + onCancelTimedCapture = diagnosticsViewModel::cancelTimedCapture, + onReportSelected = onOpenDiagnosticsReport, + ) TvSettingsCategory.Server -> TvServerSettingsPane( state = state, - diagnosticsState = diagnosticsState, firstFocusRequester = detailFocusRequester, onManageServers = onManageServers, - onNavigateToDiagnostics = onNavigateToDiagnostics, ) } } @@ -784,10 +881,11 @@ private fun TvGeneralSettingsPane( private fun TvPlaybackSettingsPane( state: TvSettingsViewModel.UiState, firstFocusRequester: FocusRequester, - onQualityChanged: (PlaybackQuality) -> Unit, + /** Receives a [QualityPresets] preset id. */ + onQualityPresetSelected: (String) -> Unit, onAudioLanguageChanged: (String) -> Unit, onAutoPlayNextChanged: (Boolean) -> Unit, - onAutoSkipIntroChanged: (Boolean) -> Unit, + onIntroSkipModeChanged: (IntroSkipMode) -> Unit, onAutoSkipCreditsChanged: (Boolean) -> Unit, onMatchContentFrameRateChanged: (Boolean) -> Unit, onDolbyVisionEnabledChanged: (Boolean) -> Unit, @@ -798,6 +896,13 @@ private fun TvPlaybackSettingsPane( onResetPlaybackOverrides: () -> Unit, ) { var activePicker by remember { mutableStateOf(null) } + val audioLanguages = remember(state.audioLanguage, state.audioLanguageSuggestions) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = state.audioLanguage, + runtimeValues = state.audioLanguageSuggestions, + ) + } LazyColumn( modifier = Modifier.fillMaxSize(), @@ -808,13 +913,16 @@ private fun TvPlaybackSettingsPane( SettingsGroup(title = "Streaming") { SettingsValueRow( label = "Quality", - value = state.playbackQuality.label, + value = QualityPresets.describe(state.qualityResolution, state.maxBitrateKbps), onClick = { activePicker = PlaybackPicker.Quality }, focusRequester = firstFocusRequester, ) SettingsValueRow( label = "Audio Language", - value = audioLanguageLabel(state.audioLanguage), + value = LanguageOptions.label( + state.audioLanguage, + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + ), onClick = { activePicker = PlaybackPicker.AudioLanguage }, ) // tvOS TVPlaybackSettingsPane STREAMING parity: Dolby Vision @@ -852,10 +960,13 @@ private fun TvPlaybackSettingsPane( value = nextUpPromptLabel(state.nextUpPromptSeconds), onClick = { activePicker = PlaybackPicker.NextUpPrompt }, ) - SettingsToggleRow( - label = "Auto-Skip Intros", - checked = state.autoSkipIntro, - onCheckedChange = onAutoSkipIntroChanged, + // Three-way, not a switch: the schema's recommended control + // is a select and TV has no segmented control, so this uses the + // same value row + picker sheet every other enum here does. + SettingsValueRow( + label = stringResource(R.string.settings_intro_skip_title), + value = stringResource(introSkipModeLabel(state.introSkipMode)), + onClick = { activePicker = PlaybackPicker.IntroSkipMode }, ) SettingsToggleRow( label = "Auto-Skip Credits", @@ -889,19 +1000,23 @@ private fun TvPlaybackSettingsPane( } when (activePicker) { + // The picker offers presets; a stored pair no preset covers (set + // through the API, or left by a legacy compound value) selects + // nothing rather than silently highlighting the wrong entry. PlaybackPicker.Quality -> TvSettingsPickerSheet( title = "Quality", - options = PlaybackQuality.values().map { PickerOption(it.name, it.label) }, - selectedId = state.playbackQuality.name, + options = QualityPresets.ALL.map { PickerOption(it.id, it.label) }, + selectedId = QualityPresets.presetFor(state.qualityResolution, state.maxBitrateKbps)?.id + ?: "", onSelect = { id -> - PlaybackQuality.values().firstOrNull { it.name == id }?.let(onQualityChanged) + onQualityPresetSelected(id) activePicker = null }, onDismiss = { activePicker = null }, ) PlaybackPicker.AudioLanguage -> TvSettingsPickerSheet( title = "Audio Language", - options = AudioLanguages.map { PickerOption(it.first, it.second) }, + options = audioLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.audioLanguage, onSelect = { onAudioLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, @@ -916,6 +1031,23 @@ private fun TvPlaybackSettingsPane( }, onDismiss = { activePicker = null }, ) + // Three short options: a compact popup over the settings list, not the + // full-screen picker the longer lists use. + PlaybackPicker.IntroSkipMode -> TvOptionDialog( + title = stringResource(R.string.settings_intro_skip_title), + options = IntroSkipMode.entries.map { mode -> + TvDialogOption( + key = mode.wireValue, + title = stringResource(introSkipModeLabel(mode)), + selected = mode == state.introSkipMode, + onClick = { + onIntroSkipModeChanged(mode) + activePicker = null + }, + ) + }, + onDismiss = { activePicker = null }, + ) PlaybackPicker.ResumeRewind -> TvSettingsPickerSheet( title = "Resume Skip-Back", options = ResumeRewindOptions.map { PickerOption(it.toString(), resumeRewindLabel(it)) }, @@ -965,12 +1097,37 @@ private fun TvSubtitleSettingsPane( var activePicker by remember { mutableStateOf(null) } var showResetConfirmation by remember { mutableStateOf(false) } val appearance = state.subtitleAppearance + val subtitleLanguages = remember( + state.subtitleLanguage, + state.subtitleLanguageSuggestions, + ) { + LanguageOptions.options( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = state.subtitleLanguage, + runtimeValues = state.subtitleLanguageSuggestions, + ) + } + val metadataLanguages = remember( + state.metadataLanguage, + state.metadataLanguageSuggestions, + ) { + LanguageOptions.options( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + currentValue = state.metadataLanguage, + runtimeValues = state.metadataLanguageSuggestions, + ) + } LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = Spacing.xxxl), ) { + if (state.settingsAvailability == + ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED + ) { + item { TvSettingsUpgradeRequiredNotice() } + } item { SettingsGroup(title = "Profile") { SettingsValueRow( @@ -981,13 +1138,19 @@ private fun TvSubtitleSettingsPane( ) SettingsValueRow( label = "Language", - value = subtitleLanguageLabel(state.subtitleLanguage), + value = LanguageOptions.label( + state.subtitleLanguage, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + ), onClick = { activePicker = SubtitlePicker.Language }, ) if (metadataLanguageEnabled) { SettingsValueRow( label = "Metadata Language", - value = subtitleLanguageLabel(state.metadataLanguage), + value = LanguageOptions.label( + state.metadataLanguage, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ), onClick = { activePicker = SubtitlePicker.MetadataLanguage }, ) } @@ -1105,14 +1268,14 @@ private fun TvSubtitleSettingsPane( ) SubtitlePicker.Language -> TvSettingsPickerSheet( title = "Language", - options = SubtitleLanguages.map { PickerOption(it.first, it.second) }, + options = subtitleLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.subtitleLanguage, onSelect = { onSubtitleLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, ) SubtitlePicker.MetadataLanguage -> TvSettingsPickerSheet( title = "Metadata Language", - options = SubtitleLanguages.map { PickerOption(it.first, it.second) }, + options = metadataLanguages.map { PickerOption(it.first, it.second) }, selectedId = state.metadataLanguage, onSelect = { onMetadataLanguageChanged(it); activePicker = null }, onDismiss = { activePicker = null }, @@ -1301,12 +1464,9 @@ private fun TvSettingsSubtitlePreview(appearance: SubtitleAppearance) { @Composable private fun TvServerSettingsPane( state: TvSettingsViewModel.UiState, - diagnosticsState: org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState, firstFocusRequester: FocusRequester, onManageServers: () -> Unit, - onNavigateToDiagnostics: () -> Unit, ) { - val uriHandler = LocalUriHandler.current LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -1328,44 +1488,17 @@ private fun TvServerSettingsPane( ) } } - if (diagnosticsState.profileEligible) { - item { - SettingsGroup(title = "Diagnostics") { - SettingsActionRow( - label = "Diagnostics & Crash Reports", - onClick = onNavigateToDiagnostics, - ) - SettingsFooterText( - text = "Review local reports, choose consent, and run a timed diagnostic capture.", - ) - } - } - } + // Diagnostics used to hang off this pane as a "Diagnostics & Crash + // Reports" drill-in to a route outside the shell. It is its own + // category now (tvOS parity), so nothing here points at it. item { SettingsGroup(title = "About") { + // Same "1.0.0 (5)" form as the phone About row, so a TV support + // report names the build the server's admin Activity page shows. SettingsInfoRow( label = "Version", - value = state.appVersionName.ifBlank { BuildConfig.VERSION_NAME }, + value = clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER), ) - SettingsInfoRow( - label = "Update status", - value = state.appUpdateStatus.statusLabel(), - ) - state.appUpdateStatus.latestVersionLabel()?.let { latest -> - SettingsInfoRow(label = "Latest version", value = latest) - } - state.appUpdateStatus.changelogUrlOrNull()?.let { url -> - SettingsActionRow( - label = "Changelog", - onClick = { runCatching { uriHandler.openUri(url) } }, - ) - } - state.appUpdateStatus.releaseUrlOrNull()?.let { url -> - SettingsActionRow( - label = "View update", - onClick = { runCatching { uriHandler.openUri(url) } }, - ) - } } } } @@ -1386,7 +1519,22 @@ private fun accountSubtitle(state: TvSettingsViewModel.UiState): String { return state.user?.username?.takeIf { it.isNotBlank() } ?: "Signed in" } -private enum class PlaybackPicker { Quality, AudioLanguage, NextUpPrompt, ResumeRewind, PassOutThreshold } +private enum class PlaybackPicker { + Quality, + AudioLanguage, + NextUpPrompt, + IntroSkipMode, + ResumeRewind, + PassOutThreshold, +} + +/** The label each intro-skip mode is offered under. The copy is fixed by the contract. */ +@StringRes +private fun introSkipModeLabel(mode: IntroSkipMode): Int = when (mode) { + IntroSkipMode.NEVER -> R.string.settings_intro_skip_never + IntroSkipMode.ASK -> R.string.settings_intro_skip_ask + IntroSkipMode.ALWAYS -> R.string.settings_intro_skip_always +} private enum class SubtitlePicker { Mode, @@ -1427,13 +1575,19 @@ fun TvSettingsPickerSheet( val initialFocus = remember { FocusRequester() } val selectedIndex = options.indexOfFirst { it.id == selectedId }.coerceAtLeast(0) + var pickerHasFocus by remember { mutableStateOf(false) } val focusTargetIndex = if (options.isEmpty()) -1 else selectedIndex val listState: LazyListState = rememberLazyListState() LaunchedEffect(title, selectedId) { if (focusTargetIndex >= 0) { runCatching { listState.scrollToItem(focusTargetIndex) } - runCatching { initialFocus.requestFocus() } + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = initialFocus::requestFocus, + isFocused = { pickerHasFocus }, + ) } } @@ -1446,6 +1600,7 @@ fun TvSettingsPickerSheet( Box( modifier = Modifier .fillMaxSize() + .onFocusChanged { pickerHasFocus = it.hasFocus } .background(Color.Black.copy(alpha = 0.94f)), contentAlignment = Alignment.Center, ) { @@ -1553,7 +1708,7 @@ private fun TvSettingsPickerOptionRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun TvSettingsConfirmDialog( +internal fun TvSettingsConfirmDialog( title: String, message: String, confirmLabel: String, @@ -1564,7 +1719,15 @@ private fun TvSettingsConfirmDialog( // Default focus lands on Cancel so a stray OK press never triggers the // destructive action. val cancelFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } + var confirmHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + requestFocusUntilObserved( + maxAttempts = TvContentInitialFocusMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = cancelFocus::requestFocus, + isFocused = { confirmHasFocus }, + ) + } Dialog( onDismissRequest = onDismiss, @@ -1658,11 +1821,11 @@ private fun DialogButton( */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsGroup( +internal fun SettingsGroup( title: String, content: @Composable () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(SettingsGroupRowSpacing)) { Text( text = title.uppercase(), style = SettingsMonoHeaderStyle(), @@ -1711,9 +1874,21 @@ private fun SettingsRowTextStyle() = private val RowShape = RoundedCornerShape(10.dp) private val RowMaxWidth = 520.dp + +/** + * Gap between the rows (and the trailing footer) inside one [SettingsGroup]. + * + * Exposed rather than inlined because a pane that asks a focused row to pull + * its group's footer into view has to add this gap to the footer's measured + * height — see `TvDiagnosticsSettingsPane`. Two copies of the number would + * silently drift. + */ +internal val SettingsGroupRowSpacing = 6.dp // 42dp keeps the 16sp row text comfortably centered — audit 2026-07-20. private val RowHeight = 42.dp -private val SettingsBackground = Color(0xFF17181A) + +/** The one settings-surface ground color. Shared so no screen re-hardcodes it. */ +internal val SettingsBackground = Color(0xFF17181A) // tvOS destructive row colors: bright red at rest on black, deeper red on the // focused white platter (TVSettingsRailRowStyle). @@ -1801,12 +1976,13 @@ private fun SettingsAccountRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsValueRow( +internal fun SettingsValueRow( label: String, value: String, onClick: () -> Unit, focusRequester: FocusRequester? = null, enabled: Boolean = true, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1822,7 +1998,8 @@ private fun SettingsValueRow( // widthIn must precede fillMaxWidth: as the outer constraint it caps // the row at RowMaxWidth, and fillMaxWidth then stretches to that cap // (the reverse order lets fillMaxWidth's fixed constraints win). - modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + modifier = modifier + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .widthIn(max = RowMaxWidth) .fillMaxWidth() .height(RowHeight) @@ -1862,12 +2039,13 @@ private fun SettingsValueRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsActionRow( +internal fun SettingsActionRow( label: String, onClick: () -> Unit, destructive: Boolean = false, focusRequester: FocusRequester? = null, enabled: Boolean = true, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1883,7 +2061,8 @@ private fun SettingsActionRow( // widthIn must precede fillMaxWidth: as the outer constraint it caps // the row at RowMaxWidth, and fillMaxWidth then stretches to that cap // (the reverse order lets fillMaxWidth's fixed constraints win). - modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + modifier = modifier + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .widthIn(max = RowMaxWidth) .fillMaxWidth() .height(RowHeight) @@ -1920,12 +2099,13 @@ private fun SettingsActionRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsToggleRow( +internal fun SettingsToggleRow( label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit, focusRequester: FocusRequester? = null, enabled: Boolean = true, + modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1941,7 +2121,8 @@ private fun SettingsToggleRow( // widthIn must precede fillMaxWidth: as the outer constraint it caps // the row at RowMaxWidth, and fillMaxWidth then stretches to that cap // (the reverse order lets fillMaxWidth's fixed constraints win). - modifier = (focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + modifier = modifier + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) .widthIn(max = RowMaxWidth) .fillMaxWidth() .height(RowHeight) @@ -1974,7 +2155,7 @@ private fun SettingsToggleRow( @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = true) { +internal fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = true) { Row( modifier = Modifier .widthIn(max = RowMaxWidth) @@ -2007,13 +2188,34 @@ private fun SettingsInfoRow(label: String, value: String, singleLine: Boolean = } /** Non-focusable explanatory footer below a settings group (tvOS `TVSettingsFooter`). */ +/** + * Shown when the connected server predates the canonical settings API. + * + * The failure mode this replaces was a settings pane that looked normal but + * saved nothing: the profile preferences resolve to nothing, so the rows show + * defaults and every edit goes nowhere with no explanation. Playback is + * unaffected — it runs from this device's own settings. + */ +@Composable +private fun TvSettingsUpgradeRequiredNotice() { + SettingsGroup(title = "Server Update Needed") { + SettingsFooterText( + text = "This server is too old to store profile settings. Subtitle and metadata " + + "preferences below will not save until it is updated. Playback still works " + + "using this Android TV's own settings.", + ) + } +} + @Composable -private fun SettingsFooterText(text: String) { +internal fun SettingsFooterText(text: String, modifier: Modifier = Modifier) { Text( text = text, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp, lineHeight = 18.sp), color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - modifier = Modifier + // The modifier goes outermost so a caller measuring this footer sees the + // laid-out block, not the text before its width cap and padding apply. + modifier = modifier .widthIn(max = RowMaxWidth) .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 2.dp), @@ -2054,42 +2256,6 @@ private val PassOutThresholdOptions = listOf(0, 2, 3, 4, 5) // Up-Next prompt timing (seconds before end; 0 = at end). Mirrors tvOS. private val NextUpPromptOptions = listOf(0, 10, 30, 60, 120) -// Audio-language options mirror the phone: the stored value IS the display -// name (Default => "" locally), persisted to playerSettingsStore.audioLanguage. -private val AudioLanguages = listOf( - "" to "Default", - "English" to "English", - "Spanish" to "Spanish", - "French" to "French", - "German" to "German", - "Japanese" to "Japanese", - "Korean" to "Korean", - "Chinese" to "Chinese", - "Portuguese" to "Portuguese", - "Italian" to "Italian", - "Russian" to "Russian", -) - -private val SubtitleLanguages = listOf( - "" to "Off", - "en" to "English", - "es" to "Spanish", - "fr" to "French", - "de" to "German", - "ja" to "Japanese", - "ko" to "Korean", - "zh" to "Chinese", - "pt" to "Portuguese", - "it" to "Italian", - "ru" to "Russian", -) - -private fun audioLanguageLabel(wire: String): String = - AudioLanguages.firstOrNull { it.first == wire }?.second ?: "Default" - -private fun subtitleLanguageLabel(wire: String): String = - SubtitleLanguages.firstOrNull { it.first == wire }?.second ?: "Off" - private fun resumeRewindLabel(seconds: Int): String = if (seconds <= 0) "Off" else "${seconds}s" diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt index 006f78ac7..69d90caf6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsViewModel.kt @@ -6,10 +6,10 @@ import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore import org.prairieserver.prairie.common.settings.OverlayPrefsStore import org.prairieserver.prairie.common.settings.PlayerSettingsStore -import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface import org.prairieserver.prairie.model.auth.User -import org.prairieserver.prairie.model.auth.isActingAdmin -import org.prairieserver.prairie.model.profile.UpdateProfileRequest +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleBackgroundStylePreset import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset @@ -20,11 +20,8 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.repository.AuthRepository import org.prairieserver.prairie.repository.ProfileRepository import org.prairieserver.prairie.tv.data.preferences.LegacyTvPrefsMigration -import org.prairieserver.prairie.tv.data.preferences.PlaybackQuality import org.prairieserver.prairie.tv.data.preferences.SubtitleMode import org.prairieserver.prairie.tv.data.preferences.SubtitleSize -import org.prairieserver.prairie.update.AppUpdateChecker -import org.prairieserver.prairie.update.AppUpdateStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,9 +34,12 @@ import kotlinx.coroutines.launch /** * ViewModel for the TV settings screen. Server-managed device settings * flow exclusively through [PlayerSettingsStore] (mirror of iOS - * `PlayerSettings.shared`); profile-level subtitle prefs still go via - * [profileRepository]. [LegacyTvPrefsMigration] runs the one-time legacy - * `tv_prefs` → server import on first boot (sentinel-gated no-op after). + * `PlayerSettings.shared`); profile-level preferences go through + * [ProfileSettingsController], which writes them as canonical settings at + * `scope=profile` rather than as columns on the profile endpoint — the same + * path the phone uses, so the two screens cannot drift. + * [LegacyTvPrefsMigration] runs the one-time legacy `tv_prefs` → server + * import on first boot (sentinel-gated no-op after). * * Sign-out and switch-profile operations emit a one-shot [NavAction] * signal that the screen collects and forwards to the top-level NavHost. @@ -53,9 +53,8 @@ class TvSettingsViewModel( private val libraryPlaybackPrefsStore: LibraryPlaybackPrefsStore, private val overlayPrefsStore: OverlayPrefsStore, private val legacyTvPrefsMigration: LegacyTvPrefsMigration, + private val profileSettings: ProfileSettingsController, private val tvLibraryScopeStore: org.prairieserver.prairie.tv.data.preferences.TvLibraryScopeStore? = null, - private val appUpdateChecker: AppUpdateChecker, - private val appVersionName: String, ) : ViewModel() { enum class NavAction { SIGNED_OUT, SWITCH_PROFILE } @@ -69,12 +68,25 @@ class TvSettingsViewModel( val profileAvatar: String? = null, val serverUrl: String = "", val serverName: String = "", - val playbackQuality: PlaybackQuality = PlaybackQuality.Auto, + // Whether this server serves the canonical settings API at all. When + // it reports SERVER_UPGRADE_REQUIRED the pane explains that instead of + // showing rows whose edits go nowhere; playback is unaffected. + val settingsAvailability: ProfileSettingsController.Availability = + ProfileSettingsController.Availability.UNKNOWN, + // Quality is two orthogonal values behind one picker: + // playback.preferred_quality (resolution) and + // playback.max_bitrate_kbps (bandwidth; null = uncapped). The preset + // table is shared with the phone, so the two cannot drift. + val qualityResolution: String = QualityPresets.RESOLUTION_AUTO, + val maxBitrateKbps: Int? = null, val subtitleMode: SubtitleMode = SubtitleMode.Auto, val subtitleLanguage: String = "", + val subtitleLanguageSuggestions: List = emptyList(), // Metadata AI: preferred description/metadata language ("" = server default). val metadataLanguage: String = "", + val metadataLanguageSuggestions: List = emptyList(), val audioLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), val subtitleSize: SubtitleSize = SubtitleSize.Medium, val showForcedSubtitles: Boolean = true, // Full subtitle appearance + whether the device-scoped override is on. @@ -83,7 +95,7 @@ class TvSettingsViewModel( val effectiveSubtitleAppearance: SubtitleAppearance = SubtitleAppearance.DEFAULT, val subtitleUsesDeviceOverride: Boolean = false, val autoPlayNext: Boolean = true, - val autoSkipIntro: Boolean = false, + val introSkipMode: IntroSkipMode = IntroSkipMode.Default, val matchContentFrameRate: Boolean = false, val dolbyVisionEnabled: Boolean = true, val showAudiobooksTab: Boolean = false, @@ -97,43 +109,25 @@ class TvSettingsViewModel( // Seconds before the end of an episode to surface the Up-Next prompt // (0 = at the very end). Mirrors tvOS `nextUpPromptSeconds`. val nextUpPromptSeconds: Int = 10, - // Client admin is hidden for now even when the server would accept acting-admin. - val adminVisible: Boolean = false, val navAction: NavAction? = null, - val appVersionName: String = "", - val appUpdateStatus: AppUpdateStatus = AppUpdateStatus.Checking, ) - private val _uiState = MutableStateFlow(UiState(appVersionName = appVersionName)) + private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadUser() loadSettings() observePlayerSettings() - checkForAppUpdate() - } - - private fun checkForAppUpdate() { - viewModelScope.launch { - _uiState.update { - it.copy( - appVersionName = appVersionName, - appUpdateStatus = AppUpdateStatus.Checking, - ) - } - val status = appUpdateChecker.check(appVersionName) - _uiState.update { it.copy(appUpdateStatus = status) } - } } /** - * Loads the current user (and derives [UiState.adminVisible]). A transient - * failure here would silently drop the Admin dashboard entry for an acting - * admin — the fetch is what gates admin visibility — so retry a few times - * with a short backoff before surfacing [UiState.userError]. Only the final - * attempt's failure is reported; a flaky load recovers and keeps the Admin - * entry. Exposed publicly so a screen-level retry can also call it. + * Loads the current user, and the active profile that supplies the account + * header's name and avatar. A transient failure would blank that header, so + * retry a few times with a short backoff before surfacing + * [UiState.userError]. Only the final attempt's failure is reported; a + * flaky load recovers. Exposed publicly so a screen-level retry can also + * call it. */ fun loadUser() { viewModelScope.launch { @@ -142,7 +136,19 @@ class TvSettingsViewModel( val isLastAttempt = attempt == UserLoadMaxAttempts - 1 when (val r = authRepository.getCurrentUser()) { is ApiResult.Success -> { - val profile = profileRepository.getActiveProfile() + // Retried alongside /me. getActiveProfile collapses + // "network failed", "no active id" and "not found" into + // null, so without this a transient failure left the + // account header without a name or avatar for the life + // of this ViewModel. Bounded by the same attempt budget + // so it cannot become a poll. + var profile = profileRepository.getActiveProfile() + var profileAttempt = 1 + while (profile == null && profileAttempt < UserLoadMaxAttempts) { + delay(ProfileResolveRetryMs) + profile = profileRepository.getActiveProfile() + profileAttempt += 1 + } _uiState.update { it.copy( user = r.data, @@ -150,7 +156,6 @@ class TvSettingsViewModel( userError = null, profileName = profile?.name, profileAvatar = profile?.avatar, - adminVisible = shouldShowClientAdminSurface(isActingAdmin(r.data, profile)), ) } return@launch @@ -199,34 +204,91 @@ class TvSettingsViewModel( // mirrors them into _uiState. playerSettingsStore.refreshFromServer() - when (val profileResult = profileRepository.getActiveProfileResult()) { - is ApiResult.Success -> { - val profile = profileResult.data - _uiState.update { - it.copy( - subtitleMode = SubtitleMode.fromWire(profile.subtitleMode), - subtitleLanguage = profile.subtitleLanguage.orEmpty(), - metadataLanguage = profile.preferredMetadataLanguage.orEmpty(), - showForcedSubtitles = profile.showForcedSubtitles ?: true, - ) - } - } - is ApiResult.Error, is ApiResult.NetworkError -> Unit + loadProfileSettings() + } + } + + /** + * Resolves the profile-scoped preferences through the canonical settings + * API, and records whether this server speaks it at all. + * + * On [ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED] the + * values are left alone and the Subtitles pane explains why — rendering + * rows whose edits silently go nowhere is the failure this replaces. + * Playback keeps running from the device-scoped store. + */ + fun loadProfileSettings() { + viewModelScope.launch { + val result = profileSettings.load() + _uiState.update { state -> + val snapshot = result.snapshot ?: return@update state.copy( + settingsAvailability = result.availability, + ) + state.copy( + settingsAvailability = result.availability, + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + subtitleLanguage = snapshot.subtitleLanguage, + metadataLanguage = snapshot.metadataLanguage, + showForcedSubtitles = snapshot.showForcedSubtitles, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) } } } + /** + * Replaces the optimistic values with what the server actually resolves. + * + * A successful PUT stores the authored value; it does not make it + * effective. Policy can narrow it, and a device-scoped row for the same key + * outranks the profile row these setters write — so the screen would + * otherwise show a preference playback is not using. Skipped when a newer + * edit for the *same* field landed while the round trip was in flight, + * which the optimistic rollbacks guard the same way. + */ + private fun applyResolved( + snapshot: ProfileSettingsController.Snapshot?, + edited: String, + fieldOf: (ProfileSettingsController.Snapshot) -> String, + ) { + if (snapshot == null) return + if (fieldOf(snapshot) == edited) { + _uiState.update { + it.copy( + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } + return + } + _uiState.update { state -> + state.copy( + subtitleMode = SubtitleMode.fromWire(snapshot.subtitleMode), + subtitleLanguage = snapshot.subtitleLanguage, + metadataLanguage = snapshot.metadataLanguage, + showForcedSubtitles = snapshot.showForcedSubtitles, + audioLanguageSuggestions = snapshot.audioLanguageSuggestions, + subtitleLanguageSuggestions = snapshot.subtitleLanguageSuggestions, + metadataLanguageSuggestions = snapshot.metadataLanguageSuggestions, + ) + } + } + /** * Mirror device-scoped flows into UI state. The store is the single * source of truth — this just projects to the TV-specific UI types - * (PlaybackQuality, SubtitleSize). + * (the two quality axes, SubtitleSize). */ private fun observePlayerSettings() { viewModelScope.launch { combine( playerSettingsStore.preferredQualityFlow, + playerSettingsStore.maxBitrateKbpsFlow, playerSettingsStore.autoPlayNextFlow, - playerSettingsStore.autoSkipIntroFlow, + playerSettingsStore.introSkipModeFlow, playerSettingsStore.autoSkipCreditsFlow, playerSettingsStore.savedCustomSubtitleAppearanceFlow, playerSettingsStore.audioLanguageFlow, @@ -235,25 +297,30 @@ class TvSettingsViewModel( ) { values -> @Suppress("UNCHECKED_CAST") val quality = values[0] as String + val bitrate = values[1] as Int? @Suppress("UNCHECKED_CAST") - val autoPlay = values[1] as Boolean + val autoPlay = values[2] as Boolean @Suppress("UNCHECKED_CAST") - val skipIntro = values[2] as Boolean + val skipIntro = values[3] as IntroSkipMode @Suppress("UNCHECKED_CAST") - val skipCredits = values[3] as Boolean + val skipCredits = values[4] as Boolean @Suppress("UNCHECKED_CAST") - val appearance = values[4] as SubtitleAppearance + val appearance = values[5] as SubtitleAppearance @Suppress("UNCHECKED_CAST") - val audioLang = values[5] as String - val rewind = values[6] as Int - val threshold = values[7] as Int - Snapshot(quality, autoPlay, skipIntro, skipCredits, appearance, audioLang, rewind, threshold) + val audioLang = values[6] as String + val rewind = values[7] as Int + val threshold = values[8] as Int + Snapshot( + quality, bitrate, autoPlay, skipIntro, skipCredits, + appearance, audioLang, rewind, threshold, + ) }.collect { snap -> _uiState.update { it.copy( - playbackQuality = PlaybackQuality.fromWire(snap.quality), + qualityResolution = snap.quality, + maxBitrateKbps = snap.maxBitrateKbps, autoPlayNext = snap.autoPlay, - autoSkipIntro = snap.skipIntro, + introSkipMode = snap.skipIntro, autoSkipCredits = snap.skipCredits, subtitleSize = snap.appearance.fontSize.toTvSubtitleSize(), subtitleAppearance = snap.appearance, @@ -324,39 +391,66 @@ class TvSettingsViewModel( .ifBlank { url } } - fun onPlaybackQualityChanged(value: PlaybackQuality) { - viewModelScope.launch { playerSettingsStore.setPreferredQuality(value.wireValue) } + /** + * Applies one quality preset — the two axes it decomposes into. The + * compound legacy spellings ("1080p-high") are never written. + */ + fun onQualityPresetSelected(presetId: String) { + val preset = QualityPresets.byId(presetId) ?: return + viewModelScope.launch { + playerSettingsStore.setQuality(preset.resolution, preset.bitrateKbps) + } } + // The four profile preferences below are canonical settings written at + // scope=profile, one key per edit. They used to be named columns sent + // together on PUT /profiles/{id}, where one failed write reverted all + // three. Each applies optimistically and rolls back only if state still + // holds the value it wrote — a newer edit mid-request wins. + fun onSubtitleModeChanged(value: SubtitleMode) { - val previousState = _uiState.value + val previous = _uiState.value.subtitleMode _uiState.update { it.copy(subtitleMode = value) } - persistProfileSubtitleSettings(previousState) + viewModelScope.launch { + val result = profileSettings.setSubtitleMode(value.wireValue) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleMode == value) it.copy(subtitleMode = previous) else it + } + } else { + applyResolved(result.snapshot, edited = value.wireValue) { it.subtitleMode } + } + } } fun onMetadataLanguageChanged(value: String) { val previous = _uiState.value.metadataLanguage _uiState.update { it.copy(metadataLanguage = value) } viewModelScope.launch { - when ( - profileRepository.updateActiveProfile( - UpdateProfileRequest(preferredMetadataLanguage = value.ifBlank { null }) - ) - ) { - is ApiResult.Success -> Unit - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { current -> - if (current.metadataLanguage == value) current.copy(metadataLanguage = previous) else current - } + val result = profileSettings.setMetadataLanguage(value) + if (!result.succeeded) { + _uiState.update { current -> + if (current.metadataLanguage == value) current.copy(metadataLanguage = previous) else current } + } else { + applyResolved(result.snapshot, edited = value) { it.metadataLanguage } } } } fun onSubtitleLanguageChanged(value: String) { - val previousState = _uiState.value + val previous = _uiState.value.subtitleLanguage _uiState.update { it.copy(subtitleLanguage = value) } - persistProfileSubtitleSettings(previousState) + viewModelScope.launch { + val result = profileSettings.setSubtitleLanguage(value) + if (!result.succeeded) { + _uiState.update { + if (it.subtitleLanguage == value) it.copy(subtitleLanguage = previous) else it + } + } else { + applyResolved(result.snapshot, edited = value) { it.subtitleLanguage } + } + } } /** @@ -369,19 +463,25 @@ class TvSettingsViewModel( } fun onShowForcedSubtitlesChanged(enabled: Boolean) { - val previousState = _uiState.value + val previous = _uiState.value.showForcedSubtitles _uiState.update { it.copy(showForcedSubtitles = enabled) } - persistProfileSubtitleSettings(previousState) - } - - fun onSubtitleSizeChanged(value: SubtitleSize) { viewModelScope.launch { - val current = playerSettingsStore.subtitleAppearanceFlow.first() - val updated = current.copy(fontSize = value.toFontSizePreset()) - playerSettingsStore.setSubtitleAppearance(updated) + val result = profileSettings.setShowForcedSubtitles(enabled) + if (!result.succeeded) { + _uiState.update { + if (it.showForcedSubtitles == enabled) it.copy(showForcedSubtitles = previous) else it + } + } else { + applyResolved(result.snapshot, edited = enabled.toString()) { + it.showForcedSubtitles.toString() + } + } } } + fun onSubtitleSizeChanged(value: SubtitleSize) = + editAppearance { it.copy(fontSize = value.toFontSizePreset()) } + /** * Commit a full subtitle-appearance value (device-scoped). The Appearance * picker rows build [next] by copying the current appearance and changing @@ -403,6 +503,10 @@ class TvSettingsViewModel( viewModelScope.launch { val current = playerSettingsStore.subtitleAppearanceFlow.first() playerSettingsStore.setSubtitleAppearance(transform(current)) + // The granular subtitle.* fields are client-local — the contract + // carries appearance as one object — so a per-field edit only + // reaches the server once it is projected into the composite. + playerSettingsStore.flushProjectedSubtitleAppearance() } } @@ -464,8 +568,8 @@ class TvSettingsViewModel( viewModelScope.launch { playerSettingsStore.setDvProfile7HDR10Fallback(value) } } - fun onAutoSkipIntroChanged(value: Boolean) { - viewModelScope.launch { playerSettingsStore.setAutoSkipIntro(value) } + fun onIntroSkipModeChanged(value: IntroSkipMode) { + viewModelScope.launch { playerSettingsStore.setIntroSkipMode(value) } } fun onAutoSkipCreditsChanged(value: Boolean) { @@ -523,40 +627,6 @@ class TvSettingsViewModel( _uiState.update { it.copy(navAction = null) } } - private fun persistProfileSubtitleSettings(previousState: UiState) { - val state = _uiState.value - viewModelScope.launch { - when ( - profileRepository.updateActiveProfile( - UpdateProfileRequest( - subtitleLanguage = state.subtitleLanguage.ifBlank { null }, - subtitleMode = state.subtitleMode.wireValue, - showForcedSubtitles = state.showForcedSubtitles, - ) - ) - ) { - is ApiResult.Success -> Unit - is ApiResult.Error, is ApiResult.NetworkError -> { - _uiState.update { current -> - if ( - current.subtitleLanguage == state.subtitleLanguage && - current.subtitleMode == state.subtitleMode && - current.showForcedSubtitles == state.showForcedSubtitles - ) { - current.copy( - subtitleLanguage = previousState.subtitleLanguage, - subtitleMode = previousState.subtitleMode, - showForcedSubtitles = previousState.showForcedSubtitles, - ) - } else { - current - } - } - } - } - } - } - private fun SubtitleSize.toFontSizePreset(): SubtitleFontSizePreset = when (this) { SubtitleSize.Small -> SubtitleFontSizePreset.Small SubtitleSize.Medium -> SubtitleFontSizePreset.Medium @@ -575,8 +645,9 @@ class TvSettingsViewModel( private data class Snapshot( val quality: String, + val maxBitrateKbps: Int?, val autoPlay: Boolean, - val skipIntro: Boolean, + val skipIntro: IntroSkipMode, val skipCredits: Boolean, val appearance: SubtitleAppearance, val audioLanguage: String, @@ -586,8 +657,11 @@ class TvSettingsViewModel( private companion object { // Retry the user load a few times before surfacing an error, so a - // flaky fetch doesn't silently strip the Admin entry from an admin. + // flaky fetch doesn't blank the account header. const val UserLoadMaxAttempts = 3 + + /** Gap between profile lookups while the profile is unresolved. */ + const val ProfileResolveRetryMs = 400L const val UserLoadRetryDelayMs = 400L } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsComponents.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsComponents.kt new file mode 100644 index 000000000..4e4e168fb --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsComponents.kt @@ -0,0 +1,136 @@ +package org.prairieserver.prairie.tv.ui.screens.settings.diagnostics + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import java.text.DateFormat +import java.util.Date +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsBackground +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent + +/** + * Chrome shared by the standalone diagnostics screens — the pending-report + * detail route, the crash prompt, and its confirmation. + * + * The diagnostics *settings* surface no longer uses any of this: it renders + * inside the Settings detail pane out of the shared settings row primitives + * (see [TvDiagnosticsSettingsPane]). What is left here is modal/full-screen + * chrome, where a taller row and a bigger focus scale are appropriate. + */ +@Composable +internal fun TvDiagnosticsPage(title: String, content: @Composable () -> Unit) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SettingsBackground) + .padding(horizontal = 64.dp, vertical = 38.dp), + ) { + Text(title, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(22.dp)) + Column(Modifier.widthIn(max = 760.dp), content = { content() }) + } + } +} + +@Composable +internal fun TvDiagnosticsSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text( + title, + style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 1.4.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + content() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun TvDiagnosticsAction( + label: String, + value: String? = null, + enabled: Boolean = true, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + // A disabled TV Surface still takes focus, so a dead row that paints its + // label at full white reads as live. Say so in the color. + val labelColor = (if (focused) FocusedContent else Color.White) + .copy(alpha = if (enabled) 1f else 0.45f) + val valueColor = (if (focused) FocusedContent else MaterialTheme.colorScheme.onSurfaceVariant) + .copy(alpha = if (enabled) 1f else 0.45f) + Surface( + onClick = onClick, + enabled = enabled, + interactionSource = interaction, + colors = ClickableSurfaceDefaults.colors( + containerColor = Color.White.copy(alpha = 0.06f), + contentColor = Color.White, + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + pressedContainerColor = FocusedContainer, + pressedContentColor = FocusedContent, + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), + modifier = modifier.fillMaxWidth().height(52.dp), + ) { + Row( + Modifier.fillMaxSize().padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, modifier = Modifier.weight(1f), color = labelColor) + value?.let { Text(it, color = valueColor) } + } + } +} + +/** tvOS `typeTitle(for:)` — used by the prompt and the report detail screen. */ +internal fun org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.tvDisplayName(): String = + tvDiagnosticsReportTypeTitle(this) + +internal fun tvFormatBytes(bytes: Long): String = when { + bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" +} + +internal fun tvFormatDate(epochMs: Long): String = + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(epochMs)) + +/** Compact form for list rows, where the full medium date will not fit. */ +internal fun tvFormatShortDateTime(epochMs: Long): String = + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(epochMs)) + +/** + * Never handed to a browser: an Android TV box is not guaranteed to have one. + * Shown as footer text, and as a QR the viewer can scan from the pane's + * Privacy Policy row. + */ +internal const val PRIVACY_POLICY_URL = "https://prairieserver.org/privacy" diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt index 87a55cfbf..8075f067a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt @@ -14,17 +14,25 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import kotlinx.coroutines.delay import org.prairieserver.prairie.common.diagnostics.DiagnosticsPrompt +import org.prairieserver.prairie.tv.ui.focus.TvModalRestoreMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved +import org.prairieserver.prairie.tv.ui.focus.tvModalFocusBoundary @Composable fun TvDiagnosticsPromptScreen( @@ -33,46 +41,106 @@ fun TvDiagnosticsPromptScreen( onSend: () -> Unit, onAlwaysSend: () -> Unit, onDontSend: () -> Unit, + allowAlwaysSend: Boolean = true, ) { var confirmAlways by remember { mutableStateOf(false) } val safeFocus = remember(prompt.reportId, confirmAlways) { FocusRequester() } - LaunchedEffect(prompt.reportId, confirmAlways) { runCatching { safeFocus.requestFocus() } } - BackHandler(onBack = onDontSend) - Surface(Modifier.fillMaxSize()) { - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.92f)), - contentAlignment = Alignment.Center, - ) { - Column( - Modifier.width(560.dp).padding(28.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + var modalHasFocus by remember(prompt.reportId, confirmAlways) { + mutableStateOf(false) + } + + // This is a separate window, but it is still composed while the crashed + // route is settling. Retry until focus acquisition is observed: an accepted + // request is not evidence that the safe default actually received focus. + LaunchedEffect(prompt.reportId, confirmAlways) { + requestFocusUntilObserved( + maxAttempts = TvModalRestoreMaxAttempts, + awaitAttempt = { delay(60L) }, + requestFocus = { + safeFocus.requestFocus() + true + }, + isFocused = { modalHasFocus }, + ) + } + + Dialog( + onDismissRequest = onDontSend, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + ), + ) { + Surface(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.92f)) + .onFocusChanged { modalHasFocus = it.hasFocus } + .tvModalFocusBoundary(), + contentAlignment = Alignment.Center, ) { - if (confirmAlways) { - Text("Always send crash reports?", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) - Text("Future eligible reports may upload automatically until you change this setting.") - TvDiagnosticsAction("Always send", onClick = onAlwaysSend) - TvDiagnosticsAction( - "Cancel", - onClick = { confirmAlways = false }, - modifier = Modifier.focusRequester(safeFocus), - ) - } else { - Text("Prairie encountered a problem", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) - Text( - if (prompt.reportCount == 1) { - "A ${prompt.reportType.tvDisplayName().lowercase()} report is ready. Review it before deciding whether to send it." - } else { - "${prompt.reportCount} diagnostics reports are ready. Review them before deciding whether to send them." - }, - ) - TvDiagnosticsAction("Review", onClick = onReview) - TvDiagnosticsAction("Send", onClick = onSend) - TvDiagnosticsAction("Always send", onClick = { confirmAlways = true }) - TvDiagnosticsAction( - "Don't send", - onClick = onDontSend, - modifier = Modifier.focusRequester(safeFocus), - ) + Column( + Modifier.width(560.dp).padding(28.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (confirmAlways && allowAlwaysSend) { + Text( + "Always send crash reports?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Future eligible reports may upload automatically " + + "until you change this setting.", + ) + TvDiagnosticsAction("Always send", onClick = onAlwaysSend) + TvDiagnosticsAction( + "Cancel", + onClick = { confirmAlways = false }, + modifier = Modifier.focusRequester(safeFocus), + ) + } else { + Text( + "Silo encountered a problem", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + if (prompt.reportCount == 1) { + "A ${prompt.reportType.tvDisplayName().lowercase()} " + + "report is ready. Review it before deciding " + + "whether to send it." + } else { + "${prompt.reportCount} diagnostics reports are " + + "ready. Review them before deciding whether " + + "to send them." + }, + ) + if (!allowAlwaysSend) { + Text( + "The report includes the Silo app version and build, Android version, device " + + "model, crash details, and diagnostic logs. Its pseudonymous credential is " + + "not linked to an account on your self-hosted server. Username, email, " + + "profile, server address, and playback session IDs are omitted. It never " + + "sends automatically and may be retained for up to 30 days.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TvDiagnosticsAction("Review", onClick = onReview) + TvDiagnosticsAction("Send", onClick = onSend) + if (allowAlwaysSend) { + TvDiagnosticsAction( + "Always send", + onClick = { confirmAlways = true }, + ) + } + TvDiagnosticsAction( + "Don't send", + onClick = onDontSend, + modifier = Modifier.focusRequester(safeFocus), + ) + } } } } @@ -88,18 +156,41 @@ internal fun TvDiagnosticsConfirmation( onDismiss: () -> Unit, ) { val cancelFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } + var confirmationHasFocus by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + requestFocusUntilObserved( + maxAttempts = TvModalRestoreMaxAttempts, + awaitAttempt = { withFrameNanos { } }, + requestFocus = cancelFocus::requestFocus, + isFocused = { confirmationHasFocus }, + ) + } BackHandler(onBack = onDismiss) Surface(Modifier.fillMaxSize()) { Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.9f)), + Modifier + .fillMaxSize() + .onFocusChanged { confirmationHasFocus = it.hasFocus } + .background(Color.Black.copy(alpha = 0.9f)), contentAlignment = Alignment.Center, ) { - Column(Modifier.width(520.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) + Column( + Modifier.width(520.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + ) Text(message) TvDiagnosticsAction(confirmLabel, onClick = onConfirm) - TvDiagnosticsAction("Cancel", onClick = onDismiss, modifier = Modifier.focusRequester(cancelFocus)) + TvDiagnosticsAction( + "Cancel", + onClick = onDismiss, + modifier = Modifier.focusRequester(cancelFocus), + ) } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt index d176e0c5d..d0a6e242c 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt @@ -21,6 +21,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import org.koin.compose.viewmodel.koinViewModel import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadDecision @Composable @@ -34,7 +35,9 @@ fun TvDiagnosticsReportScreen( var confirmDelete by remember { mutableStateOf(false) } var uploading by remember { mutableStateOf(false) } var sentShortId by remember { mutableStateOf(null) } + var sentState by remember { mutableStateOf("processing") } var uploadNotice by remember { mutableStateOf(null) } + var uploadNoticeIsError by remember { mutableStateOf(true) } BackHandler(onBack = onBack) TvDiagnosticsPage(title = "Report details") { val shortId = sentShortId @@ -44,16 +47,17 @@ fun TvDiagnosticsReportScreen( Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text("Report sent", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold) TvReportLine("Reference ID", shortId) + TvReportLine("Processing state", sentState.replace('_', ' ')) Text( - "The report was removed from this device once your server received a copy. " + - "Share the reference ID with your server admin so they can find it.", + "The report was removed from this device after the destination accepted a copy. " + + "Share the reference ID with Silo Diagnostics or your server admin.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) TvDiagnosticsAction(label = "Done", onClick = onBack) } } else if (report == null) { if (uploading) { - Text("Sending report to your server…") + Text("Sending report…") } else { Text("This report is no longer on this device.") } @@ -65,8 +69,17 @@ fun TvDiagnosticsReportScreen( } item { TvReportLine("Evidence", tvFormatBytes(report.evidenceBytes)) - TvReportLine("Destination", report.destinationServerInstanceId) - TvReportLine("Captured profile", report.capturedProfileId ?: "Account scoped") + TvReportLine( + "Destination", + if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "Prairie Diagnostics" + } else { + report.destinationServerInstanceId + }, + ) + if (report.destinationKind == DiagnosticsDestinationKind.SELF_HOSTED) { + TvReportLine("Captured profile", report.capturedProfileId ?: "Account scoped") + } TvReportLine("Expires", tvFormatDate(report.expiresAtEpochMs)) TvReportLine("Upload state", report.uploadStatus.name.lowercase().replace('_', ' ')) report.uploadErrorCode?.let { TvReportLine("Last error", it) } @@ -82,12 +95,19 @@ fun TvDiagnosticsReportScreen( Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { if (uploading) { Text( - "Sending report to your server…", + "Sending report…", color = MaterialTheme.colorScheme.onSurfaceVariant, ) } uploadNotice?.let { notice -> - Text(notice, color = MaterialTheme.colorScheme.error) + Text( + notice, + color = if (uploadNoticeIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { TvDiagnosticsAction( @@ -99,8 +119,18 @@ fun TvDiagnosticsReportScreen( viewModel.upload(report.id) { decision -> uploading = false when (decision) { - is DiagnosticsUploadDecision.Uploaded -> sentShortId = decision.shortId - else -> uploadNotice = tvUploadKeptMessage(decision) + is DiagnosticsUploadDecision.Uploaded -> { + sentShortId = decision.shortId + sentState = decision.state.wireValue + } + is DiagnosticsUploadDecision.HostedProcessing -> { + uploadNoticeIsError = false + uploadNotice = tvUploadKeptMessage(decision) + } + else -> { + uploadNoticeIsError = true + uploadNotice = tvUploadKeptMessage(decision) + } } } }, @@ -121,7 +151,12 @@ fun TvDiagnosticsReportScreen( if (confirmDelete && report != null) { TvDiagnosticsConfirmation( title = "Delete this report?", - message = "The local evidence will be permanently removed from this device.", + message = if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "The local evidence will be removed from this device. If this report was already submitted, " + + "its copy in Silo Diagnostics will also be permanently deleted." + } else { + "The local evidence will be permanently removed from this device." + }, confirmLabel = "Delete", onConfirm = { confirmDelete = false @@ -134,6 +169,8 @@ fun TvDiagnosticsReportScreen( internal fun tvUploadKeptMessage(decision: DiagnosticsUploadDecision): String = when (decision) { is DiagnosticsUploadDecision.Uploaded -> "" // handled by the caller + is DiagnosticsUploadDecision.HostedProcessing -> + "Report ${decision.shortId} was accepted and is still processing. It will be checked again automatically." DiagnosticsUploadDecision.KeptRetryable -> "The upload didn't go through. The report stays on this device to try again later." DiagnosticsUploadDecision.KeptIdentityChanged -> diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt new file mode 100644 index 000000000..d12fe81a7 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt @@ -0,0 +1,613 @@ +package org.prairieserver.prairie.tv.ui.screens.settings.diagnostics + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind +import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState +import org.prairieserver.prairie.common.diagnostics.TimedCaptureStatus +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.TvFrameRelocationMaxAttempts +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics +import org.prairieserver.prairie.tv.ui.screens.auth.QrCodePanel +import org.prairieserver.prairie.tv.ui.screens.settings.PickerOption +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsActionRow +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsFooterText +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsGroup +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsGroupRowSpacing +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsInfoRow +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsToggleRow +import org.prairieserver.prairie.tv.ui.screens.settings.SettingsValueRow +import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsConfirmDialog +import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsPickerSheet +import org.prairieserver.prairie.tv.ui.theme.Spacing + +/** + * Diagnostics as a Settings category, rendered inline in the detail pane — + * modeled on `iosApp/.../tvOS/Screens/Settings/TVDiagnosticsSettingsPane.swift`. + * + * Two things this replaces are worth remembering. + * + * It used to be a top-level route outside `TvMainShell`, so the surface had no + * top bar, no on-screen Back, and no relationship to the rest of Settings. + * + * The destination choice ("Prairie Diagnostics" vs "This Prairie server") used to be + * a pair of inline rows above the consent ladder, and a hand-rolled + * `onPreviewKeyEvent` ladder swallowed Up at the top of that ladder — the rows + * rendered but no D-pad press could ever reach them. tvOS does not have that + * problem because both choices live behind a picker row; so do they now, which + * deletes the ladder rather than repairing it. + * + * ## Section order, and why read-only rows still stay out of the focus graph + * + * Read-only rows are deliberately not focus stops (tvOS behaviour): the D-pad + * only lands on controls that act. The cost is that Compose's bring-into-view + * scrolls just far enough to reveal the *focused* node, so read-only content is + * only ever seen as a side effect of scrolling to some control. Two defects on + * a Shield came straight out of that (2026-08-15): + * + * - FEATURE STATE scrolled off the top and could never be recovered — the + * first focus stop sat below it, so coming back up stopped as soon as that + * row was visible. + * - One Down press jumped ~320dp from CAPTURE's last row to MANUAL REPORT, + * because the privacy footer, the empty PENDING REPORTS row and the whole + * SENT HISTORY block lay between them with no focus stop in the middle. + * + * The order below fixes both by grouping sections by kind rather than + * interleaving them: **read-only status first, every control next, read-only + * log last.** + * + * 1. FEATURE STATE — read-only + * 2. PENDING REPORTS — read-only when empty, focus stops when populated + * 3. CAPTURE — focus stops (+ privacy footer) + * 4. MANUAL REPORT — focus stop (+ footer) + * 5. TIMED CAPTURE — focus stop (+ footer); Android-only, no tvOS twin + * 6. SENT HISTORY — read-only + * + * That makes the three control sections contiguous, so the only read-only run + * left between two focus stops is CAPTURE's own privacy footer. It also + * replaces the previous deliberate deviation (SENT HISTORY hoisted above MANUAL + * REPORT so trailing content could not be stranded) — [tvRevealsListContext] + * now carries that guarantee instead, which frees SENT HISTORY to sit where + * tvOS puts it, after the controls. + * + * Nothing here re-introduces a key ladder: every scroll is a `bringIntoView` + * request on a row that already has focus. + */ +@Composable +internal fun TvDiagnosticsSettingsPane( + state: DiagnosticsUiState, + serverName: String, + firstFocusRequester: FocusRequester, + onSetDestination: (DiagnosticsDestinationKind) -> Unit, + onSetConsent: (DiagnosticsConsentMode) -> Unit, + onSetDebugLogging: (Boolean) -> Unit, + onCaptureNow: () -> Unit, + onStartTimedCapture: () -> Unit, + onStopTimedCapture: () -> Unit, + onCancelTimedCapture: () -> Unit, + onReportSelected: (String) -> Unit, +) { + // The crash prompt used to be suppressed by route (`TvRoute.Diagnostics`). + // Now that this is a pane inside Main, presence is the signal — otherwise + // the prompt reopens on top of the very screen the viewer opened to read + // about it. + DisposableEffect(Unit) { + TvDiagnosticsSurfacePresence.enter() + onDispose { TvDiagnosticsSurfacePresence.leave() } + } + + var activePicker by remember { mutableStateOf(null) } + var confirmAlways by remember { mutableStateOf(false) } + var showPrivacyPolicy by remember { mutableStateOf(false) } + val model = tvDiagnosticsScreenModel(state) + val effectiveConsent = tvDiagnosticsEffectiveConsent(state.consent, state.allowsAutomaticUpload) + val debugLoggingApplies = state.consent != DiagnosticsConsentMode.NEVER + val capturing = state.timedCapture.status == TimedCaptureStatus.ACTIVE + val now = remember(state.pending) { System.currentTimeMillis() } + + // Every reveal below is bounded by the list's own viewport, so a request can + // never be taller than the container — a rect that straddles both edges is + // one Compose declines to scroll at all. + var viewportPx by remember { mutableIntStateOf(0) } + // Measured rather than assumed: footer height depends on how the prose wraps + // at the current width and font scale, and both vary by device. + var privacyFooterPx by remember { mutableIntStateOf(0) } + var manualFooterPx by remember { mutableIntStateOf(0) } + val groupRowGapPx = with(LocalDensity.current) { SettingsGroupRowSpacing.roundToPx() } + val pendingOwnsFirstFocus = tvDiagnosticsPendingOwnsFirstFocus(state.pending.size) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { viewportPx = it.height }, + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = Spacing.xxxl), + ) { + item { + SettingsGroup(title = "Feature State") { + SettingsInfoRow( + label = "Status", + value = tvDiagnosticsStatusTitle(state.availability), + ) + SettingsInfoRow( + label = "Destination", + value = tvDiagnosticsDestinationName(state.destinationKind, serverName), + ) + } + } + item { + SettingsGroup(title = tvDiagnosticsPendingHeader(state.pending.size)) { + if (state.pending.isEmpty()) { + // tvOS shows the empty state outright. Omitting the section + // left a clean device with no sign it had been checked. + SettingsInfoRow(label = "Reports", value = "None") + } else { + state.pending.forEachIndexed { index, report -> + SettingsValueRow( + label = tvDiagnosticsReportTypeTitle(report.type), + value = tvFormatShortDateTime(report.capturedAtEpochMs) + + " · " + tvDiagnosticsExpiryLabel(report.expiresAtEpochMs, now), + onClick = { onReportSelected(report.id) }, + // A waiting report is the most actionable thing in + // the pane, so it owns entry focus while it exists. + focusRequester = if (index == 0) firstFocusRequester else null, + modifier = if (index == 0) { + Modifier.tvRevealsListContext(viewportPx, abovePx = viewportPx) + } else { + Modifier + }, + ) + } + } + } + } + item { + SettingsGroup(title = "Capture") { + SettingsValueRow( + label = "Send Reports To", + value = tvDiagnosticsDestinationTitle(state.destinationKind), + onClick = { activePicker = TvDiagnosticsPicker.Destination }, + focusRequester = if (pendingOwnsFirstFocus) null else firstFocusRequester, + // The pane's first focus stop with no pending report, so it + // is the one that has to drag FEATURE STATE back into view + // (tvOS `.focused(detailFocus, .top)`). + modifier = if (pendingOwnsFirstFocus) { + Modifier + } else { + Modifier.tvRevealsListContext(viewportPx, abovePx = viewportPx) + }, + ) + // Under consent NEVER nothing is logged, so the toggle cannot + // apply: structural, i.e. out of the focus graph rather than a + // dead D-pad stop (see TvControlEnablement). + SettingsToggleRow( + label = "Debug Logging", + checked = state.debugLogging, + onCheckedChange = onSetDebugLogging, + enabled = debugLoggingApplies, + modifier = Modifier + .tvControlSemantics(TvControlState.structural(debugLoggingApplies)) + .dimWhenDisabled(debugLoggingApplies), + ) + SettingsValueRow( + label = "Crash Reports", + value = tvDiagnosticsConsentTitle(effectiveConsent), + onClick = { activePicker = TvDiagnosticsPicker.Consent }, + // Last focus stop before the privacy text it qualifies, so + // it shows that text instead of leaving it to be flown past + // on the way to MANUAL REPORT. + modifier = Modifier.tvRevealsListContext( + viewportPx = viewportPx, + belowPx = groupRowGapPx + privacyFooterPx, + ), + ) + SettingsFooterText( + text = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { + "Reports include the Silo app version and build, Android version, device model, " + + "crash details, and diagnostic logs you review. A pseudonymous installation " + + "credential is not linked to an account on your self-hosted server. Username, " + + "email, profile, server address, and playback session IDs are omitted. Reports " + + "are never sent automatically and may be retained for up to " + + "${state.retentionDays} days. Full policy: $PRIVACY_POLICY_URL" + } else { + "Crash report consent is tied to this server account. Debug logging is a " + + "setting for this Android TV." + }, + modifier = Modifier.onSizeChanged { privacyFooterPx = it.height }, + ) + if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { + // The address alone is not reachable with a remote: footer + // text is deliberately outside the focus graph, so it can + // neither be activated nor copied. Keep the action row the + // hosted consent surface used to have. + SettingsActionRow( + label = "Privacy Policy", + onClick = { showPrivacyPolicy = true }, + ) + } + } + } + item { + SettingsGroup(title = "Manual Report") { + // Availability can come back (the server reconnects), so these + // stay in the focus graph — transient, not structural. They + // only have to *look* dead, which they previously did not. + SettingsActionRow( + label = "Send Diagnostics Now", + onClick = onCaptureNow, + enabled = model.canCapture && !capturing, + modifier = Modifier + .tvRevealsListContext( + viewportPx = viewportPx, + belowPx = if (state.debugLogging) 0 else groupRowGapPx + manualFooterPx, + ) + .dimWhenDisabled(model.canCapture && !capturing), + ) + if (!state.debugLogging) { + SettingsFooterText( + text = "Debug logging is off. This report contains only the last few minutes " + + "of basic logs.", + modifier = Modifier.onSizeChanged { manualFooterPx = it.height }, + ) + } + } + } + // Android-only: tvOS has no timed capture. It sits with MANUAL REPORT + // because both are "capture something now" actions, which is also what + // keeps the control sections contiguous. + item { + SettingsGroup(title = "Timed Capture") { + if (capturing) { + SettingsActionRow(label = "Stop & Review", onClick = onStopTimedCapture) + SettingsActionRow( + label = "Cancel Capture", + onClick = onCancelTimedCapture, + destructive = true, + modifier = Modifier.tvRevealsListContext(viewportPx, belowPx = viewportPx), + ) + // Explainers sit below their controls here, as they do in + // CAPTURE and MANUAL REPORT. Above the buttons this one was + // pushed off screen by the reveal that shows SENT HISTORY. + SettingsFooterText( + text = "Capture is running. Reproduce the issue, then stop to review.", + ) + } else { + SettingsActionRow( + label = "Start Diagnostic Capture", + onClick = onStartTimedCapture, + enabled = model.canCapture, + // The pane's last focus stop in every state, so it owns + // revealing everything that trails it. + modifier = Modifier + .tvRevealsListContext(viewportPx, belowPx = viewportPx) + .dimWhenDisabled(model.canCapture), + ) + SettingsFooterText( + text = "Records logs until you stop it, then opens the report for review.", + ) + } + } + } + item { + SettingsGroup(title = "Sent History") { + if (state.sentHistory.isEmpty()) { + SettingsInfoRow(label = "Reports", value = "None") + } else { + state.sentHistory.take(TvDiagnosticsSentHistoryLimit).forEach { sent -> + SettingsInfoRow( + label = sent.shortId, + value = tvFormatShortDateTime(sent.sentAtEpochMs), + ) + } + SettingsFooterText( + text = "Sent reports are removed from this device once the destination has a " + + "copy. Use the reference ID when asking for help.", + ) + } + } + } + } + + when (activePicker) { + TvDiagnosticsPicker.Destination -> TvSettingsPickerSheet( + title = "Send Reports To", + options = TvDiagnosticsDestinations.map { + PickerOption(it.name, tvDiagnosticsDestinationTitle(it)) + }, + selectedId = state.destinationKind.name, + onSelect = { id -> + activePicker = null + TvDiagnosticsDestinations.firstOrNull { it.name == id }?.let(onSetDestination) + }, + onDismiss = { activePicker = null }, + ) + TvDiagnosticsPicker.Consent -> TvSettingsPickerSheet( + title = "Crash Reports", + options = tvDiagnosticsConsentOptions(state.allowsAutomaticUpload).map { + PickerOption(it.name, tvDiagnosticsConsentTitle(it)) + }, + selectedId = effectiveConsent.name, + onSelect = { id -> + activePicker = null + val requested = DiagnosticsConsentMode.entries.firstOrNull { it.name == id } + ?: return@TvSettingsPickerSheet + if (tvDiagnosticsConsentAction(state.consent, requested).requiresConfirmation) { + confirmAlways = true + } else { + onSetConsent(requested) + } + }, + onDismiss = { activePicker = null }, + ) + null -> Unit + } + + if (confirmAlways && state.allowsAutomaticUpload) { + TvSettingsConfirmDialog( + title = "Always send crash reports?", + message = "Future eligible reports may upload automatically until you change this setting.", + confirmLabel = "Always Send", + onConfirm = { + confirmAlways = false + onSetConsent(DiagnosticsConsentMode.ALWAYS) + }, + onDismiss = { confirmAlways = false }, + ) + } + + if (showPrivacyPolicy) { + TvPrivacyPolicyDialog(onDismiss = { showPrivacyPolicy = false }) + } +} + +/** + * The policy itself is a web page, and an Android TV box is not guaranteed to + * have a browser — nor is a TV a comfortable place to read one. So the action + * hands the address to a device that is: the same QR idiom the login and + * pairing screens use, with the URL spelled out for anyone typing it manually. + */ +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun TvPrivacyPolicyDialog(onDismiss: () -> Unit) { + BackHandler(onBack = onDismiss) + val closeFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + // Relocation, not acquisition: the dialog window already holds focus, + // so a miss only costs the viewer a Back press instead of a Select. + repeat(TvFrameRelocationMaxAttempts) { + withFrameNanos { } + if (closeFocus.claimFocusOrReport(target = "privacy_policy", action = "open")) { + return@LaunchedEffect + } + } + } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.86f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(360.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Privacy Policy", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Scan this code with your phone, or type the address below, to read " + + "the full policy.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + QrCodePanel(content = PRIVACY_POLICY_URL, size = 160.dp) + Text( + text = PRIVACY_POLICY_URL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + SettingsActionRow( + label = "Done", + onClick = onDismiss, + focusRequester = closeFocus, + ) + } + } + } +} + +private enum class TvDiagnosticsPicker { Destination, Consent } + +/** + * A TV `Surface` paints its content at full strength whether or not it is + * enabled, so a row that refuses to act still reads as live. Say it in the + * paint as well as in the semantics. + */ +private fun Modifier.dimWhenDisabled(enabled: Boolean): Modifier = + if (enabled) this else alpha(0.45f) + +// --------------------------------------------------------------------------- +// Bringing read-only context into view with the row that owns it +// --------------------------------------------------------------------------- + +/** + * Asks the scrolling parent for [abovePx] of extra room above this row, or + * [belowPx] below it, whenever the row takes focus. + * + * Compose reveals the focused node and nothing else, which strands read-only + * content at the ends of a list: there is no focus stop past it to scroll to. + * The fix is the idiom `Modifier.tvImeAwareFieldContext` already uses for the + * IME — hold a [BringIntoViewRequester] and ask for a [Rect] larger than the + * node — pointed at list edges instead of at a keyboard. + * + * Pass the viewport height for [abovePx] / [belowPx] to mean "as much as will + * fit": [tvListContextReveal] clamps the request so the rect is never taller + * than the viewport. That bound is load-bearing rather than tidiness — a + * bring-into-view rect that overhangs both edges is one Compose treats as + * already visible and declines to scroll for at all, so an unclamped request + * would silently do nothing. + * + * Applied to a row rather than to a wrapping container on purpose: a container + * would need `focusGroup()` to observe its child's focus, and this pane has no + * other reason to add focus groups between the D-pad and its rows. + */ +@Composable +private fun Modifier.tvRevealsListContext( + viewportPx: Int, + abovePx: Int = 0, + belowPx: Int = 0, +): Modifier { + if (viewportPx <= 0 || (abovePx <= 0 && belowPx <= 0)) return this + + val requester = remember { BringIntoViewRequester() } + var nodeSize by remember { mutableStateOf(IntSize.Zero) } + var hasFocus by remember { mutableStateOf(false) } + // Null while the row is unfocused or unmeasured, which is also what keeps + // the effect below from firing on every unrelated recomposition. + val reveal = if (hasFocus) { + tvListContextReveal( + nodeHeightPx = nodeSize.height, + viewportPx = viewportPx, + abovePx = abovePx, + belowPx = belowPx, + ) + } else { + null + } + + LaunchedEffect(reveal, nodeSize.width) { + val target = reveal ?: return@LaunchedEffect + // Compose's own focus-driven bring-into-view runs first; landing a + // frame later is what makes this request the one that wins. + withFrameNanos { } + runCatching { + requester.bringIntoView( + Rect( + left = 0f, + top = target.topPx, + right = nodeSize.width.toFloat(), + bottom = target.bottomPx, + ), + ) + } + } + + return this + .bringIntoViewRequester(requester) + .onSizeChanged { nodeSize = it } + // hasFocus, not isFocused: the focus target is the row's own clickable + // Surface, a descendant of this modifier's node. + .onFocusEvent { hasFocus = it.hasFocus } +} + +/** Vertical extent a focused row asks for, in its own local coordinates. */ +internal data class TvListContextReveal(val topPx: Float, val bottomPx: Float) + +/** + * Clamps a context request so the rect stays inside one viewport. + * + * Callers pass how much surrounding content they would *like* revealed (the + * viewport height itself for "everything on that side"); what comes back never + * exceeds `viewportPx`, because a taller rect overhangs both container edges + * and Compose then scrolls by zero. Returns null when there is nothing to ask + * for, or before the row has been measured. + */ +internal fun tvListContextReveal( + nodeHeightPx: Int, + viewportPx: Int, + abovePx: Int, + belowPx: Int, +): TvListContextReveal? { + if (nodeHeightPx <= 0 || viewportPx <= 0) return null + val room = (viewportPx - nodeHeightPx).coerceAtLeast(0) + val above = abovePx.coerceIn(0, room) + val below = belowPx.coerceIn(0, room - above) + if (above == 0 && below == 0) return null + return TvListContextReveal( + // Subtraction rather than unary minus: `-0.toFloat()` is negative zero, + // which is a different value to Float.equals and so to this data class. + topPx = 0f - above, + bottomPx = (nodeHeightPx + below).toFloat(), + ) +} + +/** + * Whether PENDING REPORTS owns the pane's first D-pad stop. + * + * The section sits above CAPTURE and only has focusable rows while reports are + * waiting, so the row that carries entry focus — and with it the reveal that + * brings FEATURE STATE back on screen — moves between sections with state. + * Exactly one row must hold `firstFocusRequester`: none and the rail's + * enter-category claim throws, two and the later one silently wins. + */ +internal fun tvDiagnosticsPendingOwnsFirstFocus(pendingCount: Int): Boolean = pendingCount > 0 + +/** + * Whether a diagnostics settings surface is on screen. + * + * The crash prompt is a global overlay hosted by the NavHost; it used to check + * the current route, which stopped working the moment diagnostics became a pane + * inside `TvRoute.Main`. A counter rather than a flag, so an overlapping + * enter/leave during a transition cannot latch it false. + */ +internal object TvDiagnosticsSurfacePresence { + private var count by mutableIntStateOf(0) + + val isVisible: Boolean get() = count > 0 + + fun enter() { + count += 1 + } + + fun leave() { + count = (count - 1).coerceAtLeast(0) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt deleted file mode 100644 index 7a4f3f70c..000000000 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt +++ /dev/null @@ -1,253 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.settings.diagnostics - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.tv.material3.ClickableSurfaceDefaults -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface -import androidx.tv.material3.Text -import java.text.DateFormat -import java.util.Date -import org.koin.compose.viewmodel.koinViewModel -import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi -import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode -import org.prairieserver.prairie.common.diagnostics.TimedCaptureStatus -import org.prairieserver.prairie.tv.ui.theme.FocusedContainer -import org.prairieserver.prairie.tv.ui.theme.FocusedContent - -@Composable -fun TvDiagnosticsSettingsScreen( - onBack: () -> Unit, - onReportSelected: (String) -> Unit, - viewModel: TvDiagnosticsViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsState() - BackHandler(onBack = onBack) - if (!state.profileEligible) { - TvDiagnosticsPage(title = "Diagnostics") { - Text("Diagnostics aren't available for this profile.") - } - return - } - var confirmAlways by remember { mutableStateOf(false) } - val firstFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } - val model = tvDiagnosticsScreenModel(state) - TvDiagnosticsPage(title = "Diagnostics") { - LazyColumn( - contentPadding = PaddingValues(bottom = 40.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - item { - TvDiagnosticsSection("STATUS") { - val status = when (state.availability) { - DiagnosticsAvailabilityUi.AVAILABLE -> "Available — reports can be sent to this server." - DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server — local review and deletion remain available." - DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Server storage unavailable — reports stay local." - DiagnosticsAvailabilityUi.OFFLINE -> "Offline — connect to refresh availability." - DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable for this profile." - } - Text(status, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - item { - TvDiagnosticsSection("CRASH REPORTS") { - DiagnosticsConsentMode.entries.forEachIndexed { index, mode -> - TvDiagnosticsAction( - label = when (mode) { - DiagnosticsConsentMode.ASK -> "Ask before sending" - DiagnosticsConsentMode.ALWAYS -> "Always send" - DiagnosticsConsentMode.NEVER -> "Never send" - }, - value = if (state.consent == mode) "Selected" else null, - onClick = { - if (tvDiagnosticsConsentAction(state.consent, mode).requiresConfirmation) { - confirmAlways = true - } else { - viewModel.setConsent(mode) - } - }, - modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier, - ) - } - TvDiagnosticsAction( - label = "Debug logging", - value = if (state.debugLogging) "On" else "Off", - enabled = state.consent != DiagnosticsConsentMode.NEVER, - onClick = { viewModel.setDebugLogging(!state.debugLogging) }, - ) - } - } - item { - TvDiagnosticsSection("CAPTURE") { - if (state.timedCapture.status == TimedCaptureStatus.ACTIVE) { - Text("Capture is running. Reproduce the issue, then stop to review.") - TvDiagnosticsAction("Stop & review", onClick = { viewModel.stopTimedCapture(onReportSelected) }) - TvDiagnosticsAction("Cancel capture", onClick = viewModel::cancelTimedCapture) - } else { - TvDiagnosticsAction( - "Send diagnostics now", - enabled = model.canCapture, - onClick = { viewModel.captureNow(onReportSelected) }, - ) - TvDiagnosticsAction( - "Start diagnostic capture", - enabled = model.canCapture, - onClick = viewModel::startTimedCapture, - ) - } - } - } - if (model.showPending) { - item { - TvDiagnosticsSection("PENDING REPORTS") { - state.pending.forEach { report -> - TvDiagnosticsAction( - label = report.type.tvDisplayName(), - value = "${report.capturedAt} · ${tvFormatBytes(report.evidenceBytes)}", - onClick = { onReportSelected(report.id) }, - ) - } - } - } - } - if (state.sentHistory.isNotEmpty()) { - item { - TvDiagnosticsSection("RECENTLY SENT") { - state.sentHistory.forEach { sent -> - Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { - Text(sent.shortId, modifier = Modifier.weight(1f)) - Text(tvFormatDate(sent.sentAtEpochMs), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - Text( - "Sent reports are removed from this device once your server has a copy. " + - "Use the reference ID when asking for help.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - if (confirmAlways) { - TvDiagnosticsConfirmation( - title = "Always send crash reports?", - message = "Future eligible reports may upload automatically until you change this setting.", - confirmLabel = "Always send", - onConfirm = { - confirmAlways = false - viewModel.setConsent(DiagnosticsConsentMode.ALWAYS) - }, - onDismiss = { confirmAlways = false }, - ) - } -} - -@Composable -internal fun TvDiagnosticsPage(title: String, content: @Composable () -> Unit) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFF17181A)) - .padding(horizontal = 64.dp, vertical = 38.dp), - ) { - Text(title, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.SemiBold) - Spacer(Modifier.height(22.dp)) - Column(Modifier.widthIn(max = 760.dp), content = { content() }) - } - } -} - -@Composable -internal fun TvDiagnosticsSection(title: String, content: @Composable () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { - Text( - title, - style = MaterialTheme.typography.labelMedium.copy(letterSpacing = 1.4.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - content() - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -internal fun TvDiagnosticsAction( - label: String, - value: String? = null, - enabled: Boolean = true, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val interaction = remember { MutableInteractionSource() } - val focused by interaction.collectIsFocusedAsState() - Surface( - onClick = onClick, - enabled = enabled, - interactionSource = interaction, - colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.06f), - contentColor = Color.White, - focusedContainerColor = FocusedContainer, - focusedContentColor = FocusedContent, - pressedContainerColor = FocusedContainer, - pressedContentColor = FocusedContent, - ), - scale = ClickableSurfaceDefaults.scale(focusedScale = 1.02f), - modifier = modifier.fillMaxWidth().height(52.dp), - ) { - Row( - Modifier.fillMaxSize().padding(horizontal = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, modifier = Modifier.weight(1f), color = if (focused) FocusedContent else Color.White) - value?.let { - Text(it, color = if (focused) FocusedContent else MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -internal fun org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.tvDisplayName(): String = - name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) - -internal fun tvFormatBytes(bytes: Long): String = when { - bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) - bytes >= 1_024 -> "%.1f KB".format(bytes / 1_024.0) - else -> "$bytes B" -} - -internal fun tvFormatDate(epochMs: Long): String = - DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(epochMs)) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsViewModel.kt index 6e584ae44..20c8c1624 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsViewModel.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.settings.diagnostics import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsPrompt import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState @@ -39,3 +40,123 @@ fun tvDiagnosticsScreenModel(state: DiagnosticsUiState): TvDiagnosticsScreenMode canDelete = state.profileEligible && state.pending.isNotEmpty(), canCapture = state.profileEligible && state.availability != DiagnosticsAvailabilityUi.OFFLINE, ) + +// --------------------------------------------------------------------------- +// Pane presentation — pure helpers shared with TvDiagnosticsSettingsPane. +// +// tvOS renders diagnostics as read-only "info" rows plus a handful of picker / +// toggle / action rows (TVDiagnosticsSettingsPane.swift). These functions carry +// the label and option logic so the composable stays declarative and the +// choices stay unit-testable. +// --------------------------------------------------------------------------- + +/** tvOS `DiagnosticsFeatureState.title` parity. */ +internal fun tvDiagnosticsStatusTitle(availability: DiagnosticsAvailabilityUi): String = + when (availability) { + DiagnosticsAvailabilityUi.AVAILABLE -> "Available" + DiagnosticsAvailabilityUi.DISABLED -> "Disabled by server" + DiagnosticsAvailabilityUi.STORAGE_UNAVAILABLE -> "Storage unavailable" + DiagnosticsAvailabilityUi.OFFLINE -> "Offline" + DiagnosticsAvailabilityUi.INELIGIBLE -> "Unavailable" + } + +/** The two destinations, in the order the picker offers them. */ +internal val TvDiagnosticsDestinations: List = listOf( + DiagnosticsDestinationKind.HOSTED, + DiagnosticsDestinationKind.SELF_HOSTED, +) + +internal fun tvDiagnosticsDestinationTitle(kind: DiagnosticsDestinationKind): String = when (kind) { + DiagnosticsDestinationKind.HOSTED -> "Prairie Diagnostics" + DiagnosticsDestinationKind.SELF_HOSTED -> "This Prairie server" +} + +/** + * tvOS `model.destinationServerName`: the hosted collector is named outright, + * a self-hosted destination reads as the connected server. + */ +internal fun tvDiagnosticsDestinationName( + kind: DiagnosticsDestinationKind, + serverName: String, +): String = when (kind) { + DiagnosticsDestinationKind.HOSTED -> "Prairie Diagnostics" + DiagnosticsDestinationKind.SELF_HOSTED -> serverName.ifBlank { "This Prairie server" } +} + +internal fun tvDiagnosticsConsentTitle(mode: DiagnosticsConsentMode): String = when (mode) { + DiagnosticsConsentMode.ASK -> "Ask" + DiagnosticsConsentMode.ALWAYS -> "Always" + DiagnosticsConsentMode.NEVER -> "Never" +} + +/** + * "Always" only exists where the destination can accept an unattended upload. + * A hosted collector never does, so the option is not offered at all. + */ +internal fun tvDiagnosticsConsentOptions(allowsAutomaticUpload: Boolean): List = + DiagnosticsConsentMode.entries.filter { + it != DiagnosticsConsentMode.ALWAYS || allowsAutomaticUpload + } + +/** + * A stored ALWAYS becomes ASK when the destination stopped allowing automatic + * upload, so the row never claims a mode the picker cannot even show. + */ +internal fun tvDiagnosticsEffectiveConsent( + consent: DiagnosticsConsentMode, + allowsAutomaticUpload: Boolean, +): DiagnosticsConsentMode = + if (consent == DiagnosticsConsentMode.ALWAYS && !allowsAutomaticUpload) { + DiagnosticsConsentMode.ASK + } else { + consent + } + +/** tvOS interpolates the count into the section header. */ +internal fun tvDiagnosticsPendingHeader(count: Int): String = "Pending Reports ($count)" + +/** + * tvOS `typeTitle(for:)` parity — the wire enum is not a label. Android carries + * two extra cases (ANR, NATIVE_CRASH) that Apple folds into the same titles. + */ +internal fun tvDiagnosticsReportTypeTitle( + type: org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType, +): String = when (type) { + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.CRASH, + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.NATIVE_CRASH, + -> "Crash" + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.HANG, + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.ANR, + -> "Not Responding" + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.ABNORMAL_EXIT -> "Unclean Shutdown" + org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType.MANUAL -> "Manual Report" +} + +/** tvOS "Expires " — whole days, because a TV is read from a sofa. */ +internal fun tvDiagnosticsExpiryLabel(expiresAtEpochMs: Long, nowEpochMs: Long): String { + val remainingMs = expiresAtEpochMs - nowEpochMs + if (remainingMs <= 0L) return "Expired" + val days = ((remainingMs + MILLIS_PER_DAY - 1) / MILLIS_PER_DAY).toInt() + return when (days) { + 1 -> "Expires in 1 day" + else -> "Expires in $days days" + } +} + +/** + * The sent log is read-only, so it sits outside the focus graph, and it is the + * last thing in the pane — the only way it can be seen at all is the pane's + * last focus stop asking for it (`tvRevealsListContext`). That request cannot + * exceed one viewport, which turns the cap into arithmetic rather than taste. + * + * At the 960x540dp reference surface the pane's list viewport is ~384dp, the + * focused row is 42dp, and TIMED CAPTURE's footer plus the section gap take + * ~36dp, leaving ~306dp. A section header (~26dp), the trailing footer (~35dp) + * and the 6dp row gaps mean each entry costs 48dp: five entries land exactly on + * the limit with nothing to spare, four leave ~48dp of slack for a wider font + * scale or a footer that wraps one line further. tvOS caps at 10, but its pane + * is not sharing a 540dp canvas with a rail and a category header. + */ +internal const val TvDiagnosticsSentHistoryLimit = 4 + +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt index b65cfb00d..7f2e98561 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt @@ -39,6 +39,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.focus.TvControlState +import org.prairieserver.prairie.tv.ui.focus.tvControlSemantics import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow import org.prairieserver.prairie.tv.ui.theme.DarkBackground import org.prairieserver.prairie.tv.ui.theme.FocusedContainer @@ -150,7 +152,10 @@ fun TvJoinCodeDialog( rowKeys.forEachIndexed { colIndex, ch -> JoinCodeKey( char = ch, - enabled = !isBusy, + // Joining is in flight, not a dead end — the + // grid keeps its focus so the ring survives a + // failed join. + controlState = TvControlState.transient(!isBusy), onClick = { state = state.append(ch) }, modifier = if (rowIndex == 0 && colIndex == 0) { Modifier @@ -198,20 +203,29 @@ fun TvJoinCodeDialog( @Composable private fun JoinCodeKey( char: Char, - enabled: Boolean, + controlState: TvControlState, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() val shape = RoundedCornerShape(12.dp) + val enabled = controlState.actionable Surface( - onClick = { if (enabled) onClick() }, + onClick = { controlState.perform(onClick) }, + enabled = controlState.focusable, interactionSource = interactionSource, shape = ClickableSurfaceDefaults.shape(shape = shape), + // Dimmed from `actionable`, not from the Surface's disabled slots: the + // key stays focusable while a join is in flight, so it never enters the + // disabled colour path. colors = ClickableSurfaceDefaults.colors( - containerColor = Color.White.copy(alpha = 0.06f), + containerColor = if (enabled) { + Color.White.copy(alpha = 0.06f) + } else { + Color.White.copy(alpha = 0.03f) + }, contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.42f), focusedContainerColor = FocusedContainer, focusedContentColor = FocusedContent, @@ -241,7 +255,8 @@ private fun JoinCodeKey( } else { Modifier }, - ), + ) + .tvControlSemantics(controlState), ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt new file mode 100644 index 000000000..788a4181b --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget + +fun tvWatchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Lobby -> + TvRoute.WatchTogetherLobby(room.roomId).route + WatchTogetherEntryTarget.Player -> + TvRoute.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + resumePositionSeconds = room.anchorPositionSeconds + .takeIf { it.isFinite() && it > 0.0 }, + ).route + } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt index 4fb4e3861..9e420a902 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.screens.watchtogether +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -32,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -106,11 +108,12 @@ fun TvWatchTogetherLobbyScreen( val room by viewModel.room.collectAsState() val suggestions by viewModel.suggestions.collectAsState() val closedReason by viewModel.roomClosedReason.collectAsState() + val context = LocalContext.current - // Role drives only the cosmetic header label; mutating controls (and the - // host's room-closing Back behaviour) gate on the server's per-recipient - // management capability so a demoted/grace-period host (selfRole still - // "host" but management revoked) doesn't see dead buttons. + // Role drives only the cosmetic header label; mutating controls gate on + // the server's per-recipient management capability so a demoted/ + // grace-period host (selfRole still "host" but management revoked) doesn't + // see dead buttons. val snapshot = room val isHostLabel = snapshot?.selfRole == MemberRole.Host val canManage = snapshot?.selfCanManageRoom == true @@ -145,14 +148,17 @@ fun TvWatchTogetherLobbyScreen( } } - // User-initiated leave just drops our own connection and exits — matching - // mobile, where leaving the lobby never closes the room. A host closes the - // room for everyone via the explicit "Close room" action (below), which - // stays on screen until the server's room_closed broadcast reactively backs - // us out. (Auto-closing here raced the closeRoom call against viewModelScope - // cancellation on dispose, so the room often never actually closed.) + LaunchedEffect(viewModel) { + viewModel.errors.collect { message -> + if (message.isNotBlank()) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + } + + // Browsing away retains the process-scoped room connection. Leaving is an + // explicit action below; hosts close the room for everyone via "Close room". BackHandler(enabled = true) { - viewModel.leave() onBack() } @@ -262,6 +268,17 @@ fun TvWatchTogetherLobbyScreen( verticalArrangement = Arrangement.spacedBy(20.dp), ) { if (snapshot != null) { + TvDialogActionRow( + title = "Browse titles", + onClick = onBack, + ) + TvDialogActionRow( + title = "Leave room", + onClick = { + viewModel.leave() + onBack() + }, + ) if (canManage) { // Selection mode is fixed at room creation — shown read-only. TvDialogCyclerRow( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt index 6ea581e66..b211ed207 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyViewModel.kt @@ -6,6 +6,8 @@ import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.Suggestion import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.errorMessage import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.watchtogether.RoomSession import kotlinx.coroutines.flow.SharingStarted @@ -40,26 +42,43 @@ class TvWatchTogetherLobbyViewModel( .stateIn(viewModelScope, SharingStarted.Eagerly, repository.suggestions.value) val roomClosedReason: StateFlow = repository.roomClosedReason .stateIn(viewModelScope, SharingStarted.Eagerly, repository.roomClosedReason.value) + val errors = repository.errors + + fun vote(suggestionId: String) = + launchOperation("Could not vote") { repository.vote(suggestionId) } + + fun unvote(suggestionId: String) = + launchOperation("Could not remove vote") { repository.unvote(suggestionId) } - fun vote(suggestionId: String) = viewModelScope.launch { repository.vote(suggestionId) } - fun unvote(suggestionId: String) = viewModelScope.launch { repository.unvote(suggestionId) } fun removeSuggestion(suggestionId: String) = - viewModelScope.launch { repository.deleteSuggestion(suggestionId) } + launchOperation("Could not remove suggestion") { + repository.deleteSuggestion(suggestionId) + } /** Host: promote a suggestion to the room selection (moves everyone to the player). */ fun promote(suggestionId: String) = - viewModelScope.launch { + launchOperation("Could not start suggestion") { repository.promoteSuggestion(PromoteSuggestionRequest(suggestionId = suggestionId)) } /** Host: change the guest-control policy. */ fun updatePolicy(guestControlPolicyWire: String) = - viewModelScope.launch { + launchOperation("Could not update room policy") { repository.updatePolicy(UpdatePolicyRequest(guestControlPolicy = guestControlPolicyWire)) } /** Host: close the room for everyone. */ - fun closeRoom() = viewModelScope.launch { repository.closeRoom() } + fun closeRoom() = launchOperation("Could not close room") { repository.closeRoom() } + + private fun launchOperation( + fallback: String, + operation: suspend () -> ApiResult, + ) = viewModelScope.launch { + val result = operation() + if (result !is ApiResult.Success) { + repository.reportDeliveryFailure(result.errorMessage(fallback)) + } + } /** * Leave the room: tear down our own WS and reset shared repo state. Matches diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt new file mode 100644 index 000000000..0eb4c8295 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt @@ -0,0 +1,140 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.ui.components.rememberTvDialogInitialFocus +import org.prairieserver.prairie.tv.ui.screens.player.TvDialogActionRow +import org.prairieserver.prairie.tv.ui.theme.DarkBackground +import org.prairieserver.prairie.watchtogether.canDismissRoomEntry + +enum class TvWatchTogetherMenuInitialAction { + Resume, + Host, +} + +fun tvWatchTogetherMenuInitialAction( + canResume: Boolean, +): TvWatchTogetherMenuInitialAction = + if (canResume) { + TvWatchTogetherMenuInitialAction.Resume + } else { + TvWatchTogetherMenuInitialAction.Host + } + +/** + * Focus-owning Watch Together entry popup launched from the authenticated + * profile dropdown. Resume is initially focused when a current room exists; + * otherwise Host owns initial focus. + */ +@Composable +fun TvWatchTogetherMenuEntryDialog( + canResume: Boolean, + isBusy: Boolean, + error: String?, + onResume: () -> Unit, + onHost: () -> Unit, + onJoin: () -> Unit, + onDismiss: () -> Unit, +) { + val resumeFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val initialAction = tvWatchTogetherMenuInitialAction(canResume) + val initialFocus = when (initialAction) { + TvWatchTogetherMenuInitialAction.Resume -> resumeFocus + TvWatchTogetherMenuInitialAction.Host -> hostFocus + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp), + contentAlignment = Alignment.Center, + ) { + val panelShape = RoundedCornerShape(14.dp) + Column( + modifier = Modifier + .width(340.dp) + .background(color = DarkBackground.copy(alpha = 0.68f), shape = panelShape) + .border(0.6.dp, Color.White.copy(alpha = 0.20f), panelShape) + .padding(horizontal = 14.dp, vertical = 14.dp) + .then(rememberTvDialogInitialFocus(initialFocus)), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "WATCH TOGETHER", + style = MaterialTheme.typography.labelMedium.copy( + fontSize = 16.sp, + letterSpacing = 1.1.sp, + fontWeight = FontWeight.Bold, + ), + color = Color.White.copy(alpha = 0.58f), + modifier = Modifier.padding(horizontal = 8.dp), + ) + + if (canResume) { + TvDialogActionRow( + title = "Resume current room", + enabled = !isBusy, + onClick = onResume, + modifier = Modifier.focusRequester(resumeFocus), + ) + } + + TvDialogActionRow( + title = if (isBusy) "Working…" else "Host a room", + enabled = !isBusy, + onClick = onHost, + modifier = Modifier.focusRequester(hostFocus), + ) + + TvDialogActionRow( + title = "Join by code", + enabled = !isBusy, + onClick = onJoin, + ) + + error?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFEF4444), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt index 6bc0f5358..c96438b1b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt @@ -9,10 +9,14 @@ import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import org.prairieserver.prairie.watchtogether.resumableWatchTogetherRoom import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -22,8 +26,8 @@ import kotlinx.coroutines.launch * then surfaces a one-shot [UiState.result] [RoomSnapshot] the detail screen routes * on (host-with-selection → synced player, no selection → lobby). * - * The repository stores the room JWT internally on create/join and reads the - * active roomId from its own snapshot, so [WatchTogetherRepository.setSelection] + * The gateway stores the room JWT internally on create/join and reads the + * active roomId from its own snapshot, so [WatchTogetherEntryGateway.setSelection] * takes only the request. createRoom does NOT auto-select, so the host flow must * createRoom THEN setSelection(contentId, fileId) so the host lands on the player. * The ordering is safe because createRoom synchronously stores the snapshot before @@ -33,7 +37,7 @@ import kotlinx.coroutines.launch * Mirrors the mobile `WatchTogetherEntryViewModel` host()/joinByCode() shape. */ class TvWatchTogetherViewModel( - private val repository: WatchTogetherRepository, + private val gateway: WatchTogetherEntryGateway, ) : ViewModel() { data class UiState( @@ -45,6 +49,13 @@ class TvWatchTogetherViewModel( private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) /** Host flow: create a room with this title pre-selected as the room selection. */ fun createRoom( @@ -52,26 +63,26 @@ class TvWatchTogetherViewModel( fileId: Int?, selectionMode: RoomSelectionMode = RoomSelectionMode.HostPick, ) { + if (selectionMode == RoomSelectionMode.Vote) { + createEmptyVoteRoom() + return + } if (_uiState.value.isBusy) return _uiState.update { it.copy(isBusy = true, error = null) } viewModelScope.launch { when ( - val created = repository.createRoom( + val created = gateway.createRoom( CreateRoomRequest(selectionMode = selectionMode.wire), ) ) { is ApiResult.Success -> { - if (selectionMode == RoomSelectionMode.Vote) { - finish(created.data.room) - return@launch - } // createRoom does NOT auto-select; set this title as the room - // selection so the host lands on the synced player. The repo + // selection so the host lands on the synced player. The gateway // already stored the snapshot synchronously, so setSelection // reads the right roomId/token. if (created.data.room.selectedContentId.isNullOrBlank()) { when ( - val sel = repository.setSelection( + val sel = gateway.setSelection( SetSelectionRequest(contentId = contentId, fileId = fileId), ) ) { @@ -89,13 +100,41 @@ class TvWatchTogetherViewModel( } } + /** Host flow for the menu action: create an empty vote room, then enter its lobby. */ + fun createEmptyVoteRoom() { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + /** Resume the valid room snapshot already held by the process session. */ + fun resumeCurrentRoom() { + if (_uiState.value.isBusy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } + /** Join flow: resolve an invite code; the screen routes to player or lobby. */ fun joinRoom(code: String) { val trimmed = code.trim().uppercase() if (_uiState.value.isBusy || trimmed.isBlank()) return _uiState.update { it.copy(isBusy = true, error = null) } viewModelScope.launch { - when (val joined = repository.joinRoom(JoinRoomRequest(code = trimmed))) { + when (val joined = gateway.joinRoom(JoinRoomRequest(code = trimmed))) { is ApiResult.Success -> finish(joined.data.room) is ApiResult.Error, is ApiResult.NetworkError -> fail(joined.errorMessage("Could not join — check the code")) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt new file mode 100644 index 000000000..faa2a0373 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigation.kt @@ -0,0 +1,10 @@ +package org.prairieserver.prairie.tv.ui.shell + +/** + * A held Up may traverse content, but it must not escape from the first + * content row into the top menu after focus movement has already failed. + */ +internal fun shouldRequestMenuAfterContentUp( + movedWithinContent: Boolean, + isRepeat: Boolean, +): Boolean = !movedWithinContent && !isRepeat diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt new file mode 100644 index 000000000..99573d458 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt @@ -0,0 +1,45 @@ +package org.prairieserver.prairie.tv.ui.shell + +/** + * Per-root detail-return focus bookkeeping. + * + * Root-agnostic: Home and For You both render the Skyline feed, which arms its + * launch-card requester at click time, so both roots want exactly this ladder. + */ +internal data class TvDetailReturnFocusState( + val requestId: Int = 0, + val needsRetry: Boolean = false, + val fallbackPending: Boolean = false, +) + +internal fun beginTvDetailReturnRetry( + previousRequestId: Int, + needsRetry: Boolean, +): TvDetailReturnFocusState = TvDetailReturnFocusState( + requestId = previousRequestId + 1, + needsRetry = needsRetry, + fallbackPending = needsRetry, +) + +internal fun beginTvDetailReturnRetryIfRoot( + previousState: TvDetailReturnFocusState, + isDetailReturnForRoot: Boolean, + needsRetry: Boolean, +): TvDetailReturnFocusState = if (isDetailReturnForRoot) { + beginTvDetailReturnRetry( + previousRequestId = previousState.requestId, + needsRetry = needsRetry, + ) +} else { + previousState +} + +internal fun completeTvDetailReturnRetry( + state: TvDetailReturnFocusState, +): TvDetailReturnFocusState = state.copy( + needsRetry = false, + fallbackPending = false, +) + +internal fun resetTvDetailReturnFocus(): TvDetailReturnFocusState = + TvDetailReturnFocusState() diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index cbc401690..58ecc8d3b 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.tv.ui.shell +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -28,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Favorite @@ -46,14 +46,17 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusDirection @@ -83,6 +86,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import kotlinx.coroutines.launch import kotlin.math.roundToInt +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -99,21 +105,22 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import androidx.lifecycle.compose.LifecycleResumeEffect +import org.prairieserver.prairie.common.diagnostics.DiagnosticsFocusLogger import org.prairieserver.prairie.common.ui.components.ThumbhashImage -import org.prairieserver.prairie.common.ui.components.isImageAvatar +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface import org.prairieserver.prairie.tv.ui.theme.DarkBackground import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.network.ServerReachabilityStatus import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText +import org.prairieserver.prairie.common.ui.components.avatarRef +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl -import org.prairieserver.prairie.common.ui.components.resolveAvatarUrl import org.prairieserver.prairie.model.catalog.BrowseItem -import org.prairieserver.prairie.model.admin.shouldShowClientAdminSurface -import org.prairieserver.prairie.model.auth.isActingAdmin -import org.prairieserver.prairie.model.feature.LiveTvFeatureStore +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.repository.AuthRepository @@ -127,15 +134,9 @@ import org.prairieserver.prairie.tv.ui.components.TvForYouSelector import org.prairieserver.prairie.tv.ui.components.TvCatalogEmptyState import org.prairieserver.prairie.tv.ui.components.tvSkylinePanelChrome import org.prairieserver.prairie.tv.ui.navigation.TvMainRoute +import org.prairieserver.prairie.tv.ui.navigation.TvRemovedMainRoutes import org.prairieserver.prairie.tv.ui.screens.library.TvLibraryDetailScreen import org.prairieserver.prairie.tv.ui.screens.library.TvLibraryTab -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminHubScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminLogsScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminScansScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminSessionsScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminUserEditScreen -import org.prairieserver.prairie.tv.ui.screens.admin.TvAdminUsersScreen import org.prairieserver.prairie.tv.ui.screens.browse.TvBrowseScreen import org.prairieserver.prairie.tv.ui.screens.calendar.TvCalendarScreen import org.prairieserver.prairie.tv.ui.screens.collections.TvCollectionsScreen @@ -145,17 +146,34 @@ import org.prairieserver.prairie.tv.ui.screens.personal.TvFavoritesScreen import org.prairieserver.prairie.tv.ui.screens.personal.TvHistoryScreen import org.prairieserver.prairie.tv.ui.screens.personal.TvWatchlistScreen import org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendationsScreen +import org.prairieserver.prairie.tv.ui.screens.recommendations.SavedListSelection +import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequest +import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequestSaver import org.prairieserver.prairie.tv.ui.screens.requests.TvMyRequestsScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestDetailScreen -import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvPlayerScreen -import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsScreen import org.prairieserver.prairie.tv.ui.screens.search.TvSearchScreen -import org.prairieserver.prairie.tv.ui.screens.settings.TvManageSessionsScreen import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsScreen +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvJoinCodeDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel import org.prairieserver.prairie.tv.ui.theme.TvSkyline import org.prairieserver.prairie.tv.ui.util.visibleOnTv import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved + +/** + * Frames the shell will wait for content to actually take focus after it + * dismantles the previous owner. + * + * Three: the synchronous claim, then two more frames. On a 2 GB Amlogic box the + * content group is routinely still composing when the claim arrives, and one + * extra frame was already known to be too few for the Home row. Beyond this the + * screen genuinely has nothing focusable and retrying cannot help. + */ +private const val CONTENT_HANDOFF_ATTEMPTS = 3 /** * Main authenticated TV shell. Mirrors `TVMainTabView` on tvOS: a content @@ -171,15 +189,21 @@ fun TvMainShell( returnToManageServers: Boolean = false, onManageServersReturnFocusConsumed: () -> Unit = {}, onManageServers: () -> Unit, - onOpenDiagnostics: () -> Unit, + onOpenDiagnosticsReport: (reportId: String) -> Unit, onOpenItemDetail: (contentId: String) -> Unit, - onOpenLibraryCollectionDetail: (libraryId: Int, collectionId: String, title: String) -> Unit, + onOpenLibraryCollectionDetail: ( + libraryId: Int, + collectionId: String, + title: String, + libraryType: String, + ) -> Unit, onOpenCollectionDetail: (collectionId: String, title: String) -> Unit, onSignedOut: () -> Unit, onSwitchProfile: () -> Unit, onSwitchServer: () -> Unit, onPairDevice: () -> Unit, onPlayItem: (contentId: String, type: String?, resumePositionSeconds: Double?) -> Unit, + onOpenWatchTogether: (RoomSnapshot) -> Unit, onOpenPersonDetail: (personId: Long) -> Unit, ) { val nestedNav = rememberNavController() @@ -191,15 +215,26 @@ fun TvMainShell( val profileRepository: ProfileRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() - val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: org.prairieserver.prairie.model.feature.MetadataAiFeatureStore = koinInject() val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() - val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val activeServerEntry by serverRegistry.activeEntry.collectAsState() val tvLibraryScopeStore: TvLibraryScopeStore = koinInject() val serverUrl = rememberProfileServerUrl() + val watchTogetherViewModel = koinViewModel() + val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() + val currentWatchTogetherRoom by watchTogetherViewModel.currentRoom.collectAsState() + var watchTogetherEntryOpen by rememberSaveable { mutableStateOf(false) } + var watchTogetherJoinOpen by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(watchTogetherState.result) { + val room = watchTogetherState.result ?: return@LaunchedEffect + watchTogetherViewModel.consumeResult() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + onOpenWatchTogether(room) + } // The raw list of libraries visible to this profile on TV, sorted by the // server's sort order (ebook-like libraries filtered out by visibleOnTv). @@ -248,7 +283,23 @@ fun TvMainShell( // also persisted via TvLibraryScopeStore; pill selections are session-only // (Stage 4 wires the cascade into these). Persistently composed. val scopeSelections: SnapshotStateMap = remember { mutableStateMapOf() } - val pillSelections: SnapshotStateMap = remember { mutableStateMapOf() } + // Saveable, not merely remembered: opening a collection (or any outer + // route) takes the whole shell out of composition, and the nested nav + // restores the Movies/Series tab on Back but a plain remember has lost the + // pill — so Collections landed back on Recommended, reading as "Back went + // Home". Scope selections survive via TvLibraryScopeStore already. + val pillSelections: SnapshotStateMap = rememberSaveable( + saver = listSaver( + save = { map -> map.entries.map { listOf(it.key.name, it.value.name) } }, + restore = { saved -> + mutableStateMapOf().apply { + saved.forEach { (type, pill) -> + runCatching { put(TvLibraryTabType.valueOf(type), TvLibraryPill.valueOf(pill)) } + } + } + }, + ), + ) { mutableStateMapOf() } // Monotonic per-type "section request" nonce, bumped on every commitScope so // re-committing the same section pill still re-applies (see TvLibraryDetailScreen). val sectionRequestNonces: SnapshotStateMap = remember { mutableStateMapOf() } @@ -287,8 +338,6 @@ fun TvMainShell( LaunchedEffect(activeServerEntry?.id, activeServerEntry?.profileId) { requestsFeatureStore.reset() requestsFeatureStore.refresh() - liveTvFeatureStore.reset() - liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -297,16 +346,36 @@ fun TvMainShell( val contentFocusRequester = remember { FocusRequester() } val homeFirstItemFocusRequester = remember { FocusRequester() } val homeFirstRowContainerFocusRequester = remember { FocusRequester() } + // For You renders the same Skyline feed as Home, so it needs its own pair: + // one feed's requesters cannot be attached in two compositions at once. + val forYouFirstItemFocusRequester = remember { FocusRequester() } + val forYouFirstRowContainerFocusRequester = remember { FocusRequester() } val searchInputFocusRequester = remember { FocusRequester() } var searchInputHasFocus by remember { mutableStateOf(false) } var searchBackToInputRequest by remember { mutableIntStateOf(0) } + // Same failure as the bar handoff: Back asks the search field to take + // focus and consumes the press. If the field never reports focus, every + // Back repeats that forever and Search cannot be left. Records the attempt + // so the next Back falls through to navigation instead. + var searchBackToInputAttempted by remember { mutableStateOf(false) } // Opening an outer item-detail route pauses/removes this shell. Remember the // pending hand-back in the Main back-stack entry so it survives either form, // then re-enter the existing content focusRestorer when Main resumes. - var restoreHomeContentAfterDetail by rememberSaveable { mutableStateOf(false) } + // `restoreContentAfterDetail` says a detail return is pending for ANY root + // and gates the resume claim below so focus lands back inside content + // instead of Compose's default search picking the top bar. + // `detailReturnRoot` names which root it was, for the two decisions that + // differ per root: the restorer's enter fallback and Home's retry ladder. + // Stored as the route string because rememberSaveable takes primitives. + var restoreContentAfterDetail by rememberSaveable { mutableStateOf(false) } + var detailReturnRoot by rememberSaveable { mutableStateOf(null) } + val restoreHomeContentAfterDetail = detailReturnRoot == TvMainRoute.Home.route + val restoreForYouContentAfterDetail = detailReturnRoot == TvMainRoute.ForYou.route var suppressHomeRefreshAfterDetail by rememberSaveable { mutableStateOf(false) } - var homeDetailReturnFocusRequest by remember { mutableIntStateOf(0) } - var homeDetailReturnNeedsRetry by remember { mutableStateOf(false) } + var homeDetailReturnFocusState by remember { mutableStateOf(TvDetailReturnFocusState()) } + var forYouDetailReturnFocusState by remember { mutableStateOf(TvDetailReturnFocusState()) } + var detailReturnFocusRequest by remember { mutableIntStateOf(0) } + var detailReturnNeedsRetry by remember { mutableStateOf(false) } // Attached (by the Home feed) to the exact card a detail page was launched // from, while that return is pending. Used as the content restorer's enter // fallback during the return resume so the synchronous claim below lands @@ -314,6 +383,19 @@ fun TvMainShell( // survive the shell being removed for the outer detail route, and its // default enter could land a row below the launch card for a few frames. val homeDetailReturnCardFocusRequester = remember { FocusRequester() } + val forYouDetailReturnCardFocusRequester = remember { FocusRequester() } + // Skyline feeds only. The feed arms its launch-card requester at click time + // (`detailReturnPending` in TvSkylineSectionFeed), so the node is attached + // for the whole round trip and is a valid restorer target during the + // synchronous resume claim below. Roots that render something else keep + // Default enter, which lands inside content — all the claim owes. + val detailReturnFallback = when { + restoreHomeContentAfterDetail || homeDetailReturnFocusState.fallbackPending -> + homeDetailReturnCardFocusRequester + restoreForYouContentAfterDetail || forYouDetailReturnFocusState.fallbackPending -> + forYouDetailReturnCardFocusRequester + else -> FocusRequester.Default + } // Whether focus currently sits anywhere inside the content group. Gates // the detail-return resume claim below: the Home feed's early restore // ladder usually re-focuses the launch card during the pop transition, and @@ -321,45 +403,90 @@ fun TvMainShell( // yanks focus to a different card for a frame. var contentHasFocus by remember { mutableStateOf(false) } LifecycleResumeEffect(Unit) { - if (restoreHomeContentAfterDetail) { + if (restoreContentAfterDetail) { // Claim the content group synchronously during ON_RESUME, before // Compose's default search can briefly settle on the Home tab — // but only when the feed hasn't already claimed it. Claim BEFORE // clearing the flag so the restorer fallback still points at the // launch card for this claim. - homeDetailReturnNeedsRetry = if (contentHasFocus) { + detailReturnNeedsRetry = if (contentHasFocus) { false } else { runCatching { !contentFocusRequester.requestFocus() }.getOrDefault(true) } - restoreHomeContentAfterDetail = false - homeDetailReturnFocusRequest++ + detailReturnFocusRequest++ + homeDetailReturnFocusState = beginTvDetailReturnRetryIfRoot( + previousState = homeDetailReturnFocusState, + isDetailReturnForRoot = restoreHomeContentAfterDetail, + needsRetry = detailReturnNeedsRetry, + ) + forYouDetailReturnFocusState = beginTvDetailReturnRetryIfRoot( + previousState = forYouDetailReturnFocusState, + isDetailReturnForRoot = restoreForYouContentAfterDetail, + needsRetry = detailReturnNeedsRetry, + ) + restoreContentAfterDetail = false + detailReturnRoot = null } onPauseOrDispose { } } - LaunchedEffect(homeDetailReturnFocusRequest) { - if (homeDetailReturnFocusRequest == 0) return@LaunchedEffect + LaunchedEffect(detailReturnFocusRequest) { + if (detailReturnFocusRequest == 0) return@LaunchedEffect // One-frame fallback for the disposed/recreated case where the Home row // requester was not attached during the synchronous resume claim. withFrameNanos { } - if (homeDetailReturnNeedsRetry) { + if (detailReturnNeedsRetry) { runCatching { contentFocusRequester.requestFocus() } } + homeDetailReturnFocusState = completeTvDetailReturnRetry(homeDetailReturnFocusState) + forYouDetailReturnFocusState = completeTvDetailReturnRetry(forYouDetailReturnFocusState) // The detail-return ON_RESUME event has now passed and Home is stable; // future real resumes (playback/background) should refresh normally. suppressHomeRefreshAfterDetail = false } val openHomeItemDetail: (String) -> Unit = { contentId -> - restoreHomeContentAfterDetail = true + restoreContentAfterDetail = true + detailReturnRoot = TvMainRoute.Home.route suppressHomeRefreshAfterDetail = true onOpenItemDetail(contentId) } - var contentUpFallback by remember { mutableStateOf<(() -> Boolean)?>(null) } + val openForYouItemDetail: (String) -> Unit = { contentId -> + restoreContentAfterDetail = true + detailReturnRoot = TvMainRoute.ForYou.route + onOpenItemDetail(contentId) + } + // Same generic hand-back for roots that render inside the shell but do not + // attach a launch-card requester. Without this the shell never claims + // content focus on the return resume, so focus settles wherever Compose's + // default search lands — in practice the top bar — and the D-pad no longer + // drives the rows the viewer was just in. + val openContentItemDetail: (String) -> Unit = { contentId -> + restoreContentAfterDetail = true + // No launch-card requester for this root — clear any root left over + // from an earlier return so the restorer does not reuse Home's. + detailReturnRoot = null + onOpenItemDetail(contentId) + } + // Collections open outer routes too, so they need the same hand-back: + // without it the return resume left focus to Compose's default search + // (the top bar), which then visibly hopped to the grid a beat later. + val openLibraryCollectionDetail: (Int, String, String, String) -> Unit = + { libraryId, collectionId, title, libraryType -> + restoreContentAfterDetail = true + detailReturnRoot = null + onOpenLibraryCollectionDetail(libraryId, collectionId, title, libraryType) + } + val openCollectionDetail: (String, String) -> Unit = { collectionId, title -> + restoreContentAfterDetail = true + detailReturnRoot = null + onOpenCollectionDetail(collectionId, title) + } + var contentUpFallback by remember { mutableStateOf<((Boolean) -> Boolean)?>(null) } // Feeds that registered the up-fallback slot, were superseded by a newer // feed, and are still awaiting their (now-stale) onDispose. Tracking them // lets us ignore that late dispose instead of nulling the entering feed's // registration. - val supersededContentUpFallbacks = remember { mutableSetOf<() -> Boolean>() } + val supersededContentUpFallbacks = remember { mutableSetOf<(Boolean) -> Boolean>() } // Register/relinquish the single D-pad-Up fallback slot BY IDENTITY. A // NavHost composes the ENTERING feed (which registers its own lambda) before // it disposes the EXITING one, so a blind null-on-dispose would drop the new @@ -368,7 +495,7 @@ fun TvMainShell( // lambda on both register and dispose; we only relinquish the slot for the // feed that still owns it, ignore a superseded feed's stale dispose, and let // a newly-entering feed take the slot (retiring the previous owner). - val onContentUpFallback: ((() -> Boolean)?) -> Unit = remember { + val onContentUpFallback: (((Boolean) -> Boolean)?) -> Unit = remember { { incoming -> if (incoming != null) { when { @@ -399,6 +526,14 @@ fun TvMainShell( // top), while ordinary content re-entry keeps the focusRestorer()'s // last-focused card. var contentFocusRequest by remember { mutableIntStateOf(0) } + // Saveable, because the For You screen guards against replaying an entry + // request with a SAVED "last applied sequence". A shell recreated with a + // plain remember restarted the counter at 0 while the screen still held + // the old high-water mark, so every dropdown pick after that (Watchlist, + // Favorites, Recommendations) was silently ignored as already applied. + var forYouEntryRequest by rememberSaveable(stateSaver = TvForYouEntryRequestSaver) { + mutableStateOf(TvForYouEntryRequest()) + } // --- Skyline cascade panel host (Stage 4) ---------------------------------- // Mirrors tvOS `TVMainTabView.persistentPanels`. The cascade overlays are @@ -421,36 +556,51 @@ fun TvMainShell( val userResult = authRepository.getCurrentUser() if (userResult !is ApiResult.Success) { // Transient /me failure (offline blip, server restart): keep the - // previous snapshot instead of blanking it — otherwise the Admin - // row and account header flicker out on every hiccup. + // previous snapshot instead of blanking it — otherwise the account + // header flickers out on every hiccup. return@produceState } val user = userResult.data val activeProfile = profileRepository.getActiveProfile() - // Subtitle mirrors tvOS §5.8: role when known, falling back to username. - val subtitle = user?.role?.takeIf { it.isNotBlank() } - ?.replaceFirstChar { it.uppercase() } - ?: user?.username.orEmpty() - val avatarUrl = activeProfile?.avatar - ?.takeIf(::isImageAvatar) - ?.let { resolveAvatarUrl(activeServerEntry?.url.orEmpty(), it) } + // The role belongs to the ACCOUNT, but this header shows a PROFILE's + // name — so rendering it under a non-owner profile reads as "laura is + // an admin" when laura is a household profile on an admin account. + // That is the caption a viewer actually sees and the reason this was + // reported. Show the role only where it is exercisable, which is the + // primary profile. A non-owner profile gets NOTHING here — falling back + // to the account username just captions laura's profile with the + // owner's name, which conflates the two all over again. Profile name + // and server is all a household profile needs to see. + // + // Nothing hangs on it — it is a caption, not a permission — but it is + // the part that misleads. + val subtitle = if (activeProfile?.isPrimary == true) { + user?.role?.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercase() } + ?: user?.username.orEmpty() + } else { + "" + } value = TvAccountState( displayName = activeProfile?.name ?: user?.username ?: "Profile", - avatar = activeProfile?.avatar, - avatarUrl = avatarUrl, + // Ref + presigned URL travel together; the shell re-fetches this on + // every profile switch / server change, which is also what hands the + // avatar a freshly signed URL. + avatar = activeProfile?.avatarRef() ?: ProfileAvatarRef.None, subtitle = subtitle, serverName = activeServerEntry?.displayName.orEmpty(), - // Gate via the shared client-admin policy (same as the Settings - // admin entry), not raw isActingAdmin — so the Admin row honors - // CLIENT_ADMIN_SURFACE_ENABLED and stays consistent with the rest - // of the TV client. - isAdmin = shouldShowClientAdminSurface(isActingAdmin(user, activeProfile)), ) } val selectedRoot by remember(currentRoute) { derivedStateOf { mapRouteToRoot(currentRoute) } } + // Where an Up out of content lands on the bar. The selected root when there + // is one; For You's dropdown children (Watchlist / Favorites) map to the + // For You tab they were opened from — they are not tab roots (no highlight, + // Back still pops), but Up from them must land on their tab, not on + // whatever the geometric search picks (the Search icon, from the left edge). + val selectedMenuFocusTarget = (selectedRoot ?: menuFocusRootForRoute(currentRoute)) + ?.let(TvTopMenuPanel::Root) // Which libraries actually HAVE collections — gates the cascade's // Collections pill so an empty library doesn't offer a dead-end section @@ -469,10 +619,26 @@ fun TvMainShell( val navigateToRoute: (String) -> Unit = { route -> if (route != currentRoute) { - nestedNav.navigate(route) { - popUpTo(nestedNav.graph.startDestinationId) { saveState = true } - launchSingleTop = true - restoreState = true + val startRoute = nestedNav.graph.startDestinationRoute + if (route == startRoute && nestedNav.popBackStack(route, inclusive = false, saveState = true)) { + // Home is the graph root, so "go Home" is a pop, never a push. + // The bottom-nav idiom below (popUpTo(start){saveState} + + // restoreState) is unsafe for the root itself: NavController + // maps the state it just popped onto the popUpTo destination + // when that destination has no saved-state key yet, and the + // restoreState step then re-pushes exactly what was popped — + // Home from a dropdown-opened For You (navigateToSecondary, + // which never seeds Home's key) landed straight back on the + // saved list. Tab→Home only worked because the earlier + // navigate() to the tab had seeded Home's key with null. + // saveState stays on so the popped tab/secondary route keeps + // its scroll state for a later restoreState re-entry. + } else { + nestedNav.navigate(route) { + popUpTo(nestedNav.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } } } } @@ -490,27 +656,23 @@ fun TvMainShell( } } - // Parameterized form routes (e.g. AdminUserEdit) must NOT restore a saved - // entry: all query variants share one destination id, so restoreState could - // resurrect a stale entry (and its idempotent-loaded ViewModel) with the - // wrong userId. Always start a fresh entry for these. - val navigateToForm: (String) -> Unit = { route -> - nestedNav.navigate(route) { - launchSingleTop = false - restoreState = false - } - } + // True when a requester actually took focus. `requestFocus()` throws rather + // than returning false when its node has not composed yet, so each call has + // to be guarded — and that guard is what used to swallow the failure whole. + val claimContentFocus: (String) -> Boolean = { route -> + when { + route == TvMainRoute.Search.route -> + runCatching { searchInputFocusRequester.requestFocus() }.getOrDefault(false) - val moveFocusToContent: (String) -> Unit = { route -> - focusState.closeProfileMenuForContent() - if (route == TvMainRoute.Search.route) { - runCatching { searchInputFocusRequester.requestFocus() } - } else if (route == TvMainRoute.Home.route || route == TvMainRoute.Video.route) { - panelScope.launch { - runCatching { contentFocusRequester.requestFocus() } - runCatching { homeFirstRowContainerFocusRequester.requestFocus() } + route == TvMainRoute.Home.route || route == TvMainRoute.Video.route -> { + val content = + runCatching { contentFocusRequester.requestFocus() }.getOrDefault(false) + val firstRow = runCatching { + homeFirstRowContainerFocusRequester.requestFocus() + }.getOrDefault(false) + content || firstRow } - } else { + // Just request focus on the content group. The Box's // .focusRestorer() restores to the user's last-focused card // (e.g., card 7 of row 3) instead of slamming back to card 0. @@ -519,20 +681,85 @@ fun TvMainShell( // re-focused index 0 — defeating the restorer. Initial focus // when a screen first loads is still handled by each screen's // own LaunchedEffect on its first data emission. - runCatching { contentFocusRequester.requestFocus() } + else -> runCatching { contentFocusRequester.requestFocus() }.getOrDefault(false) } } + val moveFocusToContent: (String) -> Unit = { route -> + focusState.closeProfileMenuForContent() + val homeLike = route == TvMainRoute.Home.route || route == TvMainRoute.Video.route + // Home/Video keep claiming from the scope; every other route still + // claims inline first, so the timing of a successful claim is exactly + // what it was. What is new is the second chance: a claim that finds a + // not-yet-composed requester now gets one more frame before it is given + // up on — the same allowance the detail-return path already makes for + // "the Home row requester was not attached during the synchronous + // claim". On a 2 GB Amlogic box the content group is routinely still + // composing when Down arrives from the menu bar, and the first claim + // lands on nothing. + // + // The claim's return value is NOT proof that focus arrived — that is + // the whole premise of the silent-focus-claim ratchet. It is only worth + // skipping the observed pass when focus is demonstrably in content + // already. + if (!homeLike) claimContentFocus(route) + panelScope.launch { + val result = requestFocusUntilObserved( + maxAttempts = CONTENT_HANDOFF_ATTEMPTS, + awaitAttempt = { withFrameNanos { } }, + requestFocus = { claimContentFocus(route) }, + isFocused = { contentHasFocus }, + ) + // One more frame before giving up. The last attempt inspects focus + // in the same frame it requested it, so a claim that WAS accepted + // but reports asynchronously would otherwise look like a failure — + // and the fallback below would yank focus off content that had just + // taken it. + if (result != TvObservedFocusResult.Focused) withFrameNanos { } + if (result != TvObservedFocusResult.Focused && !contentHasFocus) { + // Content has nothing focusable — a loading or empty rail, which + // should not have to invent a focusable control just to satisfy + // shell navigation. The shell dismantled the old focus owner, so + // the shell owes a real successor: put it back on the bar rather + // than leaving focus nowhere and the D-pad apparently dead. + DiagnosticsFocusLogger.contentEntryFailed(route) + // With a target, so dwell suppression actually applies: without + // one the suppressed-button is null and the tab we just focused + // reopens its preview a moment later. + focusState.requestMenuFocus( + target = selectedMenuFocusTarget, + suppressDwellPreview = true, + ) + } + } + } + val openForYou: (SavedListSelection?) -> Unit = { selection -> + // A dropdown pick is an explicit selection just like the tab itself: + // end any detail-return protection, or its nonzero token makes the + // Skyline feed swallow the entry focus bump and focus falls to the bar. + forYouDetailReturnFocusState = resetTvDetailReturnFocus() + forYouEntryRequest = forYouEntryRequest.next(selection) + focusState.closePanel() + navigateToSecondary(TvMainRoute.ForYou.route) + moveFocusToContent(TvMainRoute.ForYou.route) + } + val onSelectRoot: (TvRootDestination) -> Unit = { dest -> val route = dest.toRoute() + if (dest == TvRootDestination.ForYou) { + // Same reason as Home below: an explicit tab selection ends the + // detail-return protection instead of letting its nonzero token + // keep suppressing the feed's first-card focus request. + forYouDetailReturnFocusState = resetTvDetailReturnFocus() + forYouEntryRequest = forYouEntryRequest.nextForTopLevelForYou() + } if (dest == TvRootDestination.Home) { // Detail return deliberately preserves the card that opened the // detail page, but that protection must end when the user // explicitly selects Home from the bar. Otherwise its nonzero // token keeps suppressing Home's normal first-card focus request // for the rest of the shell session. - homeDetailReturnFocusRequest = 0 - homeDetailReturnNeedsRetry = false + homeDetailReturnFocusState = resetTvDetailReturnFocus() } if (dest == TvRootDestination.Calendar) { calendarFocusHandoffPending = true @@ -540,7 +767,7 @@ fun TvMainShell( // A dwell preview can still be open when Center commits For You (or a // library root). Close it without returning focus to the bar before the // content handoff, otherwise the overlay lingers and races page focus. - focusState.closePanel(false) + focusState.closePanel() if (route != currentRoute) { navigateToRoute(route) } @@ -599,7 +826,7 @@ fun TvMainShell( navigateToRoute(route) } // Close WITHOUT returning focus to the bar; commit wants content focus. - focusState.closePanel(false) + focusState.closePanel() moveFocusToContent(route) } @@ -671,67 +898,124 @@ fun TvMainShell( } } + fun handleShellBack(): Boolean { + // Settings owns a two-stage Back model (detail pane -> selected rail + // category -> Home), so its nested BackHandler must remain in charge. + if (currentRoute == TvMainRoute.Settings.route) return false + + return when (focusState.onBack( + onTabRoot = selectedRoot != null, + menuFocusTarget = selectedMenuFocusTarget, + onHome = selectedRoot == TvRootDestination.Home, + )) { + // Panel/dropdown already closed by onBack(): just consume. + // onBack() closed the panel without claiming focus; put the viewer + // back where they came from in the same press. + TvShellBackAction.ClosePanel -> { + // onBack() already put focus on the anchor tab with dwell + // suppressed. Claiming content here fought that and lost — + // focus ended up on the bar anyway, just without suppression. + true + } + // Preview only: focus never left the bar, so dismissing it must not + // move the viewer anywhere. + TvShellBackAction.ClosePanelPreview -> true + TvShellBackAction.CloseProfileMenu -> true + // Content on a tab root: onBack() already routed focus to the bar's + // selected tab -- just consume. + TvShellBackAction.MoveFocusToMenu -> true + // Bar focused: Home exits the app (fall through to the activity), + // any other section goes Home with the bar still focused. + TvShellBackAction.MenuBack -> { + if (selectedRoot == TvRootDestination.Home) { + false + } else { + navigateToRoute(firstTvRoute()) + focusState.requestMenuFocus() + true + } + } + // Secondary screens: pop the flat inner NavHost when possible; + // otherwise let the activity-level callback finish the app. + TvShellBackAction.DelegateToNav -> { + if (currentRoute == TvMainRoute.Search.route && + !searchInputHasFocus && + !searchBackToInputAttempted + ) { + searchBackToInputAttempted = true + searchBackToInputRequest += 1 + true + } else if (nestedNav.previousBackStackEntry != null) { + nestedNav.popBackStack() + true + } else { + false + } + } + } + } + + // Android 16 no longer dispatches KEYCODE_BACK to apps targeting API 36. + // Register the shell's stateful routing through the supported callback and + // enable it only when this layer can consume the press, so child callbacks + // and the activity fallback retain their existing priority. + val pendingShellBackAction = tvShellBackAction( + panelOpen = focusState.openPanel != null, + profileMenuOpen = focusState.profileMenuOpen, + menuFocused = focusState.isMenuFocused, + onTabRoot = selectedRoot != null, + // Must match what onBack() will decide, or the shell would decline the + // press and let navigation take it while handleShellBack expected it. + panelEntered = focusState.panelHasFocus, + // Must match what onBack() will decide, or the shell declines a press + // it would then have handled. + barHandoffAttempted = focusState.barHandoffAttempted, + onHome = selectedRoot == TvRootDestination.Home, + ) + val shellHandlesBack = currentRoute != TvMainRoute.Settings.route && when (pendingShellBackAction) { + TvShellBackAction.ClosePanel, + TvShellBackAction.ClosePanelPreview, + TvShellBackAction.CloseProfileMenu, + TvShellBackAction.MoveFocusToMenu -> true + TvShellBackAction.MenuBack -> selectedRoot != TvRootDestination.Home + TvShellBackAction.DelegateToNav -> + ( + currentRoute == TvMainRoute.Search.route && + !searchInputHasFocus && + !searchBackToInputAttempted + ) || + nestedNav.previousBackStackEntry != null + } + // NavHost installs its own predictive-back callback before composing the + // active destination. Put the shell callback inside each destination so + // it registers after Navigation, but before screen-level dialogs and + // overlays. That preserves the intended priority: screen > shell > nav. + val latestShellHandlesBack = rememberUpdatedState(shellHandlesBack) + val latestHandleShellBack = rememberUpdatedState<() -> Unit>({ handleShellBack() }) + fun NavGraphBuilder.shellComposable( + route: String, + arguments: List = emptyList(), + content: @Composable (NavBackStackEntry) -> Unit, + ) { + composable(route = route, arguments = arguments) { entry -> + BackHandler(enabled = latestShellHandlesBack.value) { + latestHandleShellBack.value() + } + content(entry) + } + } + Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - // Shell-level Back/Escape. Placed on the outer Box (an ancestor of - // BOTH the content layer and the top menu bar) so it fires no - // matter which has focus. When the menu is focused, Back returns to - // content instead of falling through to the activity and exiting. + // Keep the key-event path as a pre-Android-16 remote/keyboard + // fallback. API 36 Back presses arrive through BackHandler above. .onPreviewKeyEvent { ev -> if (ev.type == KeyEventType.KeyUp && (ev.key == Key.Back || ev.key == Key.Escape) ) { - // Settings owns a two-stage Back model (detail pane → - // selected rail category → Home). Let its BackHandler see - // the event instead of applying the shell-wide routing. - if (currentRoute == TvMainRoute.Settings.route) { - return@onPreviewKeyEvent false - } - // Centralized shell Back. The 4-way priority (panel > - // profile menu > focused bar > nav) is decided by - // [TvShellFocusState.onBack], which also applies the state - // half (close panel / dropdown); we run only the side effect - // each action needs. Keeping it here — not in the selector or - // the bar — means Back can never be double-handled. - when (focusState.onBack(onTabRoot = selectedRoot != null)) { - // Panel/dropdown already closed by onBack(): just consume. - TvShellBackAction.ClosePanel, - TvShellBackAction.CloseProfileMenu -> true - // Content on a tab root: onBack() already routed focus - // to the bar's selected tab — just consume. - TvShellBackAction.MoveFocusToMenu -> true - // Bar focused: Home exits the app (fall through to the - // activity), any other section goes Home with the bar - // still focused (now on the Home tab). - TvShellBackAction.MenuBack -> { - if (selectedRoot == TvRootDestination.Home) { - false - } else { - navigateToRoute(firstTvRoute()) - focusState.requestMenuFocus() - true - } - } - // Secondary screens (Settings, Search, admin, …): pop - // the flat inner NavHost when there's history; otherwise - // fall through so the activity finishes the app. - TvShellBackAction.DelegateToNav -> { - if (currentRoute == TvMainRoute.Search.route && !searchInputHasFocus) { - searchBackToInputRequest += 1 - true - } else if (nestedNav.previousBackStackEntry != null) { - // Focus restoration after the pop is owned by the - // restored screen itself (the section feed re-targets - // its last-focused card via its recreation ladder). - nestedNav.popBackStack() - true - } else { - false - } - } - } + handleShellBack() } else { false } @@ -747,15 +1031,9 @@ fun TvMainShell( .onFocusChanged { contentHasFocus = it.hasFocus } .focusRequester(contentFocusRequester) // During a detail-return resume the restorer's saved child is - // gone (the shell left composition), so fall back to the Home - // feed's launch-card requester; Default otherwise. - .focusRestorer( - if (restoreHomeContentAfterDetail) { - homeDetailReturnCardFocusRequester - } else { - FocusRequester.Default - }, - ) + // gone (the shell left composition), so fall back to the + // active feed's launch-card requester; Default otherwise. + .focusRestorer(detailReturnFallback) // Block any GEOMETRIC focus escape upward out of the content // group. Without this, moveFocus(Up) from the top content row // does a 2D search into the sibling top bar and lands on the @@ -777,18 +1055,35 @@ fun TvMainShell( .onPreviewKeyEvent { ev -> when { ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionUp -> { - val contentHandledUp = contentUpFallback?.invoke() + val isRepeat = ev.nativeKeyEvent.repeatCount > 0 + val contentHandledUp = contentUpFallback?.invoke(isRepeat) if (contentHandledUp != null) { - if (!contentHandledUp) { - focusState.requestMenuFocus() + if (shouldRequestMenuAfterContentUp(contentHandledUp, isRepeat)) { + focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, + ) } } else { // Try to move focus up inside content; if that // fails (we're already on the top row), hand // focus to the menu bar. val moved = focusManager.moveFocus(FocusDirection.Up) - if (!moved) { - focusState.requestMenuFocus() + // `exit = Cancel` above only guards the level of + // the search that owns the focused row; from a + // control that sits directly in the screen (e.g. + // the Calendar day shelf) the 2D search still + // escapes into the bar and lands on whatever is + // geometrically nearest — the Search icon from + // the left edge. A move that left content is + // therefore treated exactly like a failed move: + // route to the selected tab. + val escapedContent = moved && !contentHasFocus + if (shouldRequestMenuAfterContentUp(moved && !escapedContent, isRepeat)) { + focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, + ) } } // Always consume: we performed the move (or routed @@ -817,7 +1112,7 @@ fun TvMainShell( popEnterTransition = { fadeIn(tween(500)) }, popExitTransition = { fadeOut(tween(500)) }, ) { - composable(TvMainRoute.Video.route) { + shellComposable(TvMainRoute.Video.route) { TvHomeScreen( onItemClick = openHomeItemDetail, onPlayItem = onPlayItem, @@ -826,12 +1121,11 @@ fun TvMainShell( moveFocusToContent(TvMainRoute.Browse.route) }, onOpenForYou = { - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, - detailReturnFocusRequest = homeDetailReturnFocusRequest, + detailReturnFocusRequest = homeDetailReturnFocusState.requestId, detailReturnCardFocusRequester = homeDetailReturnCardFocusRequester, firstRowFocusRequester = homeFirstItemFocusRequester, firstRowContainerFocusRequester = homeFirstRowContainerFocusRequester, @@ -839,7 +1133,7 @@ fun TvMainShell( onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Home.route) { + shellComposable(TvMainRoute.Home.route) { TvHomeScreen( onItemClick = openHomeItemDetail, onPlayItem = onPlayItem, @@ -848,12 +1142,11 @@ fun TvMainShell( moveFocusToContent(TvMainRoute.Browse.route) }, onOpenForYou = { - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, - detailReturnFocusRequest = homeDetailReturnFocusRequest, + detailReturnFocusRequest = homeDetailReturnFocusState.requestId, detailReturnCardFocusRequester = homeDetailReturnCardFocusRequester, firstRowFocusRequester = homeFirstItemFocusRequester, firstRowContainerFocusRequester = homeFirstRowContainerFocusRequester, @@ -861,7 +1154,7 @@ fun TvMainShell( onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Search.route) { + shellComposable(TvMainRoute.Search.route) { TvSearchScreen( onResultClick = { item -> openBrowseItem( @@ -876,22 +1169,26 @@ fun TvMainShell( onOpenLibraryItem = onOpenItemDetail, searchFieldFocusRequester = searchInputFocusRequester, backToSearchFieldRequest = searchBackToInputRequest, - onSearchFieldFocusChanged = { searchInputHasFocus = it }, + onSearchFieldFocusChanged = { + searchInputHasFocus = it + // The field answered; the outstanding attempt is settled. + if (it) searchBackToInputAttempted = false + }, ) } - composable(TvMainRoute.Audio.route) { + shellComposable(TvMainRoute.Audio.route) { TvLibrariesScreen( - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.Libraries.route) { + shellComposable(TvMainRoute.Libraries.route) { TvLibrariesScreen( - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } @@ -900,70 +1197,77 @@ fun TvMainShell( // picker stays the switch mechanism this stage (TvLibrariesScreen // still hosts it for the legacy Libraries route); the cascade // selector arrives in Stage 4. - composable(TvMainRoute.Movies.route) { + shellComposable(TvMainRoute.Movies.route) { TvLibraryTypeContent( type = TvLibraryTabType.Movies, library = activeLibrary(TvLibraryTabType.Movies), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Movies.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Movies] ?: TvLibraryPill.Recommended, sectionRequestNonce = sectionRequestNonces[TvLibraryTabType.Movies] ?: 0, - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Series.route) { + shellComposable(TvMainRoute.Series.route) { TvLibraryTypeContent( type = TvLibraryTabType.Series, library = activeLibrary(TvLibraryTabType.Series), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Series.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Series] ?: TvLibraryPill.Recommended, sectionRequestNonce = sectionRequestNonces[TvLibraryTabType.Series] ?: 0, - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Music.route) { + shellComposable(TvMainRoute.Music.route) { TvLibraryTypeContent( type = TvLibraryTabType.Music, library = activeLibrary(TvLibraryTabType.Music), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Music.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Music] ?: TvLibraryPill.Recommended, sectionRequestNonce = sectionRequestNonces[TvLibraryTabType.Music] ?: 0, - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Audiobooks.route) { + shellComposable(TvMainRoute.Audiobooks.route) { TvLibraryTypeContent( type = TvLibraryTabType.Audiobooks, library = activeLibrary(TvLibraryTabType.Audiobooks), emptyConfirmed = librariesLoaded && libraries.none { TvLibraryTabType.Audiobooks.matches(it) }, selectedPill = pillSelections[TvLibraryTabType.Audiobooks] ?: TvLibraryPill.Recommended, sectionRequestNonce = sectionRequestNonces[TvLibraryTabType.Audiobooks] ?: 0, - onItemClick = onOpenItemDetail, - onLibraryCollectionClick = onOpenLibraryCollectionDetail, - onUserCollectionClick = onOpenCollectionDetail, + onItemClick = openContentItemDetail, + onLibraryCollectionClick = openLibraryCollectionDetail, + onUserCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.ForYou.route) { + shellComposable(TvMainRoute.ForYou.route) { TvRecommendationsScreen( - onItemClick = onOpenItemDetail, + onSavedListItemClick = openContentItemDetail, + onRecommendationItemClick = openForYouItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, focusRequest = contentFocusRequest, + detailReturnFocusRequest = forYouDetailReturnFocusState.requestId, + detailReturnCardFocusRequester = forYouDetailReturnCardFocusRequester, + firstRowFocusRequester = forYouFirstItemFocusRequester, + firstRowContainerFocusRequester = forYouFirstRowContainerFocusRequester, + onContentUpFallbackChanged = onContentUpFallback, + entryRequest = forYouEntryRequest, ) } - composable(TvMainRoute.Requests.route) { + shellComposable(TvMainRoute.Requests.route) { TvRequestsScreen( onOpenLibraryItem = onOpenItemDetail, onOpenMyRequests = { navigateToSecondary(TvMainRoute.MyRequests.route) }, @@ -973,7 +1277,7 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.MyRequests.route) { + shellComposable(TvMainRoute.MyRequests.route) { TvMyRequestsScreen( onOpenLibraryItem = onOpenItemDetail, onOpenRequestDetail = { mt, id -> @@ -982,34 +1286,7 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.LiveTv.route) { - TvLiveTvScreen( - onChannelClick = { channel -> - navigateToSecondary( - TvMainRoute.LiveTvPlayer(channel.id, channel.displayName).route, - ) - }, - onInitialContentFocus = { focusState.closeProfileMenuForContent() }, - ) - } - composable( - route = TvMainRoute.LiveTvPlayer.ROUTE, - arguments = listOf( - navArgument(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, - navArgument(TvMainRoute.LiveTvPlayer.ARG_NAME) { - type = NavType.StringType - nullable = true - defaultValue = "" - }, - ), - ) { entry -> - TvLiveTvPlayerScreen( - channelId = entry.arguments?.getString(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty(), - channelName = entry.arguments?.getString(TvMainRoute.LiveTvPlayer.ARG_NAME).orEmpty(), - onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - ) - } - composable( + shellComposable( route = TvMainRoute.RequestDetail.ROUTE, arguments = listOf( navArgument(TvMainRoute.RequestDetail.ARG_MEDIA_TYPE) { type = NavType.StringType }, @@ -1022,41 +1299,34 @@ fun TvMainShell( onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, ) } - composable(TvMainRoute.Collections.route) { + shellComposable(TvMainRoute.Collections.route) { TvCollectionsScreen( - onCollectionClick = onOpenCollectionDetail, + onCollectionClick = openCollectionDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.Watchlist.route) { + shellComposable(TvMainRoute.Watchlist.route) { TvWatchlistScreen( - onItemClick = onOpenItemDetail, + onItemClick = openContentItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.Favorites.route) { + shellComposable(TvMainRoute.Favorites.route) { TvFavoritesScreen( - onItemClick = onOpenItemDetail, + onItemClick = openContentItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.History.route) { + shellComposable(TvMainRoute.History.route) { TvHistoryScreen( - onItemClick = onOpenItemDetail, + onItemClick = openContentItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.Settings.route) { + shellComposable(TvMainRoute.Settings.route) { TvSettingsScreen( - onNavigateToAdmin = { - // Apple parity: the stats dashboard is the whole - // admin surface. The hub (users/sessions/logs/ - // scans) stays compiled but unlinked. - navigateToSecondary(TvMainRoute.AdminDashboard.route) - moveFocusToContent(TvMainRoute.AdminDashboard.route) - }, onManageServers = onManageServers, - onNavigateToDiagnostics = onOpenDiagnostics, + onOpenDiagnosticsReport = onOpenDiagnosticsReport, initialManageServersFocus = returnToManageServers, onManageServersReturnFocusConsumed = onManageServersReturnFocusConsumed, onSignedOut = onSignedOut, @@ -1067,76 +1337,83 @@ fun TvMainShell( onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.ManageSessions.route) { - TvManageSessionsScreen(onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }) - } - composable(TvMainRoute.Calendar.route) { + shellComposable(TvMainRoute.Calendar.route) { TvCalendarScreen( onOpenItemDetail = onOpenItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() calendarFocusHandoffPending = false }, + onMoveUpToMenu = { + focusState.requestMenuFocus( + TvTopMenuPanel.Root(TvRootDestination.Calendar), + ) + }, focusRequest = contentFocusRequest, + onContentUpFallbackChanged = onContentUpFallback, ) } - composable(TvMainRoute.Browse.route) { + shellComposable(TvMainRoute.Browse.route) { TvBrowseScreen( - onOpenItemDetail = onOpenItemDetail, + onOpenItemDetail = openContentItemDetail, onInitialContentFocus = { focusState.closeProfileMenuForContent() }, ) } - composable(TvMainRoute.AdminHub.route) { - TvAdminHubScreen( - onOpenDashboard = { navigateToSecondary(TvMainRoute.AdminDashboard.route) }, - onOpenUsers = { navigateToSecondary(TvMainRoute.AdminUsers.route) }, - onOpenSessions = { navigateToSecondary(TvMainRoute.AdminSessions.route) }, - onOpenScans = { navigateToSecondary(TvMainRoute.AdminScans.route) }, - onOpenLogs = { navigateToSecondary(TvMainRoute.AdminLogs.route) }, - onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - ) - } - composable(TvMainRoute.AdminDashboard.route) { - TvAdminScreen(onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }) - } - composable(TvMainRoute.AdminUsers.route) { - TvAdminUsersScreen( - onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - onCreateUser = { navigateToForm(TvMainRoute.AdminUserEdit().route) }, - onEditUser = { id -> navigateToForm(TvMainRoute.AdminUserEdit(id).route) }, - ) - } - composable( - route = TvMainRoute.AdminUserEdit.ROUTE, - arguments = listOf( - navArgument(TvMainRoute.AdminUserEdit.ARG_USER_ID) { - type = NavType.StringType - nullable = true - defaultValue = null + // ---- Removed route aliases (defensive) ---- see + // [TvRemovedMainRoutes]. Registered, never rendered: each one + // redirects into Settings so a back stack saved by a build that + // still had the admin/session screens can be restored. + for (removedRoute in TvRemovedMainRoutes) { + composable( + route = removedRoute, + // A pattern carrying a placeholder cannot be registered + // without the matching argument declared. + arguments = if ("{userId}" in removedRoute) { + listOf( + navArgument("userId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ) + } else { + emptyList() }, - ), - ) { entry -> - val userId = entry.arguments - ?.getString(TvMainRoute.AdminUserEdit.ARG_USER_ID) - ?.toIntOrNull() - TvAdminUserEditScreen( - userId = userId, - onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - onSaved = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, - ) - } - composable(TvMainRoute.AdminSessions.route) { - TvAdminSessionsScreen(onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }) - } - composable(TvMainRoute.AdminScans.route) { - TvAdminScansScreen(onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }) - } - composable(TvMainRoute.AdminLogs.route) { - TvAdminLogsScreen(onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }) + ) { + LaunchedEffect(Unit) { + nestedNav.navigate(TvMainRoute.Settings.route) { + popUpTo(removedRoute) { inclusive = true } + launchSingleTop = true + } + } + } } } } + // The scrim TvTopMenuBar documents but the shell had stopped drawing. + // The bar deliberately has no background band of its own ("the SHELL + // draws a fixed top scrim behind the bar", QA 2026-07-08); without it + // the labels sit directly on whatever scrolled underneath. The gradient + // keeps the hero visible behind the bar on every route. + if (currentRoute != TvMainRoute.Settings.route) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(TvTopMenuLayout.contentTopInset) + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0.92f), + MaterialTheme.colorScheme.background.copy(alpha = 0.72f), + MaterialTheme.colorScheme.background.copy(alpha = 0f), + ), + ), + ), + ) + } + // Menu overlay — content remains visible behind the transparent bar, // matching tvOS without a heavy top-edge shadow. TvTopMenuBar( @@ -1178,6 +1455,10 @@ fun TvMainShell( currentRoute == TvMainRoute.Settings.route, focusRequest = focusState.menuFocusRequest, focusRequestTarget = focusState.menuFocusTarget, + focusRequestSuppressesDwell = focusState.menuFocusSuppressesDwell, + // Lets Back move focus to the anchor tab while the cascade is still + // composed — removing it afterwards then has no focus to recover. + onInstallAnchorFocus = { hook -> focusState.focusBarAnchorNow = hook }, profileFocusRequest = focusState.profileFocusRequest, isSearchActive = currentRoute == TvMainRoute.Search.route, visibility = if (currentRoute == TvMainRoute.Settings.route) 0f else menuVisibility.value, @@ -1240,19 +1521,13 @@ fun TvMainShell( entersPanel = active && focusState.panelEntersFocus, focusEntryToken = focusState.panelFocusEntryToken, onWatchlist = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.Watchlist.route) - moveFocusToContent(TvMainRoute.Watchlist.route) + openForYou(SavedListSelection.Watchlist) }, onFavorites = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.Favorites.route) - moveFocusToContent(TvMainRoute.Favorites.route) + openForYou(SavedListSelection.Favorites) }, onRecommendations = { - focusState.closePanel(false) - navigateToSecondary(TvMainRoute.ForYou.route) - moveFocusToContent(TvMainRoute.ForYou.route) + openForYou(null) }, ) } @@ -1294,8 +1569,9 @@ fun TvMainShell( focusEntryToken = focusState.panelFocusEntryToken, onCommitLibrary = { lib -> commitScope(dest.type, lib, TvLibraryPill.Recommended) }, onCommitSection = { lib, pill -> commitScope(dest.type, lib, pill) }, - onPanelFocusChanged = { /* optional bar-dim tracking */ }, - onClose = { focusState.closePanel(true) }, + // Where focus actually is, which is what Back routing + // needs — the entry flag only records intent. + onPanelFocusChanged = { focusState.onPanelFocusChanged(it) }, modifier = Modifier, ) } @@ -1325,10 +1601,11 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.Requests.route) moveFocusToContent(TvMainRoute.Requests.route) }, - showLiveTv = liveTvEnabled, - onLiveTv = closeMenuAnd { - navigateToSecondary(TvMainRoute.LiveTv.route) - moveFocusToContent(TvMainRoute.LiveTv.route) + showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, + onWatchTogether = { + focusState.closeProfileMenuForContent() + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = true }, onSettings = { // Keep focus on the dropdown row through the route fade. @@ -1351,6 +1628,38 @@ fun TvMainShell( .zIndex(2f), ) } + + if (watchTogetherEntryOpen) { + if (watchTogetherJoinOpen) { + TvJoinCodeDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onJoin = watchTogetherViewModel::joinRoom, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = false + }, + ) + } else { + TvWatchTogetherMenuEntryDialog( + canResume = currentWatchTogetherRoom != null, + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onResume = { watchTogetherViewModel.resumeCurrentRoom() }, + onHost = { watchTogetherViewModel.createEmptyVoteRoom() }, + onJoin = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = true + }, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + focusState.dismissProfileMenu() + }, + ) + } + } } } @@ -1370,10 +1679,15 @@ private fun TvLibraryTypeContent( selectedPill: TvLibraryPill, sectionRequestNonce: Int, onItemClick: (contentId: String) -> Unit, - onLibraryCollectionClick: (libraryId: Int, collectionId: String, title: String) -> Unit, + onLibraryCollectionClick: ( + libraryId: Int, + collectionId: String, + title: String, + libraryType: String, + ) -> Unit, onUserCollectionClick: (collectionId: String, title: String) -> Unit, onInitialContentFocus: () -> Unit, - onContentUpFallbackChanged: (((() -> Boolean)?) -> Unit)? = null, + onContentUpFallbackChanged: ((((Boolean) -> Boolean)?) -> Unit)? = null, ) { if (library == null) { // Only assert "no libraries" once loading has settled AND this type @@ -1410,7 +1724,7 @@ private fun TvLibraryTypeContent( if (isUserCollection) { onUserCollectionClick(collectionId, title) } else { - onLibraryCollectionClick(library.id, collectionId, title) + onLibraryCollectionClick(library.id, collectionId, title, library.type) } }, onInitialContentFocus = onInitialContentFocus, @@ -1459,6 +1773,13 @@ private fun mapRouteToRoot(route: String): TvRootDestination? = when (route) { else -> null } +/** Bar tab that owns a non-root route for content→bar Up (see selectedMenuFocusTarget). */ +private fun menuFocusRootForRoute(route: String): TvRootDestination? = when (route) { + TvMainRoute.Watchlist.route, + TvMainRoute.Favorites.route -> TvRootDestination.ForYou + else -> null +} + private fun TvRootDestination.toRoute(): String = when (this) { TvRootDestination.Home -> TvMainRoute.Home.route TvRootDestination.ForYou -> TvMainRoute.ForYou.route @@ -1522,8 +1843,8 @@ private fun cascadePanelOffset( * returns focus to the avatar via [onDismiss]. * * Row set + order mirrors tvOS: Switch Profile · Watchlist · Favorites · - * History · Requests (feature-gated) · Settings · Switch Server · Sign Out. - * Calendar is a top-level tab. + * History · Requests (server-gated) · Watch Together (client-policy-gated) · + * Settings · Switch Server · Sign Out. Calendar is a top-level tab. */ @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -1537,8 +1858,8 @@ private fun TvProfileDropdown( onHistory: () -> Unit, showRequests: Boolean, onRequests: () -> Unit, - showLiveTv: Boolean, - onLiveTv: () -> Unit, + showWatchTogether: Boolean, + onWatchTogether: () -> Unit, onSettings: () -> Unit, onSwitchServer: () -> Unit, onSignOut: () -> Unit, @@ -1591,11 +1912,11 @@ private fun TvProfileDropdown( onClick = onRequests, ) } - if (showLiveTv) { + if (showWatchTogether) { ProfileDropdownRow( - label = "Live TV", - icon = Icons.Filled.LiveTv, - onClick = onLiveTv, + label = "Watch Together", + icon = Icons.Filled.People, + onClick = onWatchTogether, ) } @@ -1617,6 +1938,7 @@ private fun ProfileDropdownHeader(accountState: TvAccountState) { val avatarText = remember(accountState.avatar, accountState.displayName) { profileAvatarDisplayText(accountState.avatar, accountState.displayName) } + val avatarImage = rememberProfileAvatarImage(accountState.avatar) Row( modifier = Modifier .fillMaxWidth() @@ -1634,14 +1956,16 @@ private fun ProfileDropdownHeader(accountState: TvAccountState) { .background(Color.White.copy(alpha = 0.16f)), contentAlignment = Alignment.Center, ) { - if (accountState.avatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = accountState.avatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = accountState.displayName, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) } else { Text( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt index ab51330e0..bc1f21063 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt @@ -54,6 +54,14 @@ sealed interface TvShellBackAction { /** Content on a tab root pressed Back: the caller focuses the bar's selected tab. */ data object MoveFocusToMenu : TvShellBackAction + /** + * A dwell PREVIEW was showing while focus stayed on the bar. Back dismisses + * the preview and nothing else: the viewer never left the chrome, so moving + * them into content would be a jump they did not ask for, and would cost + * them the trip back up to reach Home. + */ + data object ClosePanelPreview : TvShellBackAction + /** Nothing to dismiss; the caller pops the nested NavHost or lets the activity finish. */ data object DelegateToNav : TvShellBackAction } @@ -79,22 +87,41 @@ internal fun tvShellMode( /** * Pure Back/Escape routing, mirroring the historical shell `onPreviewKeyEvent` * `when`. The order is load-bearing and was settled across several "fix focus" - * commits: an open cascade panel (even a mere preview) is dismissed first, then - * the profile dropdown, then a focused menu bar hands focus back to content; - * only with nothing to dismiss does Back fall through to navigation. + * commits: an open cascade panel is dismissed first — a preview + * without moving focus, an entered panel by handing focus to content — then the + * profile dropdown, then a focused menu bar; only with nothing to dismiss does + * Back fall through to navigation. */ internal fun tvShellBackAction( panelOpen: Boolean, profileMenuOpen: Boolean, menuFocused: Boolean, onTabRoot: Boolean, + panelEntered: Boolean = true, + barHandoffAttempted: Boolean = false, + onHome: Boolean = false, ): TvShellBackAction = when { + // A panel the viewer never entered is a dwell preview: focus is still on + // the bar, so dismissing it must leave focus there. Routing this through + // ClosePanel sent them into content from a menu they were still browsing. + panelOpen && !panelEntered -> TvShellBackAction.ClosePanelPreview panelOpen -> TvShellBackAction.ClosePanel profileMenuOpen -> TvShellBackAction.CloseProfileMenu // Back-stack model (QA 2026-07-08): content Back on a tab root climbs to // the bar; Back on the bar goes Home (or exits from Home). Secondary // screens (Settings, Search, …) still pop navigation. menuFocused -> TvShellBackAction.MenuBack + // The handoff to the bar was already asked for and the bar never reported + // taking focus. Asking again is what strands the viewer: every Back + // re-evaluates to MoveFocusToMenu, MenuBack is never reached, and Home and + // exit become unreachable — observed on a Google TV Streamer as four + // consecutive "focus request -> menu" with no "focused -> menu" between + // them. Progress matters more than tidiness here, so the second Back goes + // Home regardless of where focus actually is. + // Escalate only where MenuBack actually NAVIGATES. On Home, MenuBack means + // "exit the app", so escalating there turns a Back the viewer expected to + // move focus into quitting Silo — which is worse than the loop it fixes. + onTabRoot && barHandoffAttempted && !onHome -> TvShellBackAction.MenuBack onTabRoot -> TvShellBackAction.MoveFocusToMenu else -> TvShellBackAction.DelegateToNav } @@ -129,10 +156,52 @@ class TvShellFocusState { var menuFocusTarget by mutableStateOf(null) private set + /** + * Whether [menuFocusRequest] should also suppress its target's dwell + * preview. + * + * Only a panel Back-close wants that: reopening the panel the user just + * dismissed is the thing being prevented. An ordinary content-to-bar Up + * carries a target too — it decides which tab to land on — and arming + * suppression from that left the tab you came back to unable to reopen its + * own cascade at all, which is what testers hit on Movies and TV alike. + */ + var menuFocusSuppressesDwell by mutableStateOf(false) + private set + + /** + * Whether anything inside the open panel actually holds focus, reported by + * the selector as its rows and pills gain and lose it. + * + * Back routing asks this rather than [panelEntersFocus], because entry + * INTENT is not entry: an empty panel, an unattached requester, or a claim + * that silently failed all leave focus on the bar while the intent flag + * says otherwise — and Back would then throw the viewer into content from a + * bar they never left. + */ + var panelHasFocus by mutableStateOf(false) + private set + + fun onPanelFocusChanged(focused: Boolean) { + panelHasFocus = focused + } + /** Nudge the menu bar to return focus to the profile avatar. */ var profileFocusRequest by mutableIntStateOf(0) private set + /** + * True once Back has asked the bar to take focus and the bar has not yet + * reported doing so. + * + * [requestMenuFocus] only bumps a token — it cannot know whether the claim + * landed, and nothing corrected it when it did not. This records the + * attempt so a second Back can escalate instead of repeating a request that + * is evidently not working. + */ + var barHandoffAttempted by mutableStateOf(false) + private set + /** Re-fire the cascade selector's focus-entry effect when a panel is entered. */ var panelFocusEntryToken by mutableIntStateOf(0) private set @@ -153,7 +222,12 @@ class TvShellFocusState { var openPanel by mutableStateOf(null) private set - /** True once the user has committed to *entering* [openPanel] (vs previewing). */ + /** + * True once the user has committed to *entering* [openPanel] (vs previewing). + * + * Intent, not arrival: this is set before the selector's asynchronous focus + * request runs. Use [panelHasFocus] for questions about where focus IS. + */ var panelEntersFocus by mutableStateOf(false) private set @@ -175,13 +249,36 @@ class TvShellFocusState { // --- Menu-bar focus signals ------------------------------------------------- + /** + * Moves bar focus to a panel's anchor RIGHT NOW, installed by the bar. + * + * Closing a cascade removes the focused node, and Compose recovers focus a + * frame before any request of ours can land — it picks the bar's first + * child, so Back visibly flashed through the search icon on its way to the + * anchor. Nothing written to state can win that frame; the fix is to move + * focus while the panel is still composed, leaving no recovery to lose. + * Returns whether focus actually moved. + */ + var focusBarAnchorNow: ((TvTopMenuPanel?) -> Boolean)? = null + /** Route focus to the bar's selected tab (content → bar Up, or panel close). */ - fun requestMenuFocus(target: TvTopMenuPanel? = null) { + fun requestMenuFocus(target: TvTopMenuPanel? = null, suppressDwellPreview: Boolean = false) { DiagnosticsFocusLogger.transition(target?.diagnosticsTarget() ?: "menu", "request") menuFocusTarget = target + menuFocusSuppressesDwell = suppressDwellPreview menuFocusRequest++ } + /** + * Route content focus back to the bar only when the shell has a concrete root + * target, or when a route-specific owner intentionally handles a null target + * (currently Search). Other secondary routes must not fall through to Home. + */ + fun requestMenuFocusIfAvailable(target: TvTopMenuPanel?, allowNullTarget: Boolean = false) { + if (target == null && !allowNullTarget) return + requestMenuFocus(target) + } + /** * Record whether a bar button holds focus. Focus on the bar means we are not * inside a panel, so clear any stale entered flag — otherwise a geometric @@ -194,6 +291,8 @@ class TvShellFocusState { } isMenuFocused = focused if (focused) { + // The bar answered, so the outstanding handoff is settled. + barHandoffAttempted = false panelEntersFocus = false if (profileMenuOpen && !profileMenuEntered) profileMenuOpen = false } @@ -267,44 +366,91 @@ class TvShellFocusState { } /** - * Close any open panel. [returnFocusToBar] re-focuses the originating tab on - * a Back-close; a commit passes false so its own content-focus move is not - * raced back to the bar by the focus bump. + * Close any open panel, leaving focus where it is. Callers that want focus + * moved do it themselves, so a commit's own content-focus move is never + * raced back to the bar by a focus bump from here. */ - fun closePanel(returnFocusToBar: Boolean) { + fun closePanel() { + // Closing hands focus somewhere deliberate, so any stale unanswered + // handoff no longer describes the current situation. + barHandoffAttempted = false val closingPanel = openPanel openPanel = null panelEntersFocus = false + panelHasFocus = false DiagnosticsFocusLogger.transition(closingPanel?.diagnosticsTarget() ?: "panel", "close") - if (returnFocusToBar && closingPanel != null) { - requestMenuFocus(closingPanel) - } } // --- Back routing ----------------------------------------------------------- /** * Apply the state half of a shell Back press and report what the caller - * should do for the side-effecting cases ([TvShellBackAction.MoveFocusToContent] - * and [TvShellBackAction.DelegateToNav] are left to the composable, which owns + * should do for the side-effecting cases ([TvShellBackAction.ClosePanel] and + * [TvShellBackAction.DelegateToNav] are left to the composable, which owns * the focus manager and nav controller). */ - fun onBack(onTabRoot: Boolean): TvShellBackAction { + fun onBack( + onTabRoot: Boolean, + menuFocusTarget: TvTopMenuPanel? = null, + onHome: Boolean = false, + ): TvShellBackAction { val action = tvShellBackAction( panelOpen = openPanel != null, profileMenuOpen = profileMenuOpen, menuFocused = isMenuFocused, onTabRoot = onTabRoot, + panelEntered = panelHasFocus, + barHandoffAttempted = barHandoffAttempted, + onHome = onHome, ) when (action) { - TvShellBackAction.ClosePanel -> closePanel(returnFocusToBar = true) + // Back out of a cascade the viewer ENTERED hands focus to content, + // not back to the anchor tab. Parking them one level up in the + // chrome is what "back doesn't exit the menus" meant. + TvShellBackAction.ClosePanel -> closePanelOntoAnchor() + // The preview case looks like focus never left the bar, but it is + // not necessarily on the ANCHOR: with no explicit target the bar + // falls back to selectedEntryRequester(), which is the selected tab + // — Home, or the search icon on the Search route. Backing out of + // Movies' cascade therefore landed on Home. + TvShellBackAction.ClosePanelPreview -> closePanelOntoAnchor() TvShellBackAction.CloseProfileMenu -> dismissProfileMenu() - TvShellBackAction.MoveFocusToMenu -> requestMenuFocus() + // Back from content climbs to the bar, and must NOT pop that tab's + // cascade on arrival: the viewer is leaving, not browsing. Without + // this the next Back sees an open panel and closes it back to + // content, so Back ping-pongs and never reaches MenuBack — Home and + // exit become unreachable. Up from content is the browsing case and + // stays unsuppressed, which is what makes the cascade openable. + TvShellBackAction.MoveFocusToMenu -> { + barHandoffAttempted = true + requestMenuFocus(menuFocusTarget, suppressDwellPreview = true) + } TvShellBackAction.MenuBack, TvShellBackAction.DelegateToNav -> Unit } return action } + + /** + * Close the open panel and leave focus on ITS anchor tab, with that tab's + * dwell preview suppressed. + * + * Order matters: focus moves first, while the panel is still composed, so + * removing it never triggers Compose's focus recovery. The state request is + * the fallback for when the bar has not installed its hook, or the anchor is + * not focusable yet — it lands a frame later, which is exactly the window + * that made Back flash through the search icon. + * + * The suppression is the other half: without it the anchor re-previews its + * cascade ~250ms later and the next Back is spent closing that preview + * instead of reaching MenuBack, so Home stays unreachable. + */ + private fun closePanelOntoAnchor() { + val anchor = openPanel + val moved = focusBarAnchorNow?.invoke(anchor) ?: false + closePanel() + if (!moved) requestMenuFocus(anchor, suppressDwellPreview = true) + } } /** Remembers a [TvShellFocusState] for the lifetime of the shell composition. */ diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt index fbae79ac3..99288a2c6 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.tv.ui.shell import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusGroup @@ -23,9 +24,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -44,6 +47,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource import kotlinx.coroutines.delay import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -56,8 +60,12 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.prairieserver.prairie.tv.R +import org.prairieserver.prairie.tv.ui.focus.claimFocusOrReport import org.prairieserver.prairie.common.ui.components.ThumbhashImage +import org.prairieserver.prairie.common.ui.components.ProfileAvatarRef import org.prairieserver.prairie.common.ui.components.profileAvatarDisplayText +import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.tv.ui.theme.ChromeSelectedBorder import org.prairieserver.prairie.tv.ui.theme.ChromeSelectedFill import org.prairieserver.prairie.tv.ui.theme.PrairieOnSurface @@ -69,6 +77,18 @@ import org.prairieserver.prairie.tv.ui.theme.navRailLabel private const val TopMenuInitialPreviewDelayMillis = 180L private const val TopMenuPanelSwitchDelayMillis = 80L +/** + * How long a non-anchor focus must hold before it disarms dwell suppression. + * Shorter than [TopMenuInitialPreviewDelayMillis] so a real move still previews + * promptly, long enough to outlast the one-frame focus blip Compose emits while + * an explicit bar focus request is being applied. + */ +private const val TopMenuSuppressionHandoffGraceMillis = 120L + +/** Upper bound on the single-focusable handoff window. */ +private const val TopMenuHandoffTimeoutMillis = 500L + + /** * Layout constants for the top menu band. Vertical-clearance / anchor tokens * here are consumed by every root screen (`contentTopInset`) and by the shell's @@ -115,7 +135,8 @@ private sealed class TvTopMenuFocus { * The custom top menu bar — the Skyline grammar from tvOS `TVTopMenuBar.swift`. * * Layout (three zones): - * - Leading: the **PRAIRIE** wordmark (heavy, tracked). + * - Leading: the Silo brand lockup (`R.drawable.prairie_wordmark`, see + * [TvSiloWordmark]). * - Center: Search icon · `Home` · one inverted-capsule tab per visible * library-type · `Calendar`, derived from [destinations] (the shell's * `visibleRoots`), with an invisible search-size twin trailing the tabs so @@ -158,6 +179,18 @@ fun TvTopMenuBar( isFocusSuppressed: Boolean, focusRequest: Int, focusRequestTarget: TvTopMenuPanel? = null, + /** + * True for a deliberate shell handoff to the bar — Back up from content, or + * the fallback when content has nothing focusable. See + * TvShellFocusState.menuFocusSuppressesDwell. + */ + focusRequestSuppressesDwell: Boolean = false, + /** + * Receives a hook that moves bar focus to a panel's anchor synchronously, + * so a closing cascade never leaves focus for Compose to recover. See + * TvShellFocusState.focusBarAnchorNow. + */ + onInstallAnchorFocus: ((TvTopMenuPanel?) -> Boolean) -> Unit = {}, profileFocusRequest: Int = 0, isSearchActive: Boolean = false, visibility: Float = 1f, @@ -188,6 +221,26 @@ fun TvTopMenuBar( // uses the first Down from that state as a direct handoff to content. var dwellSuppressedButton by remember { mutableStateOf(null) } + // While a Back-close handoff is in flight, the anchor is the ONLY focusable + // bar element. Compose recovers focus the instant the cascade's node leaves + // composition and picks the bar's FIRST child — the search icon — a frame + // before the explicit request lands, so Back visibly flashed through search + // on its way to the anchor. Taking the other buttons out of focus search for + // that one window leaves the recovery nowhere to go but the anchor itself. + val handoffAnchor = dwellSuppressedButton?.takeIf { it != focusedButton } + fun canFocusButton(focus: TvTopMenuFocus): Boolean = + !isFocusSuppressed && (handoffAnchor == null || handoffAnchor == focus) + + // The anchor focusing clears handoffAnchor and cancels this. If the request + // never lands, release the restriction rather than leaving the bar with a + // single focusable button. + LaunchedEffect(handoffAnchor) { + if (handoffAnchor != null) { + delay(TopMenuHandoffTimeoutMillis) + if (dwellSuppressedButton == handoffAnchor) dwellSuppressedButton = null + } + } + fun focusForRoot(root: TvRootDestination): TvTopMenuFocus = when (root) { TvRootDestination.Home -> TvTopMenuFocus.Home TvRootDestination.ForYou -> TvTopMenuFocus.ForYou @@ -229,15 +282,52 @@ fun TvTopMenuBar( // suppression lift (e.g. closing the profile dropdown, which flips // isFocusSuppressed false) does NOT also re-grab the selected tab and fight // the dedicated profile-avatar focus path below. - var lastHandledFocusRequest by remember { mutableStateOf(0) } - LaunchedEffect(focusRequest, isFocusSuppressed) { - if (isFocusSuppressed) return@LaunchedEffect - if (focusRequest == lastHandledFocusRequest) return@LaunchedEffect - lastHandledFocusRequest = focusRequest - val explicitFocus = focusRequestTarget?.let(::focusForPanel) - dwellSuppressedButton = explicitFocus - val requester = explicitFocus?.let(::requesterForFocus) ?: selectedEntryRequester() - runCatching { requester.requestFocus() } + val currentFocusRequestTarget by rememberUpdatedState(focusRequestTarget) + val currentDestinations by rememberUpdatedState(destinations) + var lastHandledFocusRequest by remember { mutableStateOf>(0 to null) } + val focusRequestIdentity = focusRequest to focusRequestTarget + val focusRequestTargetAvailable = + isTopMenuFocusTargetAvailable(focusRequestTarget, destinations) + LaunchedEffect( + focusRequest, + focusRequestTarget, + isFocusSuppressed, + focusRequestTargetAvailable, + ) { + lastHandledFocusRequest = handleTopMenuFocusRequestIfAvailable( + requestIdentity = focusRequestIdentity, + lastHandledRequest = lastHandledFocusRequest, + isFocusSuppressed = isFocusSuppressed, + // Availability gates only the EXPLICIT target, never the request + // itself. Skipping the whole request left nothing focused, so + // Compose's default search landed on the first bar element — the + // search icon — and Back out of a cascade appeared to "go to + // search". Falling back to the selected entry keeps the viewer on + // the tab they came from. + isTargetAvailable = true, + requestFocus = { + val explicitFocus = focusRequestTarget + ?.takeIf { focusRequestTargetAvailable } + ?.let(::focusForPanel) + // The target names which bar element to land on; it does NOT by + // itself mean the preview should be suppressed. Only a panel + // Back-close wants that. Arming it for every targeted request + // meant an ordinary content-to-bar Up — which also carries a + // target — left that tab unable to reopen its own cascade. + dwellSuppressedButton = explicitFocus.takeIf { focusRequestSuppressesDwell } + val requester = explicitFocus?.let(::requesterForFocus) ?: selectedEntryRequester() + requestTopMenuFocusUntilApplied( + awaitFrame = { androidx.compose.runtime.withFrameNanos { } }, + isTargetCurrent = { + currentFocusRequestTarget == focusRequestTarget && + isTopMenuFocusTargetAvailable(focusRequestTarget, currentDestinations) + }, + requestFocus = { + runCatching { requester.requestFocus() }.getOrDefault(false) + }, + ) + }, + ) } LaunchedEffect(focusedButton) { @@ -268,7 +358,23 @@ fun TvTopMenuBar( if (suppressed != null) { // A transient null is the panel→bar focus handoff itself; keep the // suppression armed until the requested anchor actually focuses. + // A transient null is the panel→bar focus handoff itself; keep the + // suppression armed until the requested anchor actually focuses, + // and hold it while that anchor keeps focus so a deliberate handoff + // to the bar does not flash a panel straight back open. This can no + // longer wedge the tab: only an explicit requestMenuFocus arms it, + // so an ordinary content-to-bar Up arrives unsuppressed and opens + // the cascade. if (focus == null || focus == suppressed) return@LaunchedEffect + // A DIFFERENT button may still be the handoff in flight rather than + // a real move: while the requested anchor is being applied, Compose + // briefly focuses the bar's first child (the Search icon). Clearing + // on that blip disarmed the suppression, so the anchor re-previewed + // the moment it actually landed — Back out of a cascade reopened it + // and the viewer was left one Back short of Home. Wait out the blip; + // this effect is keyed on focusedButton, so the anchor arriving + // cancels the delay and leaves the suppression armed. + delay(TopMenuSuppressionHandoffGraceMillis) // Moving anywhere else re-arms normal dwell behavior, matching // tvOS's dwellSuppressedElement lifecycle. dwellSuppressedButton = null @@ -306,6 +412,31 @@ fun TvTopMenuBar( // trailing cluster). On non-tab routes (Search) we enter the search icon. val barEntryRequester = selectedEntryRequester() + // Publish the synchronous anchor-focus hook. This runs on the composition + // thread, so a Back handler can move focus BEFORE it removes the panel. + SideEffect { + onInstallAnchorFocus { panel -> + val target = panel?.let(::focusForPanel) + val requester = target?.let(::requesterForFocus) ?: selectedEntryRequester() + // requestFocus() RETURNS whether the claim was accepted, so + // runCatching{}.isSuccess threw the real answer away — it is true + // for any call that merely did not throw. That made a refused + // claim look like a move, which armed suppression, closed the + // panel and skipped the deferred fallback, leaving focus nowhere. + val accepted = requester.claimFocusOrReport( + target = "menu_anchor", + action = "back_close_anchor", + ) + // Accepted is not arrival — the helper says so itself — but it + // does separate a claim that took from one that definitely needs + // the deferred retry. Arm the suppression only on acceptance; + // otherwise the state-request fallback arms it a frame later. + if (accepted) dwellSuppressedButton = target + accepted + } + } + + // Single full-width Row (wordmark · flexible gap · search+centered tabs · // flexible gap · trailing profile) so D-pad Left/Right traverse the whole bar // in one ordered focus group — the three-zone `align` layout couldn't be @@ -333,7 +464,15 @@ fun TvTopMenuBar( // ignore explicit requester bumps. Otherwise Android's initial // focus pass can still choose Home while content is composing. canFocus = !isFocusSuppressed - enter = { barEntryRequester } + // While a Back-close handoff is in flight, the anchor is the + // entry point — not the selected tab. Closing a cascade removes + // the focused node and Compose recovers focus into the bar; if + // that recovery uses the group's first child it lands on the + // search icon and Back visibly flashes through search before the + // explicit request lands. + enter = { + dwellSuppressedButton?.let(::requesterForFocus) ?: barEntryRequester + } } .onPreviewKeyEvent { event -> val focus = focusedButton @@ -374,14 +513,14 @@ fun TvTopMenuBar( }, verticalAlignment = Alignment.Bottom, ) { - // Leading: PRAIRIE wordmark. + // Leading: the Silo brand lockup. Box( modifier = Modifier .padding(start = TvSkyline.safeAreaX) .height(TvSkyline.barHeight), contentAlignment = Alignment.Center, ) { - TvPrairieWordmark() + TvSiloWordmark() } Spacer(modifier = Modifier.weight(1f)) @@ -399,7 +538,7 @@ fun TvTopMenuBar( icon = Icons.Outlined.Search, contentDescription = "Search", isFocused = focusedButton == TvTopMenuFocus.Search, - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.Search), focusRequester = searchFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) { @@ -417,7 +556,7 @@ fun TvTopMenuBar( label = "Home", isSelected = selectedRoot == TvRootDestination.Home, isFocused = focusedButton == TvTopMenuFocus.Home, - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.Home), focusRequester = homeFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) { @@ -436,7 +575,7 @@ fun TvTopMenuBar( label = type.title, isSelected = selectedRoot == destination, isFocused = focusedButton == TvTopMenuFocus.Tab(type), - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.Tab(type)), focusRequester = tabFocusRequesters[type] ?: homeFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) { @@ -460,7 +599,7 @@ fun TvTopMenuBar( label = "For You", isSelected = selectedRoot == TvRootDestination.ForYou, isFocused = focusedButton == TvTopMenuFocus.ForYou, - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.ForYou), focusRequester = forYouFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) { @@ -482,7 +621,7 @@ fun TvTopMenuBar( label = "Calendar", isSelected = selectedRoot == TvRootDestination.Calendar, isFocused = focusedButton == TvTopMenuFocus.Calendar, - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.Calendar), focusRequester = calendarFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) { @@ -514,7 +653,7 @@ fun TvTopMenuBar( TvTopMenuProfileButton( accountState = accountState, isFocused = focusedButton == TvTopMenuFocus.Profile, - canFocus = !isFocusSuppressed, + canFocus = canFocusButton(TvTopMenuFocus.Profile), focusRequester = profileFocusRequester, onFocusChanged = { hasFocus -> focusedButton = if (hasFocus) TvTopMenuFocus.Profile else focusedButton.takeUnless { it == TvTopMenuFocus.Profile } @@ -529,26 +668,50 @@ fun TvTopMenuBar( /** Minimal account view-data the menu bar + profile dropdown render. */ data class TvAccountState( val displayName: String = "Profile", - val avatar: String? = null, - val avatarUrl: String? = null, + /** Avatar ref + server-resolved URL, kept together so neither is lost. */ + val avatar: ProfileAvatarRef = ProfileAvatarRef.None, /** Secondary line under the name in the dropdown header (role / username). */ val subtitle: String = "", /** Active server display name, shown in the dropdown header. */ val serverName: String = "", - /** Whether the signed-in user is an acting admin (gates the Admin row). */ - val isAdmin: Boolean = false, ) -/** Heavy, tracked PRAIRIE wordmark at the bar's leading edge (§5.1). */ +/** + * The Silo brand lockup at the bar's leading edge (§5.1). + * + * This is the shipped trademark artwork, not type: silo-branding's + * `silo-wordmark-white.svg` as its `derive.py` renders it for Android + * (`R.drawable.prairie_wordmark`, 764x400). Branding's own rules pick both the + * variant and the treatment: + * - *"Pick the variant that contrasts with its background: dark art on light, + * white on dark."* The menu bar is dark chrome, so the **white** lockup is the + * correct cut — and it is the only wordmark `derive.py` emits for Android. + * - *"Don't recolour the mark, or add shadows, outlines or effects."* So, unlike + * the `Text` this replaced, no `PrairieOnSurface` tint is applied. The lockup's + * type is already `#FFFFFF` and its three bars carry the signal palette; a + * `ColorFilter` would flatten them and breach the trademark guidance. + * - *"Typeset 'Silo' in place of the supplied wordmark"* is on branding's + * **Don't** list — which is precisely what the old `Text("SILO")` did. + * + * The PNG is used rather than a hand-built `VectorDrawable` because `derive.py` + * is branding's declared source of truth for downstream Android assets and emits + * exactly this file at exactly this path; a transcribed vector would fork the + * mark out of that pipeline and go stale the next time the artwork changes. It + * costs nothing in sharpness: the source is 764px wide against a ~46dp render + * (92px at the 320dpi TV reference, 184px even on a 4x surface). + * + * Height comes from [TvSkyline.wordmarkHeight]; the width follows the drawable's + * intrinsic 764:400 ratio (~45.8.dp) with [ContentScale.Fit], so the artwork is + * never stretched or cropped — also forbidden. Decorative: an `Image` adds no + * focusable node, so the bar's D-pad order is unchanged. + */ @Composable -private fun TvPrairieWordmark() { - Text( - text = "PRAIRIE", - color = PrairieOnSurface, - fontWeight = FontWeight.Black, - fontSize = TvSkyline.wordmarkSize, - letterSpacing = TvSkyline.wordmarkTracking, - maxLines = 1, +private fun TvSiloWordmark() { + Image( + painter = painterResource(id = R.drawable.prairie_wordmark), + contentDescription = "Prairie", + contentScale = ContentScale.Fit, + modifier = Modifier.height(TvSkyline.wordmarkHeight), ) } @@ -757,6 +920,7 @@ private fun TvTopMenuAvatar( val avatarText = remember(accountState.avatar, accountState.displayName) { profileAvatarDisplayText(accountState.avatar, accountState.displayName) } + val avatarImage = rememberProfileAvatarImage(accountState.avatar) // The avatar circle plus a decorative unread badge anchored to its top-end // corner. The badge is purely informational — the profile Surface stays the // sole focus target, so the focus model is unchanged. @@ -773,14 +937,16 @@ private fun TvTopMenuAvatar( ), contentAlignment = Alignment.Center, ) { - if (accountState.avatarUrl != null) { + if (avatarImage != null) { ThumbhashImage( - url = accountState.avatarUrl, + url = avatarImage.url, thumbhash = null, contentDescription = accountState.displayName, modifier = Modifier.fillMaxHeight(), contentScale = ContentScale.Crop, transparent = true, + cacheKey = avatarImage.cacheKey, + onError = avatarImage.onLoadFailed, ) } else { Text( diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt new file mode 100644 index 000000000..1c8089c1a --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.tv.ui.shell + +private const val TopMenuFocusMaxAttempts = 6 + +internal fun isTopMenuFocusTargetAvailable( + target: TvTopMenuPanel?, + destinations: List, +): Boolean = when (target) { + is TvTopMenuPanel.Root -> target.dest in destinations + TvTopMenuPanel.Profile, null -> true +} + +internal suspend fun handleTopMenuFocusRequestIfAvailable( + requestIdentity: Pair, + lastHandledRequest: Pair, + isFocusSuppressed: Boolean, + isTargetAvailable: Boolean, + requestFocus: suspend () -> Boolean, +): Pair { + if (isFocusSuppressed || !isTargetAvailable || requestIdentity == lastHandledRequest) { + return lastHandledRequest + } + return if (requestFocus()) requestIdentity else lastHandledRequest +} + +internal suspend fun requestTopMenuFocusUntilApplied( + awaitFrame: suspend () -> Unit, + isTargetCurrent: () -> Boolean = { true }, + requestFocus: () -> Boolean, +): Boolean { + repeat(TopMenuFocusMaxAttempts) { + if (!isTargetCurrent()) return false + awaitFrame() + if (!isTargetCurrent()) return false + if (requestFocus()) return true + } + return false +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Layout.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Layout.kt index 8ddb62f6d..a51f4ed70 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Layout.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Layout.kt @@ -2,11 +2,23 @@ package org.prairieserver.prairie.tv.ui.theme import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import kotlinx.coroutines.launch +import kotlin.math.abs import androidx.compose.ui.unit.Dp /** Content now lives beside a persistent rail, so page padding is local only. */ @@ -52,6 +64,160 @@ val TvSmoothBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { } } +/** + * [TvSmoothBringIntoViewSpec] for a vertical grid that scrolls under the top + * bar: the leading gutter is at least [topInset] (the grid's top content + * padding), so a row revealed by scrolling back UP parks below the bar instead + * of at 12% of the viewport — which on a 1080p canvas is ~65dp, under the + * 94dp bar, leaving the first row's posters cut off at the top. Scrolling + * down is unchanged (trailing gutter as before). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberTvGridBringIntoViewSpec(topInset: Dp): BringIntoViewSpec { + val topInsetPx = with(LocalDensity.current) { topInset.toPx() } + return remember(topInsetPx) { + object : BringIntoViewSpec { + override val scrollAnimationSpec: AnimationSpec = TvSmoothBringIntoViewSpec.scrollAnimationSpec + + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float { + val leadingGutter = maxOf(containerSize * 0.12f, topInsetPx) + val trailingGutter = containerSize * 0.22f + val visibleStart = leadingGutter + val visibleEnd = containerSize - trailingGutter + return when { + offset < visibleStart -> offset - visibleStart + offset + size > visibleEnd -> offset + size - visibleEnd + else -> 0f + } + } + } + } +} + +/** + * Horizontal rail scroll behaviour, shared by every card carousel. + * + * The focused card is PINNED: its leading edge slides to the row's start + * padding — the tvOS / Netflix rail model — so every Right or Left is one + * uniform card-sized glide and the focused card always sits in the same place + * on screen. Row ends still clamp naturally. + * + * Two pieces, deliberately split: + * + * - [Modifier.tvRailPinOnFocus] performs the pin as a ONE-SHOT clamped + * `animateScrollBy` when a card gains focus (mirroring the detail page's + * section anchors). It is the ONLY horizontal scroll a focus change + * triggers, and it starts on the focus frame itself, so the highlight and + * the glide begin together and read as one motion. + * - [TvRailScrollBehavior] tells the LazyRow's automatic bring-into-view to + * stay out of it horizontally (distance 0 — the request is satisfied at + * once, nothing loops). Before this, the auto request started a "minimal + * reveal" animation on the focus frame and the pin cancelled and restarted + * it a frame later from zero velocity: the highlight visibly landed on the + * right, paused, then the row re-launched leftward — the "jumpy" rail. + * Vertical requests keep bubbling to the enclosing column's own spec. + * + * The pin must NOT be expressed as the BringIntoViewSpec's scroll distance: + * Compose keeps re-launching the bring-into-view animation on every layout + * pass while the spec still reports a non-zero distance, and a pinned position + * is unreachable whenever the row is clamped at either end — so during any + * concurrent animation (a vertical row scroll re-lays the rail out each frame) + * it spun a new scroll job per frame. Measured on the Shield as p90 121ms and + * near-frozen vertical navigation. + * + * Fast-out/slow-in at 480ms (tuned on the Shield): quick to start so rapid + * presses feel connected, long enough to settle that a card-step reads as a + * glide; a chain of presses retargets the running animation from its current + * position, so it never stutters. + */ +val TvRailScrollSpec: AnimationSpec = tween( + durationMillis = 480, + easing = FastOutSlowInEasing, +) + +@OptIn(ExperimentalFoundationApi::class) +private val TvRailBringIntoViewSpec: BringIntoViewSpec = object : BringIntoViewSpec { + override val scrollAnimationSpec: AnimationSpec = TvRailScrollSpec + + // Never scroll horizontally on the row's own account: the pin + // (tvRailPinOnFocus) owns every focus-driven horizontal move, and a + // second animation racing it is exactly the hitch this avoids. + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f +} + +/** + * Wrap a `LazyRow` so its automatic horizontal bring-into-view defers to the + * pin. Vertical requests keep bubbling to the enclosing column's spec. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TvRailScrollBehavior(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalBringIntoViewSpec provides TvRailBringIntoViewSpec, content = content) +} + +/** + * Slide the item at [index] so its leading edge sits [leadingPx] from the + * viewport start, clamped by the list's own bounds. + * + * `LazyListItemInfo.offset` is measured from the CONTENT start (after the + * start padding), so the target in item coordinates is + * `leadingPx + viewportStartOffset` — zero when [leadingPx] equals the row's + * start padding, which is how every rail calls it. (Subtracting [leadingPx] + * from the raw offset, as this once did, parked composed cards a full padding + * width past the pin while a deep-restored card landed on it.) + * + * A card that is not yet in `visibleItemsInfo` — the neighbour just past the + * viewport edge that D-pad focus reaches through beyond-bounds layout — is + * extrapolated from the nearest visible card, since rail cards share a width + * and spacing; the list clamps the result. Only a genuinely far target (deep + * restore) falls back to `animateScrollToItem`. + */ +suspend fun LazyListState.tvRailPinItem(index: Int, leadingPx: Float) { + val info = layoutInfo + val visible = info.visibleItemsInfo + val target = leadingPx + info.viewportStartOffset + val item = visible.firstOrNull { it.index == index } + val distance = when { + item != null -> item.offset - target + visible.isEmpty() -> null + index > visible.last().index && index - visible.last().index <= NEAR_EDGE_ITEMS -> { + val last = visible.last() + val stride = last.size + info.mainAxisItemSpacing + last.offset + (index - last.index) * stride - target + } + index < visible.first().index && visible.first().index - index <= NEAR_EDGE_ITEMS -> { + val first = visible.first() + val stride = first.size + info.mainAxisItemSpacing + first.offset - (first.index - index) * stride - target + } + else -> null + } + if (distance != null) { + if (abs(distance) >= 1f) animateScrollBy(distance, TvRailScrollSpec) + } else { + // Far away (deep restore): the stock jump lands it at the content + // start, i.e. exactly at the start padding. + animateScrollToItem(index) + } +} + +/** How many cards past the viewport edge [tvRailPinItem] extrapolates from a visible neighbour. */ +private const val NEAR_EDGE_ITEMS = 2 + +/** + * Pin the item at [index] (see [tvRailPinItem]) whenever it gains focus. + * [leading] is the row's start content padding. + */ +@Composable +fun Modifier.tvRailPinOnFocus(state: LazyListState, index: Int, leading: Dp): Modifier { + val leadingPx = with(LocalDensity.current) { leading.toPx() } + val scope = rememberCoroutineScope() + return onFocusChanged { focusState -> + if (focusState.isFocused) scope.launch { state.tvRailPinItem(index, leadingPx) } + } +} + @Composable fun tvPageContentPadding( top: Dp = Spacing.xxl, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Spacing.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Spacing.kt index 8f8c2d1d5..02b2fd2d1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Spacing.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Spacing.kt @@ -1,7 +1,6 @@ package org.prairieserver.prairie.tv.ui.theme import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp /** @@ -84,11 +83,19 @@ object TvSkyline { /** Gap between the search button and the avatar — tvOS `barTrailingSpacing` (22pt). */ val barTrailingSpacing = 11.dp - /** Wordmark size, visually balanced with the top navigation labels. */ - val wordmarkSize = 15.sp - - /** Wordmark letter tracking — tvOS `wordmarkTracking` (+0.34 em). */ - val wordmarkTracking = 0.34.em + /** + * Rendered height of the Silo brand lockup at the bar's leading edge. + * + * The artwork is `silo-wordmark-white.svg` as silo-branding's `derive.py` + * renders it for Android (`res/drawable/prairie_wordmark.png`, 764x400). Branding + * requires clear space on every side of at least one bar counter — 54 of the + * mark's 266 units, i.e. 6.32% of the lockup's own height. 24.dp is the + * largest round height that keeps that clear space inside the 32.dp + * [barHeight] row with real margin (4.1dp actual against a 1.5dp minimum), + * and it draws the mark 48px tall on a 320dpi TV panel — twice branding's + * 24px legibility floor. Width follows the drawable's intrinsic ratio. + */ + val wordmarkHeight = 24.dp /** Bar opacity while focus is down in the content zone — tvOS `barDimmedOpacity`. */ const val barDimmedOpacity = 0.70f diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt index 8541aa203..ea1cfe133 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/watchnext/TvWorkerFactory.kt @@ -9,7 +9,9 @@ import org.prairieserver.prairie.common.data.sync.SyncEngine import org.prairieserver.prairie.common.data.sync.SyncWorker import org.prairieserver.prairie.common.diagnostics.DiagnosticsCoordinator import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploadWorker -import org.prairieserver.prairie.common.diagnostics.DiagnosticsUploader +import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsDeletionWorker +import org.prairieserver.prairie.common.diagnostics.HostedDiagnosticsReportDeleter +import org.prairieserver.prairie.common.diagnostics.PendingReportStore import org.prairieserver.prairie.repository.SectionRepository import org.koin.core.context.GlobalContext @@ -60,10 +62,18 @@ class TvWorkerFactory : WorkerFactory() { DiagnosticsUploadWorker( appContext = appContext, params = workerParameters, - uploader = koin.get(), coordinator = koin.get(), ) } + HostedDiagnosticsDeletionWorker::class.java.name -> { + Log.i(TAG, "Building HostedDiagnosticsDeletionWorker via Koin") + HostedDiagnosticsDeletionWorker( + appContext = appContext, + params = workerParameters, + reports = koin.get(), + deleter = koin.get(), + ) + } else -> { Log.w(TAG, "No factory match for $workerClassName — returning null") null diff --git a/androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png b/androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png index 57afa0a25..c9694d5db 100644 Binary files a/androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png and b/androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png differ diff --git a/androidTvApp/src/androidMain/res/drawable/tv_banner.png b/androidTvApp/src/androidMain/res/drawable/tv_banner.png index 7100b35db..ccd944057 100644 Binary files a/androidTvApp/src/androidMain/res/drawable/tv_banner.png and b/androidTvApp/src/androidMain/res/drawable/tv_banner.png differ diff --git a/androidTvApp/src/androidMain/res/layout/tv_player_view.xml b/androidTvApp/src/androidMain/res/layout/tv_player_view.xml index 6403c6f01..a864f15fd 100644 --- a/androidTvApp/src/androidMain/res/layout/tv_player_view.xml +++ b/androidTvApp/src/androidMain/res/layout/tv_player_view.xml @@ -3,6 +3,5 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:keepScreenOn="true" app:surface_type="surface_view" app:use_controller="false" /> diff --git a/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png index 60b62ba2b..389c9ecd9 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png and b/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png b/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png index 344b5bf21..02a22d83a 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png and b/androidTvApp/src/androidMain/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png index 42aad1d58..07131a314 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png and b/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png b/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png index 6044e95f3..bf9c55bbe 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png and b/androidTvApp/src/androidMain/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png index 2946a6531..14fe3f039 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png and b/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png b/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png index dff3d5e59..2ffff98b4 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png and b/androidTvApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png index 3200e19d6..0c2ad1bc0 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png and b/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png b/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png index e77115450..8083390b7 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/androidTvApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png index 8ee684d5b..43ba2dc16 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png and b/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 6252b0153..a551885a5 100644 Binary files a/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/androidTvApp/src/androidMain/res/values/strings.xml b/androidTvApp/src/androidMain/res/values/strings.xml index 2b4c3137e..ba391b48d 100644 --- a/androidTvApp/src/androidMain/res/values/strings.xml +++ b/androidTvApp/src/androidMain/res/values/strings.xml @@ -15,6 +15,16 @@ Sign In Play + + Skip Intro + Watch Intro + Intro skipped + Skip intros + Never + Ask to skip + Skip automatically + Loading… Something went wrong diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/TvAndroidManifestPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/TvAndroidManifestPolicyTest.kt index 405c2e3cc..285a5e5d8 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/TvAndroidManifestPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/TvAndroidManifestPolicyTest.kt @@ -16,9 +16,9 @@ class TvAndroidManifestPolicyTest { } @Test - fun tvKeepsAndroid7InstallFloor() { + fun tvKeepsAndroid7InstallFloorAndTargetsApi36() { assertTrue(buildFile.contains("minSdk = 24")) - assertTrue(buildFile.contains("targetSdk = 35")) + assertTrue(buildFile.contains("targetSdk = 36")) assertTrue(buildFile.contains("compileSdk = 36")) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt index da3e80406..8f7b85313 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/data/preferences/LegacyTvPrefsMigrationTest.kt @@ -9,11 +9,14 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import org.prairieserver.prairie.common.settings.AndroidServerSettingsCache import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.model.settings.EffectiveSetting import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys +import org.prairieserver.prairie.model.settings.QualityPresets import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -25,6 +28,8 @@ import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class LegacyTvPrefsMigrationTest { @@ -102,8 +107,12 @@ class LegacyTvPrefsMigrationTest { migration.migrateIfNeeded() assertEquals("1080p", fakePlayerStore.preferredQualityFlow.value) + // Both axes, or the pair matches no preset and the picker renders + // nothing as selected — see `imported quality is a pair the picker can + // select`. + assertEquals(6000, fakePlayerStore.maxBitrateKbpsFlow.value) assertEquals(false, fakePlayerStore.autoPlayNextFlow.value) - assertEquals(true, fakePlayerStore.autoSkipIntroFlow.value) + assertEquals(IntroSkipMode.ALWAYS, fakePlayerStore.introSkipModeFlow.value) assertEquals(true, fakePlayerStore.autoSkipCreditsFlow.value) assertEquals( SubtitleFontSizePreset.Large, @@ -136,9 +145,120 @@ class LegacyTvPrefsMigrationTest { newMigration(legacy, effective).migrateIfNeeded() assertFalse(fakePlayerStore.setterCalls.contains("setPreferredQuality")) + assertFalse(fakePlayerStore.setterCalls.contains("setQuality")) assertEquals("auto", fakePlayerStore.preferredQualityFlow.value) // Keys without a server override still import. - assertEquals(true, fakePlayerStore.autoSkipIntroFlow.value) + assertEquals(IntroSkipMode.ALWAYS, fakePlayerStore.introSkipModeFlow.value) + } + + @Test + fun `an existing bitrate override alone still blocks the quality import`() = runTest { + // Quality is two rows and setQuality writes both, so guarding on the + // resolution alone lets a device that has only a server-side bitrate + // cap pass — and the legacy preset's bitrate (or JSON null, for a + // legacy Auto) overwrites the cap the migration promised to preserve. + val legacy = legacyStore() + legacy.edit { prefs -> + prefs[legacyQualityKey] = "720p" + prefs[legacyAutoSkipIntroKey] = true + } + val effective = mapOf( + PlaybackSettingsKeys.MaxBitrateKbps to EffectiveSetting( + key = PlaybackSettingsKeys.MaxBitrateKbps, + effectiveValue = "3000", + source = "device", + hasDeviceOverride = true, + ), + ) + newMigration(legacy, effective).migrateIfNeeded() + + assertFalse( + fakePlayerStore.setterCalls.contains("setQuality"), + "a server-side bitrate override must not be overwritten by the legacy preset", + ) + // Keys without an override still import. + assertEquals(IntroSkipMode.ALWAYS, fakePlayerStore.introSkipModeFlow.value) + } + + @Test + fun `the quality guard asks the server about both axes`() = runTest { + // The guard can only preserve what it queries: a bitrate key missing + // from the request comes back absent, which reads as "no override". + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "720p" } + val requested = mutableListOf() + LegacyTvPrefsMigration( + context = mockContextStub(), + settingsCache = fakeCache, + playerSettingsStore = fakePlayerStore, + librarySelectionStore = selectionStore, + getServerUrl = { tokenManager.getServerUrl() }, + getProfileId = { tokenManager.getProfileId() }, + getEffectiveSettings = { keys -> requested.addAll(keys); emptyMap() }, + legacyStoreProvider = { legacy }, + ).migrateIfNeeded() + + assertTrue(PlaybackSettingsKeys.PreferredQuality in requested) + assertTrue( + PlaybackSettingsKeys.MaxBitrateKbps in requested, + "an unqueried axis cannot be guarded", + ) + } + + /** + * Every legacy quality value must land on a pair the picker can show as + * selected. Quality is two axes now; a resolution imported without its + * bitrate is a combination `QualityPresets.presetFor` does not match, so + * `TvSettingsScreen`'s picker computes an empty selected id, renders no + * checkmark, and parks the cursor on Auto — and because the sentinel is + * marked on the same pass, the import cannot be repeated to repair it. + */ + @Test + fun `imported quality is a pair the picker can select`() = runTest { + for (legacy in PlaybackQuality.entries) { + fakePlayerStore = FakePlayerSettingsStore() + fakeCache = FakeSettingsCache() + val store = PreferenceDataStoreFactory.create( + produceFile = { File(tempFolder.root, "tv_prefs_${legacy.name}.preferences_pb") }, + ) + store.edit { prefs -> prefs[legacyQualityKey] = legacy.wireValue } + + newMigration(store).migrateIfNeeded() + + val resolution = fakePlayerStore.preferredQualityFlow.value + val bitrate = fakePlayerStore.maxBitrateKbpsFlow.value + assertNotNull( + QualityPresets.presetFor(resolution, bitrate), + "legacy ${legacy.wireValue} imported as ($resolution, $bitrate), " + + "which no picker preset covers", + ) + assertEquals(legacy.wireValue, resolution) + } + } + + @Test + fun `a legacy quality with an implied cap imports that cap`() = runTest { + // The bitrates match the server's own migration + // (internal/settingsmigrate/plan.go decomposes 720p to {720p, 2000}), + // so the same legacy value means the same thing on both sides. + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "720p" } + + newMigration(legacy).migrateIfNeeded() + + assertEquals("720p", fakePlayerStore.preferredQualityFlow.value) + assertEquals(2000, fakePlayerStore.maxBitrateKbpsFlow.value) + } + + @Test + fun `a legacy quality with no implied cap imports uncapped`() = runTest { + val legacy = legacyStore() + legacy.edit { prefs -> prefs[legacyQualityKey] = "2160p" } + + newMigration(legacy).migrateIfNeeded() + + assertEquals("2160p", fakePlayerStore.preferredQualityFlow.value) + assertNull(fakePlayerStore.maxBitrateKbpsFlow.value) } @Test @@ -226,129 +346,6 @@ class LegacyTvPrefsMigrationTest { } /** Records setter calls and mirrors them into MutableStateFlows. */ -private class FakePlayerSettingsStore : PlayerSettingsStore { - val setterCalls = mutableListOf() - var flushCount = 0 - - override val autoSkipIntroFlow = MutableStateFlow(false) - override val autoSkipCreditsFlow = MutableStateFlow(false) - override val autoPlayNextFlow = MutableStateFlow(true) - override val hdrEnabledFlow = MutableStateFlow(true) - override val dvProfile7HDR10FallbackFlow = MutableStateFlow(false) - override val dolbyVisionEnabledFlow = MutableStateFlow(true) - override val matchContentFrameRateFlow = MutableStateFlow(false) - override val subtitleMatchesDeviceFlow = MutableStateFlow(false) - override val showAudiobooksFlow = MutableStateFlow(false) - override val effectiveSubtitleAppearanceFlow = - MutableStateFlow(org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT) - override val pictureInPictureEnabledFlow = MutableStateFlow(true) - override val downloadsWifiOnlyFlow = MutableStateFlow(true) - override val keepWatchedDownloadsFlow = MutableStateFlow(false) - override val defaultDownloadQualityFlow = MutableStateFlow("original") - override val playbackSpeedFlow = MutableStateFlow(1.0) - override val audioSyncMsFlow = MutableStateFlow(0) - override val subtitleSyncMsFlow = MutableStateFlow(0) - override fun subtitleSyncMsFor(contentId: String?) = subtitleSyncMsFlow - override suspend fun setSubtitleSyncMsFor(contentId: String, value: Int) = Unit - override val nextUpPromptSecondsFlow = MutableStateFlow(30) - override val sleepTimerDefaultMinutesFlow = MutableStateFlow(0) - override val resumeRewindSecondsFlow = MutableStateFlow(7) - override val passOutThresholdFlow = MutableStateFlow(3) - override val preferredQualityFlow = MutableStateFlow("auto") - override val audioLanguageFlow = MutableStateFlow("") - override val videoGravityFlow = MutableStateFlow("fit") - override val orientationModeFlow = MutableStateFlow("auto") - override val subtitleAppearanceFlow = MutableStateFlow(SubtitleAppearance.DEFAULT) - override val subtitleUsesDeviceOverrideFlow = MutableStateFlow(false) - - override suspend fun setAutoSkipIntro(value: Boolean) { - setterCalls += "setAutoSkipIntro"; autoSkipIntroFlow.value = value - } - override suspend fun setAutoSkipCredits(value: Boolean) { - setterCalls += "setAutoSkipCredits"; autoSkipCreditsFlow.value = value - } - override suspend fun setAutoPlayNext(value: Boolean) { - setterCalls += "setAutoPlayNext"; autoPlayNextFlow.value = value - } - override suspend fun setHdrEnabled(value: Boolean) { - setterCalls += "setHdrEnabled"; hdrEnabledFlow.value = value - } - override suspend fun setDvProfile7HDR10Fallback(value: Boolean) { - setterCalls += "setDvProfile7HDR10Fallback"; dvProfile7HDR10FallbackFlow.value = value - } - - override suspend fun setDolbyVisionEnabled(value: Boolean) { - setterCalls += "setDolbyVisionEnabled"; dolbyVisionEnabledFlow.value = value - } - - override suspend fun setMatchContentFrameRate(value: Boolean) { - setterCalls += "setMatchContentFrameRate"; matchContentFrameRateFlow.value = value - } - - override suspend fun setSubtitleMatchesDevice(enabled: Boolean) { - setterCalls += "setSubtitleMatchesDevice"; subtitleMatchesDeviceFlow.value = enabled - } - - override suspend fun setShowAudiobooks(enabled: Boolean) { - setterCalls += "setShowAudiobooks"; showAudiobooksFlow.value = enabled - } - override suspend fun setPictureInPictureEnabled(value: Boolean) { - setterCalls += "setPictureInPictureEnabled"; pictureInPictureEnabledFlow.value = value - } - override suspend fun setDownloadsWifiOnly(value: Boolean) { - setterCalls += "setDownloadsWifiOnly"; downloadsWifiOnlyFlow.value = value - } - override suspend fun setKeepWatchedDownloads(value: Boolean) { - setterCalls += "setKeepWatchedDownloads"; keepWatchedDownloadsFlow.value = value - } - override suspend fun setDefaultDownloadQuality(value: String) { - setterCalls += "setDefaultDownloadQuality"; defaultDownloadQualityFlow.value = value - } - override suspend fun setPlaybackSpeed(value: Double) { - setterCalls += "setPlaybackSpeed"; playbackSpeedFlow.value = value - } - override suspend fun setAudioSyncMs(value: Int) { - setterCalls += "setAudioSyncMs"; audioSyncMsFlow.value = value - } - override suspend fun setSubtitleSyncMs(value: Int) { - setterCalls += "setSubtitleSyncMs"; subtitleSyncMsFlow.value = value - } - override suspend fun setNextUpPromptSeconds(value: Int) { - setterCalls += "setNextUpPromptSeconds"; nextUpPromptSecondsFlow.value = value - } - override suspend fun setSleepTimerDefaultMinutes(value: Int) { - setterCalls += "setSleepTimerDefaultMinutes"; sleepTimerDefaultMinutesFlow.value = value - } - override suspend fun setResumeRewindSeconds(value: Int) { - setterCalls += "setResumeRewindSeconds"; resumeRewindSecondsFlow.value = value - } - override suspend fun setPassOutThreshold(value: Int) { - setterCalls += "setPassOutThreshold"; passOutThresholdFlow.value = value - } - override suspend fun setPreferredQuality(value: String) { - setterCalls += "setPreferredQuality"; preferredQualityFlow.value = value - } - override suspend fun setAudioLanguage(value: String) { - setterCalls += "setAudioLanguage"; audioLanguageFlow.value = value - } - override suspend fun setVideoGravity(value: String) { - setterCalls += "setVideoGravity"; videoGravityFlow.value = value - } - override suspend fun setOrientationMode(value: String) { - setterCalls += "setOrientationMode"; orientationModeFlow.value = value - } - override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { - setterCalls += "setSubtitleAppearance"; subtitleAppearanceFlow.value = value - } - - override suspend fun refreshFromServer() {} - override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} - override suspend fun resetDeviceSetting(key: String) {} - override suspend fun resetAllDeviceSettings() {} - override suspend fun flushPendingDeviceSettings() { - flushCount++ - } -} /** * In-memory sentinel store — bypasses the SharedPreferences-backed base diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt new file mode 100644 index 000000000..86b4513e9 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/testing/FakePlayerSettingsStore.kt @@ -0,0 +1,145 @@ +package org.prairieserver.prairie.tv.testing + +import org.prairieserver.prairie.common.settings.PlayerSettingsStore +import org.prairieserver.prairie.domain.player.IntroSkipMode +import org.prairieserver.prairie.model.settings.SubtitleAppearance +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [PlayerSettingsStore] for TV unit tests: every flow is a + * [MutableStateFlow] a test can read back, and every setter records its own + * name in [setterCalls] so a test can assert which write path ran. + * + * Shared rather than re-declared per test class — the interface is wide enough + * that a second copy drifts the moment a member is added. + */ +internal class FakePlayerSettingsStore : PlayerSettingsStore { + val setterCalls = mutableListOf() + var flushCount = 0 + + override val introSkipModeFlow = MutableStateFlow(IntroSkipMode.ASK) + override val autoSkipCreditsFlow = MutableStateFlow(false) + override val autoPlayNextFlow = MutableStateFlow(true) + override val hdrEnabledFlow = MutableStateFlow(true) + override val dvProfile7HDR10FallbackFlow = MutableStateFlow(false) + override val dolbyVisionEnabledFlow = MutableStateFlow(true) + override val matchContentFrameRateFlow = MutableStateFlow(false) + override val subtitleMatchesDeviceFlow = MutableStateFlow(false) + override val showAudiobooksFlow = MutableStateFlow(false) + override val effectiveSubtitleAppearanceFlow = + MutableStateFlow(org.prairieserver.prairie.model.settings.SubtitleAppearance.DEFAULT) + override val pictureInPictureEnabledFlow = MutableStateFlow(true) + override val downloadsWifiOnlyFlow = MutableStateFlow(true) + override val keepWatchedDownloadsFlow = MutableStateFlow(false) + override val defaultDownloadQualityFlow = MutableStateFlow("original") + override val playbackSpeedFlow = MutableStateFlow(1.0) + override val audioSyncMsFlow = MutableStateFlow(0) + override val subtitleSyncMsFlow = MutableStateFlow(0) + override val nextUpPromptSecondsFlow = MutableStateFlow(30) + override val sleepTimerDefaultMinutesFlow = MutableStateFlow(0) + override val resumeRewindSecondsFlow = MutableStateFlow(7) + override val passOutThresholdFlow = MutableStateFlow(3) + override val preferredQualityFlow = MutableStateFlow("auto") + override val maxBitrateKbpsFlow = MutableStateFlow(null) + override val audioLanguageFlow = MutableStateFlow("") + override val videoGravityFlow = MutableStateFlow("fit") + override val orientationModeFlow = MutableStateFlow("auto") + override val subtitleAppearanceFlow = MutableStateFlow(SubtitleAppearance.DEFAULT) + override val subtitleUsesDeviceOverrideFlow = MutableStateFlow(false) + + override suspend fun setIntroSkipMode(value: IntroSkipMode) { + setterCalls += "setIntroSkipMode"; introSkipModeFlow.value = value + } + override suspend fun setAutoSkipCredits(value: Boolean) { + setterCalls += "setAutoSkipCredits"; autoSkipCreditsFlow.value = value + } + override suspend fun setAutoPlayNext(value: Boolean) { + setterCalls += "setAutoPlayNext"; autoPlayNextFlow.value = value + } + override suspend fun setHdrEnabled(value: Boolean) { + setterCalls += "setHdrEnabled"; hdrEnabledFlow.value = value + } + override suspend fun setDvProfile7HDR10Fallback(value: Boolean) { + setterCalls += "setDvProfile7HDR10Fallback"; dvProfile7HDR10FallbackFlow.value = value + } + + override suspend fun setDolbyVisionEnabled(value: Boolean) { + setterCalls += "setDolbyVisionEnabled"; dolbyVisionEnabledFlow.value = value + } + + override suspend fun setMatchContentFrameRate(value: Boolean) { + setterCalls += "setMatchContentFrameRate"; matchContentFrameRateFlow.value = value + } + + override suspend fun setSubtitleMatchesDevice(enabled: Boolean) { + setterCalls += "setSubtitleMatchesDevice"; subtitleMatchesDeviceFlow.value = enabled + } + + override suspend fun setShowAudiobooks(enabled: Boolean) { + setterCalls += "setShowAudiobooks"; showAudiobooksFlow.value = enabled + } + override suspend fun setPictureInPictureEnabled(value: Boolean) { + setterCalls += "setPictureInPictureEnabled"; pictureInPictureEnabledFlow.value = value + } + override suspend fun setDownloadsWifiOnly(value: Boolean) { + setterCalls += "setDownloadsWifiOnly"; downloadsWifiOnlyFlow.value = value + } + override suspend fun setKeepWatchedDownloads(value: Boolean) { + setterCalls += "setKeepWatchedDownloads"; keepWatchedDownloadsFlow.value = value + } + override suspend fun setDefaultDownloadQuality(value: String) { + setterCalls += "setDefaultDownloadQuality"; defaultDownloadQualityFlow.value = value + } + override suspend fun setPlaybackSpeed(value: Double) { + setterCalls += "setPlaybackSpeed"; playbackSpeedFlow.value = value + } + override suspend fun setAudioSyncMs(value: Int) { + setterCalls += "setAudioSyncMs"; audioSyncMsFlow.value = value + } + override suspend fun setSubtitleSyncMs(value: Int) { + setterCalls += "setSubtitleSyncMs"; subtitleSyncMsFlow.value = value + } + override suspend fun setNextUpPromptSeconds(value: Int) { + setterCalls += "setNextUpPromptSeconds"; nextUpPromptSecondsFlow.value = value + } + override suspend fun setSleepTimerDefaultMinutes(value: Int) { + setterCalls += "setSleepTimerDefaultMinutes"; sleepTimerDefaultMinutesFlow.value = value + } + override suspend fun setResumeRewindSeconds(value: Int) { + setterCalls += "setResumeRewindSeconds"; resumeRewindSecondsFlow.value = value + } + override suspend fun setPassOutThreshold(value: Int) { + setterCalls += "setPassOutThreshold"; passOutThresholdFlow.value = value + } + override suspend fun setPreferredQuality(value: String) { + setterCalls += "setPreferredQuality"; preferredQualityFlow.value = value + } + override suspend fun setQuality(resolution: String, bitrateKbps: Int?) { + setterCalls += "setQuality" + preferredQualityFlow.value = resolution + maxBitrateKbpsFlow.value = bitrateKbps + } + override suspend fun setAudioLanguage(value: String) { + setterCalls += "setAudioLanguage"; audioLanguageFlow.value = value + } + override suspend fun setVideoGravity(value: String) { + setterCalls += "setVideoGravity"; videoGravityFlow.value = value + } + override suspend fun setOrientationMode(value: String) { + setterCalls += "setOrientationMode"; orientationModeFlow.value = value + } + override suspend fun setSubtitleAppearance(value: SubtitleAppearance) { + setterCalls += "setSubtitleAppearance"; subtitleAppearanceFlow.value = value + } + override suspend fun flushProjectedSubtitleAppearance() { + setterCalls += "flushProjectedSubtitleAppearance" + } + + override suspend fun refreshFromServer() {} + override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {} + override suspend fun resetDeviceSetting(key: String) {} + override suspend fun resetAllDeviceSettings() {} + override suspend fun flushPendingDeviceSettings() { + flushCount++ + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenuStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenuStateTest.kt new file mode 100644 index 000000000..fa169ac4a --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenuStateTest.kt @@ -0,0 +1,177 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private fun option( + key: String, + selected: Boolean = false, + enabled: Boolean = true, +) = TvSelectorOption( + key = key, + title = key, + detail = "", + selected = selected, + onSelect = {}, + enabled = enabled, +) + +class TvAnchoredSelectorMenuStateTest { + @Test + fun expandedSelectorStaysClosedAfterInteractivityReturns() { + var expanded = true + + expanded = selectorExpansionAfterInteractivityChange( + expanded = expanded, + interactive = true, + ) + assertTrue(expanded) + + expanded = selectorExpansionAfterInteractivityChange( + expanded = expanded, + interactive = false, + ) + assertFalse(expanded) + + expanded = selectorExpansionAfterInteractivityChange( + expanded = expanded, + interactive = true, + ) + assertFalse(expanded) + } + + // A subtitle list longer than the screen is the case that broke: the walk + // has to keep producing indices past the last on-screen row, because those + // are precisely the rows Compose's own focus search would not reach. + @Test + fun walkContinuesPastTheRowsThatFitOnScreen() { + val options = List(30) { option("sub$it") } + + var index = initialSelectorMenuIndex(options) + val visited = mutableListOf(index) + while (true) { + val next = nextSelectorMenuIndex(options, index, forward = true) ?: break + index = next + visited += index + } + + assertEquals(30, visited.size) + assertEquals(29, visited.last()) + } + + @Test + fun walkStopsAtBothEndsInsteadOfLeavingTheMenu() { + val options = listOf(option("a"), option("b")) + + assertNull(nextSelectorMenuIndex(options, from = 1, forward = true)) + assertNull(nextSelectorMenuIndex(options, from = 0, forward = false)) + } + + @Test + fun walkStepsOverDisabledRows() { + val options = listOf( + option("auto"), + option("unknown", enabled = false), + option("english"), + ) + + assertEquals(2, nextSelectorMenuIndex(options, from = 0, forward = true)) + assertEquals(0, nextSelectorMenuIndex(options, from = 2, forward = false)) + } + + @Test + fun menuOpensOnTheSelectedRow() { + val options = listOf(option("auto"), option("off"), option("dutch", selected = true)) + + assertEquals(2, initialSelectorMenuIndex(options)) + } + + // 10 rows of 100 fit a 1000 viewport; row 10 begins exactly past the fold. + @Test + fun scrollFollowsFocusPastTheFold() { + val target = selectorMenuScrollTarget( + scroll = 0, + rowTop = 1000, + rowHeight = 100, + viewport = 1000, + maxValue = 700, + ) + + assertEquals(100, target) + } + + @Test + fun scrollStaysPutForARowAlreadyOnScreen() { + val target = selectorMenuScrollTarget( + scroll = 300, + rowTop = 400, + rowHeight = 100, + viewport = 1000, + maxValue = 700, + ) + + assertEquals(300, target) + } + + @Test + fun scrollFollowsFocusBackUpwards() { + val target = selectorMenuScrollTarget( + scroll = 500, + rowTop = 200, + rowHeight = 100, + viewport = 1000, + maxValue = 700, + ) + + assertEquals(200, target) + } + + @Test + fun scrollNeverRunsPastTheEndOfTheList() { + val target = selectorMenuScrollTarget( + scroll = 0, + rowTop = 5000, + rowHeight = 100, + viewport = 1000, + maxValue = 700, + ) + + assertEquals(700, target) + } + + // Before the first layout pass there is nothing measured to scroll against; + // holding position beats guessing and yanking the list. + @Test + fun scrollHoldsWhenNothingHasBeenMeasuredYet() { + assertEquals(250, selectorMenuScrollTarget(250, 900, 100, viewport = 0, maxValue = 700)) + assertEquals(250, selectorMenuScrollTarget(250, 900, 0, viewport = 1000, maxValue = 700)) + } + + // Aiming initial focus at a disabled row loses it silently: the row cannot + // take focus, so the menu opens with focus nowhere and the d-pad dead. + @Test + fun menuReportsNoInitialRowWhenNothingIsSelectable() { + val allDisabled = listOf(option("unknown", enabled = false), option("also-unknown", enabled = false)) + + assertEquals(-1, initialSelectorMenuIndex(allDisabled)) + assertEquals(-1, initialSelectorMenuIndex(emptyList())) + } + + // From that state a d-pad press must still reach the first selectable row. + @Test + fun walkFromNoInitialRowStillReachesTheFirstSelectableOne() { + val options = listOf(option("unknown", enabled = false), option("english")) + + assertEquals(1, nextSelectorMenuIndex(options, from = -1, forward = true)) + } + + @Test + fun menuOpensOnTheFirstSelectableRowWhenNothingIsSelected() { + val options = listOf(option("unknown", enabled = false), option("english")) + + assertEquals(1, initialSelectorMenuIndex(options)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentityTest.kt new file mode 100644 index 000000000..69636c30b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentityTest.kt @@ -0,0 +1,30 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class TvCascadeSelectorIdentityTest { + @Test + fun requesterOwnershipSurvivesLibraryInsertionAndReorder() { + val requesters = mutableMapOf() + + val before = stableIdentityValues( + ids = listOf(11, 22), + valuesById = requesters, + create = ::Any, + ) + val after = stableIdentityValues( + ids = listOf(33, 22, 11), + valuesById = requesters, + create = ::Any, + ) + + assertEquals(listOf(33, 22, 11), after.keys.toList()) + assertSame(before.getValue(11), after.getValue(11)) + assertSame(before.getValue(22), after.getValue(22)) + assertNotSame(after.getValue(33), after.getValue(11)) + assertNotSame(after.getValue(33), after.getValue(22)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt new file mode 100644 index 000000000..896464844 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvControlWiringCallSiteTest.kt @@ -0,0 +1,286 @@ +package org.prairieserver.prairie.tv.ui.components + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Call-site guard. [org.prairieserver.prairie.tv.ui.focus.TvControlSemanticsTest] proves the + * primitive behaves; this proves the screens still USE it, which is the part a + * Compose harness cannot reach without composing every screen. + * + * Scope matters as much as the pattern. Every assertion below is anchored to + * the single composable that owns the wiring, not to the file: a whole-file + * search passes as long as *some* control in the file is wired, so deleting the + * wiring from one of six controls in the same file would go unnoticed. Comments + * are stripped first for the same reason — prose describing a rule must not be + * able to satisfy it. + * + * Matching is whitespace-insensitive on purpose: these assert the rule, not a + * formatting snapshot, so reformatting the sources never fails the build. + */ +class TvControlWiringCallSiteTest { + + /** + * Structurally unavailable controls hand `enabled` to the real interactive + * primitive, so they drop out of D-pad traversal instead of sitting in it + * as dead stops. Asserted as a count, so a second control appearing in the + * same composable cannot stand in for the one that lost its wiring. + */ + @Test + fun structurallyDisabledControlsLeaveTheFocusGraph() { + listOf( + StructuralControl( + path = "ui/components/TvOptionDialog.kt", + composable = "TvOptionDialogRow", + primitive = "Surface(", + wiring = "enabled = enabled", + ), + StructuralControl( + path = "ui/components/TvAuroraChrome.kt", + composable = "AuroraPrimaryButton", + primitive = ".clickable(", + wiring = "enabled = enabled", + ), + StructuralControl( + path = "ui/components/TvSquaredButtons.kt", + composable = "SquaredPillSurface", + primitive = ".clickable(", + wiring = "enabled = enabled", + ), + ).forEach { control -> + val body = source(control.path).declarationBody(control.composable) + val label = "${control.path}:${control.composable}" + + assertTrue( + body.containsLoosely(control.primitive), + "$label should reach its interactive primitive ${control.primitive}", + ) + // Scoped to the primitive's own argument list, not the whole + // declaration: an `enabled = enabled` on some unrelated child call + // must not stand in for the primitive having lost its wiring. + assertEquals( + 1, + body.argumentsOf(control.primitive).countLoosely(control.wiring), + "$label should pass its disabled state to ${control.primitive} itself", + ) + } + } + + private data class StructuralControl( + val path: String, + val composable: String, + val primitive: String, + val wiring: String, + ) + + /** + * The inverse rule, and the one that actually strands viewers: a control + * gated by work in flight must NOT leave the focus graph. Android TV does + * not re-home focus when the focused node stops being focusable, and every + * initial-focus policy here is one-shot. + * + * So the gate composable builds a transient state, and the key composable + * feeds the focus graph `controlState.focusable` — constant `true` for a + * transient state — while reporting the real state to accessibility. + */ + @Test + fun transientlyGatedControlsStayFocusableAndAnnounceDisabled() { + listOf( + Triple("ui/components/TvPinEntryDialog.kt", "PinKeypad", "PinKey"), + Triple( + "ui/screens/watchtogether/TvJoinCodeDialog.kt", + "TvJoinCodeDialog", + "JoinCodeKey", + ), + ).forEach { (path, gate, control) -> + val text = source(path) + + assertTrue( + text.declarationBody(gate).containsLoosely("TvControlState.transient("), + "$path:$gate gates on work in flight and must build a transient state", + ) + + val controlBody = text.declarationBody(control) + assertEquals( + 1, + controlBody.countLoosely("enabled = controlState.focusable"), + "$path:$control must stay focusable while gated", + ) + assertTrue( + controlBody.containsLoosely(".tvControlSemantics(controlState)"), + "$path:$control must still report the gated state to accessibility", + ) + assertFalse( + controlBody.containsLoosely("enabled = enabled"), + "$path:$control must not hand in-flight gating to the focus graph", + ) + } + } + + /** + * The selector pill is the one deliberate exception to both rules above, + * and it is an exception because it is not a control. + * + * A pill with a single real choice — one version, one audio track — is not + * a disabled button; it is Apple's `TVSelectorValue`, a value display that + * happens to sit in a row of buttons. `SquaredPillSurface` routes `enabled` + * into `Modifier.clickable`, and a disabled clickable is also unfocusable, + * so wiring `interactive` into it would delete the pill from the focus + * graph. Most titles have exactly one version and one audio track, so that + * is not a rare edge: the common case would draw three pills and let the + * viewer reach none of them, with Down from the action row skipping the + * cluster outright. + * + * So this pill stays focusable and no-ops on Select, and the chevron — + * hidden when the pill will not open — is what carries the signal. + */ + @Test + fun singleChoiceSelectorPillStaysFocusableAndNoOps() { + val selector = source("ui/components/TvAnchoredSelectorMenu.kt") + .declarationBody("TvAnchoredSelectorMenu") + + assertFalse( + selector.argumentsOf("SquaredPillSurface(").containsLoosely("enabled ="), + "the selector pill must stay focusable, so it must not hand its trigger an enabled flag", + ) + assertEquals( + 1, + selector.countLoosely("onClick = { if (interactive) expansionRequested = true }"), + "a non-interactive selector pill must swallow Select rather than leave the focus graph", + ) + assertEquals( + 1, + selector.countLoosely("if (interactive) {"), + "the chevron must be hidden when the pill will not open", + ) + } + + /** + * The pre-existing anti-pattern: focusable, but silently inert. Matched on + * the shape of the guard rather than one spelling of the callee — the + * swallowed call is rarely named `onClick()`, and pinning the assertion to + * that literal is how this pattern survived at `OverlayTile`'s call site. + */ + @Test + fun noControlFakesDisabledStateInsideItsClickHandler() { + listOf( + "ui/components/TvOptionDialog.kt", + "ui/components/TvAuroraChrome.kt", + "ui/components/TvPinEntryDialog.kt", + "ui/screens/watchtogether/TvJoinCodeDialog.kt", + ).forEach { path -> + assertFalse( + source(path).containsLoosely("onClick = { if ("), + "$path should express disabled state, not swallow the click", + ) + } + } + + /** + * Interactivity loss must collapse the dropdown in the SAME composition, not + * a frame later via an effect — otherwise the menu is briefly drawn over a + * trigger that has already left the focus graph. + */ + @Test + fun selectorCollapsesSynchronouslyAndClearsItsStoredExpansion() { + val selector = source("ui/components/TvAnchoredSelectorMenu.kt") + .declarationBody("TvAnchoredSelectorMenu") + + assertTrue( + selector.containsLoosely( + "val expanded = selectorExpansionAfterInteractivityChange(expansionRequested, interactive)", + ), + "expansion should be derived at read time", + ) + assertTrue( + selector.containsLoosely("LaunchedEffect(interactive) {"), + "the stored expansion bit should still be cleared", + ) + } + + /** + * Cascade rows must carry Compose keys in BOTH list-size branches. + * `stableIdentityValues` keeps requester ownership stable on its own, but + * without the keys the row composables still swap identity on a reorder. + */ + @Test + fun cascadeLibraryRowsAreKeyedInBothBranches() { + val cascade = source("ui/components/TvCascadeSelector.kt") + + assertTrue(cascade.containsLoosely("key(library.id) {"), "eager rows need a key") + assertTrue( + cascade.containsLoosely("items(libraries, key = { it.id }) { library ->"), + "lazy rows need a key", + ) + } + + /** Source with comments stripped, so prose can never satisfy a rule. */ + private fun source(relativePath: String): String = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/$relativePath", + ).readText().stripComments() + + /** + * The text of one top-level declaration: from its signature to the start of + * the next one. Delimiting by signature rather than brace matching keeps + * this honest around braces inside string templates. + */ + private fun String.declarationBody(name: String): String { + val declarations = TOP_LEVEL_FUN.findAll(this).toList() + val index = declarations.indexOfFirst { it.groupValues[1] == name } + require(index >= 0) { "no top-level fun named $name" } + val start = declarations[index].range.first + val end = declarations.getOrNull(index + 1)?.range?.first ?: length + return substring(start, end) + } + + /** + * The argument list of the first [call] in this text — from its open paren + * to the matching close paren, nested parens included. Trailing lambdas sit + * outside the parens and are deliberately excluded: the wiring under test + * is always a named argument. + */ + private fun String.argumentsOf(call: String): String { + val open = indexOf(call).also { + require(it >= 0) { "no call to $call" } + } + call.length - 1 + var depth = 0 + for (index in open until length) { + when (this[index]) { + '(' -> depth++ + ')' -> if (--depth == 0) return substring(open + 1, index) + } + } + error("unbalanced parentheses in call to $call") + } + + private fun String.stripComments(): String = replace(BLOCK_COMMENT, "") + .lineSequence() + .filterNot { line -> + val trimmed = line.trimStart() + trimmed.startsWith("//") || trimmed.startsWith("*") + } + .joinToString("\n") + + private fun String.containsLoosely(needle: String): Boolean = + collapseWhitespace().contains(needle.collapseWhitespace()) + + private fun String.countLoosely(needle: String): Int = + collapseWhitespace().split(needle.collapseWhitespace()).size - 1 + + private fun String.collapseWhitespace(): String = replace(WHITESPACE, " ").trim() + + private companion object { + val WHITESPACE = Regex("\\s+") + val BLOCK_COMMENT = Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL) + // Column-zero anchored, so indented members and local funs are not + // mistaken for top-level ones; the modifier list is open-ended so a + // declaration does not silently drop out of scoping when someone marks + // it `inline` or `suspend`. + val TOP_LEVEL_FUN = Regex( + "(?m)^(?:(?:private|internal|public|inline|suspend|operator|tailrec|infix)\\s+)*fun\\s+(\\w+)", + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt new file mode 100644 index 000000000..20cf47438 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvDialogInitialFocusTest { + @Test + fun unobservedDialogFocusStopsAtTheFixedBudget() = runTest { + var attempts = 0 + + val result = requestTvDialogInitialFocus( + awaitAttempt = {}, + isOverlayFocused = { false }, + requestFocus = { true.also { attempts++ } }, + ) + + assertEquals(TvObservedFocusResult.Exhausted, result) + assertEquals(TvDialogInitialFocusMaxAttempts, attempts) + } + + @Test + fun focusOnAnyDialogChildStopsTargetRequests() = runTest { + var overlayFocused = false + var attempts = 0 + + val result = requestTvDialogInitialFocus( + awaitAttempt = { + if (attempts == 1) overlayFocused = true + }, + isOverlayFocused = { overlayFocused }, + requestFocus = { false.also { attempts++ } }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(1, attempts) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt index 51d84eec0..73137b512 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt @@ -4,7 +4,9 @@ import org.prairieserver.prairie.model.catalog.CastMember import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.section.SectionItem import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -136,34 +138,98 @@ class TvFocusMarqueeEnrichmentTest { } @Test - fun `initial seed joins the coordinated candidate and does not replace it`() { + fun `page entry reseeds a stale candidate when its row identity changes`() { + val state = TvFocusMarqueeState() + val item = SectionItem(contentId = "item-1", type = "movie", title = "Item") + + state.seedInitialPreview(item, "Row", rowIdentity = "row-old") + state.commit(state.candidate) + state.seedInitialPreview(item, "Row", rowIdentity = "row-new") + + assertEquals("row-new#item-1", state.candidate?.id) + } + + @Test + fun `page entry seed never replaces settled real focus`() { + val state = TvFocusMarqueeState() + val focusedItem = SectionItem( + contentId = "focused-item", + type = "movie", + title = "Focused", + ) + val seedItem = SectionItem( + contentId = "seed-item", + type = "movie", + title = "Replacement", + ) + + state.preview(focusedItem, "Focused", rowIdentity = "focused-row") + state.commit(state.candidate) + state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") + + assertEquals("focused-row#focused-item", state.content?.id) + assertEquals("focused-row#focused-item", state.candidate?.id) + } + + @Test + fun `page entry seed never replaces pending real focus`() { + val state = TvFocusMarqueeState() + val initialItem = SectionItem( + contentId = "initial-item", + type = "movie", + title = "Initial", + ) + val focusedItem = SectionItem( + contentId = "focused-item", + type = "movie", + title = "Focused", + ) + val seedItem = SectionItem( + contentId = "seed-item", + type = "movie", + title = "Replacement", + ) + + state.seedInitialPreview(initialItem, "Initial", rowIdentity = "initial-row") + state.commit(state.candidate) + state.preview(focusedItem, "Focused", rowIdentity = "focused-row") + state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") + + assertEquals("initial-row#initial-item", state.content?.id) + assertEquals("focused-row#focused-item", state.candidate?.id) + } + + @Test + fun `initial seed does not enable network enrichment until real focus`() { val state = TvFocusMarqueeState() val first = SectionItem( contentId = "first", type = "movie", title = "First Movie", - backdropUrl = "https://art/first.jpg", - ) - val second = SectionItem( - contentId = "second", - type = "movie", - title = "Second Movie", - backdropUrl = "https://art/second.jpg", ) state.seedInitialPreview(first, "Continue Watching") + state.commit(state.candidate) + assertFalse(state.hasSettledRealFocus) - assertNull(state.content) - assertEquals("First Movie", state.candidate?.title) - - state.seedInitialPreview(second, "Next Row") + state.preview(first, "Continue Watching") + assertTrue(state.hasSettledRealFocus) + } - assertEquals("First Movie", state.candidate?.title) + @Test + fun `new raw focus does not enrich the old seed before settlement`() { + val state = TvFocusMarqueeState() + val first = SectionItem(contentId = "first", type = "movie", title = "First") + val second = SectionItem(contentId = "second", type = "movie", title = "Second") + state.seedInitialPreview(first, "First row", rowIdentity = "row-1") state.commit(state.candidate) + state.preview(second, "Second row", rowIdentity = "row-2") - assertEquals("First Movie", state.content?.title) - assertEquals("https://art/first.jpg", state.content?.heroBackdropUrl) + assertFalse(state.hasSettledRealFocus) + + state.commit(state.candidate) + assertTrue(state.hasSettledRealFocus) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt index 45c7bfef6..f682976a4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt @@ -1,30 +1,174 @@ package org.prairieserver.prairie.tv.ui.components import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem import kotlin.test.Test import kotlin.test.assertEquals class TvFocusMarqueeModelTest { - - @Test fun qualityBadgesPreserveDolbyVisionAndAtmos() { - val summary = OverlaySummary( - resolution = "2160p", - hdr = "Dolby Vision", - audio = "TrueHD Atmos", + @Test + fun movieHeroPrioritizesEditorialMetadataAndOmitsStreamQuality() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "TrueHD Atmos", + ), + ), + rowTitle = "Popular", ) + assertEquals(listOf("PG-13"), content.badges) assertEquals( - listOf("4K", "DOLBY VISION", "ATMOS"), - TvMarqueeContent.qualityBadges(summary), + listOf("2016", "1h 56m", "7.9", "Science Fiction"), + content.metaParts, ) } - @Test fun qualityBadgesPreserveHdr10AndDtsHd() { - val summary = OverlaySummary(hdr = "HDR10", audio = "DTS-HD") + @Test + fun episodeHeroUsesSeriesTitleAndEditorialEpisodeMetadata() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + overlaySummary = OverlaySummary( + resolution = "1080p", + audio = "EAC3", + ), + ), + rowTitle = "Continue Watching", + ) + assertEquals("The Last of Us", content.title) + assertEquals(listOf("TV-MA"), content.badges) assertEquals( - listOf("HDR10", "DTS-HD"), - TvMarqueeContent.qualityBadges(summary), + listOf("S1 E3", "Long, Long Time", "1h 16m", "8.6"), + content.metaParts, ) } + + @Test + fun missingEditorialMetadataProducesNoEmptyTokensOrBadges() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-2", + type = "movie", + title = "Untitled", + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "HDR10", + audio = "Atmos", + ), + ), + rowTitle = "Recently Added", + ) + + assertEquals(emptyList(), content.badges) + assertEquals(emptyList(), content.metaParts) + } + + @Test + fun invalidRatingsAndDurationsAreOmittedFromTvMetadata() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + 11.0, + ).forEachIndexed { index, invalid -> + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + rowTitle = "Invalid", + ) + + assertEquals(emptyList(), content.metaParts) + } + } + + @Test + fun invalidRatingDoesNotHideValidTvRuntime() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "runtime-with-invalid-rating", + type = "movie", + title = "Movie", + ratingImdb = Double.NaN, + durationSeconds = 7_200.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h"), content.metaParts) + } + + @Test + fun validRatingDoesNotHideInvalidTvRuntime() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "rating-with-invalid-runtime", + type = "movie", + title = "Movie", + ratingImdb = 8.4, + durationSeconds = Double.NaN, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("8.4"), content.metaParts) + } + + @Test + fun catalogRuntimeWinsOverPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h 5m"), content.metaParts) + } + + @Test + fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("1h 56m"), content.metaParts) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareFormTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareFormTest.kt new file mode 100644 index 000000000..cc63948ae --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareFormTest.kt @@ -0,0 +1,101 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvImeAwareFormTest { + + @Test + fun `relocation requires focus visible IME and measured field`() { + assertNull( + tvImeRelocationKey( + hasFocus = false, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 112, + ), + ) + assertNull( + tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 0, + fieldWidthPx = 640, + fieldHeightPx = 112, + ), + ) + assertNull( + tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 0, + fieldHeightPx = 112, + ), + ) + assertNull( + tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 0, + ), + ) + + assertEquals( + TvImeRelocationKey( + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 112, + ), + tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 112, + ), + ) + } + + @Test + fun `IME or field geometry changes create a new relocation key`() { + val original = tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 112, + ) + val duplicate = tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 112, + ) + val resizedIme = tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 760, + fieldWidthPx = 640, + fieldHeightPx = 112, + ) + val resizedField = tvImeRelocationKey( + hasFocus = true, + imeBottomPx = 720, + fieldWidthPx = 640, + fieldHeightPx = 120, + ) + + assertEquals(original, duplicate) + assertNotEquals(original, resizedIme) + assertNotEquals(original, resizedField) + } + + @Test + fun `scroll restores only when a visible IME closes`() { + assertTrue(shouldRestoreTvImeFormScroll(previousImeBottomPx = 720, currentImeBottomPx = 0)) + assertFalse(shouldRestoreTvImeFormScroll(previousImeBottomPx = 0, currentImeBottomPx = 0)) + assertFalse(shouldRestoreTvImeFormScroll(previousImeBottomPx = 0, currentImeBottomPx = 720)) + assertFalse(shouldRestoreTvImeFormScroll(previousImeBottomPx = 720, currentImeBottomPx = 760)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRowFocusRestoreTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRowFocusRestoreTest.kt new file mode 100644 index 000000000..a100d3de3 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRowFocusRestoreTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvMediaRowFocusRestoreTest { + @Test + fun pendingOffscreenReturnScrollsToResolvedCardIndex() = runTest { + var scrolledTo: Int? = null + + val prepared = prepareTvMediaRowFocusRestore( + requestId = 3, + restoreFocusIndex = 8, + itemCount = 10, + scrollToItem = { scrolledTo = it }, + ) + + assertTrue(prepared) + assertEquals(8, scrolledTo) + } + + @Test + fun ordinaryRowRenderingPreservesHorizontalPosition() = runTest { + var scrollCalls = 0 + + val prepared = prepareTvMediaRowFocusRestore( + requestId = 0, + restoreFocusIndex = 8, + itemCount = 10, + scrollToItem = { scrollCalls++ }, + ) + + assertFalse(prepared) + assertEquals(0, scrollCalls) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt new file mode 100644 index 000000000..e9c7c07ee --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialogPositionProviderTest.kt @@ -0,0 +1,48 @@ +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvOptionDialogPositionProviderTest { + + @Test + fun `centers tall episode actions in the window regardless of trigger position`() { + val provider = TvOptionDialogWindowPositionProvider() + val window = IntSize(width = 1920, height = 1080) + val popup = IntSize(width = 600, height = 680) + + val nearTop = provider.calculatePosition( + anchorBounds = IntRect(left = 80, top = 100, right = 240, bottom = 180), + windowSize = window, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = popup, + ) + val nearBottom = provider.calculatePosition( + anchorBounds = IntRect(left = 900, top = 850, right = 1100, bottom = 950), + windowSize = window, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = popup, + ) + + assertEquals(IntOffset(x = 660, y = 200), nearTop) + assertEquals(nearTop, nearBottom) + } + + @Test + fun `keeps oversized dialog origin inside the viewport`() { + val provider = TvOptionDialogWindowPositionProvider() + + val position = provider.calculatePosition( + anchorBounds = IntRect(left = 400, top = 700, right = 700, bottom = 800), + windowSize = IntSize(width = 1280, height = 720), + layoutDirection = LayoutDirection.Rtl, + popupContentSize = IntSize(width = 1400, height = 800), + ) + + assertEquals(IntOffset.Zero, position) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualStateTest.kt new file mode 100644 index 000000000..3c3cdb340 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualStateTest.kt @@ -0,0 +1,36 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import org.prairieserver.prairie.tv.ui.theme.FocusedContainer +import org.prairieserver.prairie.tv.ui.theme.FocusedContent + +class TvSelectorRowVisualStateTest { + + @Test + fun focusedRowsUseInvertedTvContrast() { + val state = tvSelectorRowVisualState(focused = true, selected = false, enabled = true) + + assertEquals(FocusedContainer, state.container) + assertEquals(FocusedContent, state.content) + assertTrue(state.border.alpha > 0f) + } + + @Test + fun selectedIdleRowsRemainDistinctFromIdleRows() { + val selected = tvSelectorRowVisualState(focused = false, selected = true, enabled = true) + val idle = tvSelectorRowVisualState(focused = false, selected = false, enabled = true) + + assertNotEquals(idle.container, selected.container) + assertNotEquals(idle.border, selected.border) + } + + @Test + fun disabledRowsStayMutedEvenWhenSelected() { + val state = tvSelectorRowVisualState(focused = false, selected = true, enabled = false) + + assertTrue(state.content.alpha < 0.5f) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt new file mode 100644 index 000000000..7a644c18e --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt @@ -0,0 +1,97 @@ +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TvSkylinePrefetchPolicyTest { + private val items = listOf("a", "b", "c", "d", "e").map(::item) + + @Test + fun rapidFocusBeforeMarqueeSettlementStartsNoNeighborWork() { + assertTrue( + settledPrefetchItems( + items = items, + rawFocusedContentId = "d", + settledContentId = "b", + ).isEmpty(), + ) + } + + @Test + fun settledFocusReturnsOnlyTwoNeighborsPerSide() { + assertEquals( + listOf("a", "b", "d", "e"), + settledPrefetchItems( + items = items, + rawFocusedContentId = "c", + settledContentId = "c", + ).map { it.contentId }, + ) + } + + @Test + fun firstCardReturnsOnlyFollowingNeighbors() { + assertEquals( + listOf("b", "c"), + settledPrefetchItems( + items = items, + rawFocusedContentId = "a", + settledContentId = "a", + ).map { it.contentId }, + ) + } + + @Test + fun missingSettledIdentityStartsNoNeighborWork() { + assertTrue( + settledPrefetchItems( + items = items, + rawFocusedContentId = "missing", + settledContentId = "missing", + ).isEmpty(), + ) + } + + @Test + fun sameContentInDifferentRowWaitsForRowQualifiedSettlement() { + assertEquals( + null, + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "a", + rawFocusedMarqueeId = "row-1#a", + settledMarqueeId = "row-0#a", + ), + ) + assertEquals( + TvSkylineSettledFocus(rowIndex = 1, contentId = "a"), + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "a", + rawFocusedMarqueeId = "row-1#a", + settledMarqueeId = "row-1#a", + ), + ) + } + + @Test + fun unsettledFocusHasNoPrefetchIdentity() { + assertEquals( + null, + settledFocusIdentity( + rawRowIndex = 1, + rawFocusedContentId = "b", + rawFocusedMarqueeId = "row-1#b", + settledMarqueeId = "row-0#a", + ), + ) + } + + private fun item(id: String) = SectionItem( + contentId = id, + type = "movie", + title = id, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt new file mode 100644 index 000000000..e87d6e9c2 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt @@ -0,0 +1,101 @@ +package org.prairieserver.prairie.tv.ui.components + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvSkylineUpNavigationTest { + + @Test + fun heldUpStopsOnFirstContentRow() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction( + currentRow = 0, + rowCount = 6, + isRepeat = true, + relocationInFlight = false, + ), + ) + } + + @Test + fun freshUpFromFirstContentRowMayEnterMenu() { + assertEquals( + TvSkylineUpAction.EnterMenu, + tvSkylineUpAction( + currentRow = 0, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + ), + ) + } + + @Test + fun repeatedInputDuringOffscreenRelocationIsConsumed() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction( + currentRow = 4, + rowCount = 6, + isRepeat = true, + relocationInFlight = true, + ), + ) + } + + @Test + fun ordinaryUpWithinRowsTriesExactlyOnePreviousRow() { + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction( + currentRow = 4, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + ), + ) + } + + @Test + fun staleFirstRowIndexWhileBandIsScrolledDownStepsToPreviousRow() { + // The card focus callback lagged (or was clamped by a row refresh) and + // still says row 0, but the band shows row 3 at its top: a fast Up must + // step up, not leave for the menu. + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction( + currentRow = 0, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + bandTopRow = 3, + ), + ) + assertEquals(3, tvSkylineEffectiveRow(focusedRow = 0, bandTopRow = 3, rowCount = 6)) + } + + @Test + fun unknownFocusedRowFallsBackToBandTopRow() { + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction( + currentRow = -1, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + bandTopRow = 2, + ), + ) + assertEquals( + TvSkylineUpAction.EnterMenu, + tvSkylineUpAction( + currentRow = -1, + rowCount = 6, + isRepeat = false, + relocationInFlight = false, + bandTopRow = 0, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockKeyboardPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockKeyboardPolicyTest.kt index 12e50e2a1..f6f0962bc 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockKeyboardPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvStockKeyboardPolicyTest.kt @@ -3,6 +3,7 @@ package org.prairieserver.prairie.tv.ui.components import java.io.File import kotlin.test.Test import kotlin.test.assertFalse +import kotlin.test.assertTrue class TvStockKeyboardPolicyTest { @Test @@ -18,4 +19,36 @@ class TvStockKeyboardPolicyTest { assertFalse(File(path).exists(), "$path should not remain as a production text-entry path") } } + + @Test + fun everySurfaceThatRaisesTheStockKeyboardAlsoDismissesIt() { + // Android TV leaves the IME up when the surface that raised it goes + // away, so it floats over the next screen and keeps eating the D-pad. + // Two implementations copied the focus-and-show half of the policy and + // omitted the disposal half. + // + // Being a source check, this catches the copy — a file that takes the + // keyboard controller and shows it — not every conceivable way of + // raising the IME. It is also per file rather than per composable, so + // one helper call blesses everything in that file. + val sources = File("src/androidMain/kotlin") + .walkTopDown() + .filter { it.isFile && it.extension == "kt" } + + val showsTheKeyboard = Regex("""\w+\??\.show\(\)""") + val offenders = sources + .map { it to it.readText() } + .filter { (_, text) -> + text.contains("LocalSoftwareKeyboardController") && + showsTheKeyboard.containsMatchIn(text) + } + .filterNot { (_, text) -> text.contains("TvHideStockImeOnDispose()") } + .map { (file, _) -> file.path } + .toList() + + assertTrue( + offenders.isEmpty(), + "these raise the stock TV keyboard without dismissing it on disposal: $offenders", + ) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocusTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocusTest.kt new file mode 100644 index 000000000..afbb5110f --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvContentInitialFocusTest.kt @@ -0,0 +1,118 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvContentInitialFocusTest { + + @Test + fun `a request rejected during placement is retried until focus is observed`() { + runTest { + var requests = 0 + var focused = false + + val result = requestTvContentInitialFocus( + awaitAttempt = {}, + isContentFocused = { focused }, + requestFocus = { + requests++ + // The lazy grid places the first item on the third frame. + if (requests < 3) false else true.also { focused = true } + }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(3, requests) + } + } + + @Test + fun `a request that is accepted but never observed exhausts rather than latching success`() { + runTest { + var requests = 0 + + val result = requestTvContentInitialFocus( + awaitAttempt = {}, + isContentFocused = { false }, + requestFocus = { true.also { requests++ } }, + ) + + // The old code treated this exact case as success and latched it. + assertEquals(TvObservedFocusResult.Exhausted, result) + assertEquals(TvContentInitialFocusMaxAttempts, requests) + } + } + + @Test + fun `content that already owns focus is never asked again, so refresh cannot steal it`() { + runTest { + var requests = 0 + + val result = requestTvContentInitialFocus( + awaitAttempt = {}, + isContentFocused = { true }, + requestFocus = { true.also { requests++ } }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(0, requests) + } + } + + @Test + fun `a throwing requester does not end acquisition`() { + runTest { + var requests = 0 + var focused = false + + val result = requestTvContentInitialFocus( + awaitAttempt = {}, + isContentFocused = { focused }, + requestFocus = { + requests++ + // A detached requester throws rather than returning false. + if (requests == 1) error("not attached") + true.also { focused = true } + }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(2, requests) + } + } + + @Test + fun `no anchoring pass runs without content`() { + assertEquals(false, shouldRequestTvContentInitialFocus(contentKey = null, contentHasFocus = false)) + assertEquals(false, shouldRequestTvContentInitialFocus(contentKey = null, contentHasFocus = true)) + } + + @Test + fun `a viewer already inside the content is never pulled back by a refresh`() { + assertEquals(false, shouldRequestTvContentInitialFocus(contentKey = "collection-1", contentHasFocus = true)) + } + + @Test + fun `content that arrives with nothing focused is anchored`() { + assertEquals(true, shouldRequestTvContentInitialFocus(contentKey = "collection-1", contentHasFocus = false)) + } + + @Test + fun `returning to content that was focused before is anchored again, not suppressed`() { + // The wedge an "already acquired" latch would create: after the first + // item goes away and comes back, or after a different key exhausts in + // between, nothing holds focus and the pass must still run. + assertEquals(true, shouldRequestTvContentInitialFocus(contentKey = "collection-1", contentHasFocus = false)) + assertEquals(true, shouldRequestTvContentInitialFocus(contentKey = "collection-2", contentHasFocus = false)) + assertEquals(true, shouldRequestTvContentInitialFocus(contentKey = "collection-1", contentHasFocus = false)) + } + + @Test + fun `the attempt budget covers the acquisition window`() { + assertEquals( + TvFocusAcquisitionBudgetMillis, + TvContentInitialFocusMaxAttempts * 60L, + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablementTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablementTest.kt new file mode 100644 index 000000000..4e79c11b4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlEnablementTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvControlEnablementTest { + @Test + fun structuralDisablementLeavesTheFocusGraph() { + val state = TvControlState.structural(isEnabled = false) + + assertFalse(state.focusable) + assertFalse(state.actionable) + } + + @Test + fun transientDisablementPreservesFocusButSuppressesAction() { + val state = TvControlState.transient(isEnabled = false) + var invoked = false + + state.perform { invoked = true } + + assertTrue(state.focusable) + assertFalse(state.actionable) + assertFalse(invoked) + } + + @Test + fun enabledTransientControlRunsItsAction() { + val state = TvControlState.transient(isEnabled = true) + var invoked = false + + state.perform { invoked = true } + + assertTrue(state.focusable) + assertTrue(state.actionable) + assertTrue(invoked) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlSemanticsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlSemanticsTest.kt new file mode 100644 index 000000000..06124c993 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvControlSemanticsTest.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.tv.ui.focus + +import android.app.Application +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Surface +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +@OptIn(ExperimentalTvMaterial3Api::class) +class TvControlSemanticsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun transientlyDisabledControlRemainsFocusableAndReportsDisabled() { + val state = TvControlState.transient(isEnabled = false) + composeRule.setContent { + Surface( + onClick = {}, + enabled = state.focusable, + modifier = Modifier + .testTag("control") + .tvControlSemantics(state), + ) {} + } + + composeRule.onNodeWithTag("control") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) + .assertIsNotEnabled() + } + + @Test + fun actionableControlReportsEnabled() { + val state = TvControlState.transient(isEnabled = true) + composeRule.setContent { + Surface( + onClick = {}, + enabled = state.focusable, + modifier = Modifier + .testTag("control") + .tvControlSemantics(state), + ) {} + } + + composeRule.onNodeWithTag("control") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Focused, false)) + .assertIsEnabled() + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt new file mode 100644 index 000000000..4406686ad --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt @@ -0,0 +1,143 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvObservedFocusPolicyTest { + @Test + fun requestOutcomeDistinguishesRejectionAcceptanceAndObservation() { + assertEquals( + TvFocusRequestOutcome.Rejected, + observeTvFocusRequest(requestAccepted = false, isFocused = false), + ) + assertEquals( + TvFocusRequestOutcome.AcceptedUnobserved, + observeTvFocusRequest(requestAccepted = true, isFocused = false), + ) + assertEquals( + TvFocusRequestOutcome.Focused, + observeTvFocusRequest(requestAccepted = true, isFocused = true), + ) + } + + @Test + fun rejectedAndThrowingRequestsRetryUntilFocusIsObserved() = runTest { + var requests = 0 + var focused = false + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { + requests++ + when (requests) { + 1 -> false + 2 -> error("detached") + else -> true.also { focused = true } + } + }, + isFocused = { focused }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(3, requests) + } + + @Test + fun acceptedButUnobservedRequestsExhaustTheBudget() = runTest { + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 4, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { true.also { requests++ } }, + isFocused = { false }, + ) + + assertEquals(TvObservedFocusResult.Exhausted, result) + assertEquals(4, requests) + } + + @Test + fun notReadyTargetsWaitWithoutRequesting() = runTest { + var frames = 0 + var requests = 0 + var focused = false + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = { frames++ }, + targetState = { + if (frames < 3) TvFocusTargetState.NotReady else TvFocusTargetState.Ready + }, + requestFocus = { + requests++ + true.also { focused = true } + }, + isFocused = { focused }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(1, requests) + assertEquals(3, frames) + } + + @Test + fun disposedTargetStopsWithoutRequestingAgain() = runTest { + var frames = 0 + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = { frames++ }, + targetState = { + if (frames < 2) TvFocusTargetState.Ready else TvFocusTargetState.Disposed + }, + requestFocus = { false.also { requests++ } }, + isFocused = { false }, + ) + + assertEquals(TvObservedFocusResult.Disposed, result) + assertEquals(1, requests) + } + + @Test + fun existingObservedFocusCompletesWithoutRequesting() = runTest { + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 3, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { true.also { requests++ } }, + isFocused = { true }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(0, requests) + } + + @Test + fun cancellationFromFocusRequestEscapes() = runTest { + val cancellation = CancellationException("cancelled") + + val thrown = try { + requestFocusUntilObserved( + maxAttempts = 1, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { throw cancellation }, + isFocused = { false }, + ) + null + } catch (thrown: CancellationException) { + thrown + } + + assertEquals(cancellation, thrown) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTargetTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTargetTest.kt new file mode 100644 index 000000000..c5bdbf3f9 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvPairDeviceFocusTargetTest.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Focus here is keyed on which control deserves focus, not on which control + * happens to be enabled. The panel keeps something focusable in every state — + * Check is gated transiently — so there is always an answer. + * + * Deny is absent from [TvPairDeviceAction] entirely rather than merely + * unreachable, so "focus never defaults to the destructive choice" is a + * compile-time property and not something asserted here. + */ +class TvPairDeviceFocusTargetTest { + + private fun target( + hasCompleted: Boolean = false, + hasResolvedLookup: Boolean = false, + canEnterCode: Boolean = false, + ) = tvPairDeviceFocusTarget(hasCompleted, hasResolvedLookup, canEnterCode) + + @Test + fun `a token route waits on Check until its automatic lookup resolves`() { + // The deep-link route has no code to enter and nothing to approve yet. + // Check is the only control on screen, and it stays focusable while its + // own lookup runs — which is why this is Check and not "nothing". + assertEquals(TvPairDeviceAction.Check, target()) + } + + @Test + fun `a resolved lookup moves focus to Approve`() { + assertEquals(TvPairDeviceAction.Approve, target(hasResolvedLookup = true)) + } + + @Test + fun `a failed lookup falls back to Check rather than to Approve`() { + // The error path clears the lookup while the token stays set. Keying on + // the identifier instead put focus on an Approve that could act on a + // request the server had just rejected. + assertEquals(TvPairDeviceAction.Check, target(hasResolvedLookup = false)) + } + + @Test + fun `the manual route offers code entry before any lookup resolves`() { + assertEquals(TvPairDeviceAction.EnterCode, target(canEnterCode = true)) + } + + @Test + fun `a resolved lookup outranks code entry`() { + assertEquals( + TvPairDeviceAction.Approve, + target(hasResolvedLookup = true, canEnterCode = true), + ) + } + + @Test + fun `completion outranks everything`() { + assertEquals( + TvPairDeviceAction.Done, + target(hasCompleted = true, hasResolvedLookup = true, canEnterCode = true), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTargetTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTargetTest.kt new file mode 100644 index 000000000..dcb122b38 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvProfileFocusTargetTest.kt @@ -0,0 +1,163 @@ +package org.prairieserver.prairie.tv.ui.focus + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TvProfileFocusTargetTest { + + private val abc = listOf("a", "b", "c", "d") + + @Test + fun `the first arrival of the list is anchored`() { + assertEquals( + "a", + tvProfileFocusTarget( + previousIds = emptyList(), + currentIds = abc, + focusedId = null, + hasMaterialized = false, + ), + ) + } + + @Test + fun `deleting the first profile falls to the one that replaced it`() { + assertEquals( + "b", + tvProfileFocusTarget( + previousIds = abc, + currentIds = listOf("b", "c", "d"), + focusedId = "a", + hasMaterialized = true, + ), + ) + } + + @Test + fun `deleting the only profile anchors nothing`() { + assertNull( + tvProfileFocusTarget( + previousIds = listOf("a"), + currentIds = emptyList(), + focusedId = "a", + hasMaterialized = true, + ), + ) + } + + @Test + fun `adding a profile leaves the focused one alone`() { + assertEquals( + "b", + tvProfileFocusTarget( + previousIds = abc, + currentIds = abc + "e", + focusedId = "b", + hasMaterialized = true, + ), + ) + } + + @Test + fun `a list that changed but kept the focused profile keeps focus on it`() { + // Note the caller keys its effect on the ID list, so an *identical* + // list never reaches here — this is the changed-list case, which is + // what an edit or a deletion elsewhere in the grid produces. + assertEquals( + "c", + tvProfileFocusTarget( + previousIds = abc, + currentIds = listOf("a", "c", "d"), + focusedId = "c", + hasMaterialized = true, + ), + ) + } + + @Test + fun `an edited profile keeps focus even after it moves position`() { + assertEquals( + "c", + tvProfileFocusTarget( + previousIds = abc, + currentIds = listOf("c", "a", "b", "d"), + focusedId = "c", + hasMaterialized = true, + ), + ) + } + + @Test + fun `deleting the focused profile falls to the tile that took its place`() { + assertEquals( + "d", + tvProfileFocusTarget( + previousIds = abc, + currentIds = listOf("a", "b", "d"), + focusedId = "c", + hasMaterialized = true, + ), + ) + } + + @Test + fun `deleting the last profile falls back to the new last one`() { + assertEquals( + "c", + tvProfileFocusTarget( + previousIds = abc, + currentIds = listOf("a", "b", "c"), + focusedId = "d", + hasMaterialized = true, + ), + ) + } + + @Test + fun `a refresh with focus elsewhere on the screen is left alone`() { + // Focus may be on Add Profile, Change Server or Sign Out. A profile + // reload has no business pulling it into the grid. + assertNull( + tvProfileFocusTarget( + previousIds = abc, + currentIds = abc, + focusedId = null, + hasMaterialized = true, + ), + ) + } + + @Test + fun `an empty list has nothing to anchor`() { + assertNull( + tvProfileFocusTarget( + previousIds = abc, + currentIds = emptyList(), + focusedId = "a", + hasMaterialized = true, + ), + ) + assertNull( + tvProfileFocusTarget( + previousIds = emptyList(), + currentIds = emptyList(), + focusedId = null, + hasMaterialized = false, + ), + ) + } + + @Test + fun `a focused profile that was never in the previous list is left alone`() { + // No index to fall back from, so guessing would be worse than nothing. + assertNull( + tvProfileFocusTarget( + previousIds = listOf("a", "b"), + currentIds = listOf("a", "b"), + focusedId = "ghost", + hasMaterialized = true, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdaptersTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdaptersTest.kt new file mode 100644 index 000000000..1dc032204 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnAdaptersTest.kt @@ -0,0 +1,155 @@ +package org.prairieserver.prairie.tv.ui.focus + +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TvReturnAdaptersTest { + + private fun item(contentId: String) = SectionItem( + contentId = contentId, + type = "movie", + title = "Title $contentId", + ) + + private fun section(id: String, ids: List, totalCount: Int = 0) = ResolvedSection( + id = id, + sectionType = "row", + title = id, + totalCount = totalCount, + items = ids.map(::item), + ) + + @Test + fun `rows project to their section id and card content ids in order`() { + val feed = listOf( + section("continue", listOf("a", "b")), + section("recent", listOf("c")), + ) + + assertEquals( + listOf( + TvReturnSection("continue", listOf("a", "b")), + TvReturnSection("recent", listOf("c")), + ), + feed.toTvReturnSections(), + ) + } + + @Test + fun `a capped row is complete even though the server says more exist`() { + // The distinction that matters: totalCount above the card count means + // the row was TRIMMED, not that a page is still coming. Nothing will + // ever load the remainder, so reporting it incomplete would leave every + // unresolved return waiting forever for a request nobody makes. + val capped = listOf( + section("recent", listOf("a", "b"), totalCount = 500).copy(itemLimit = 2), + ) + + assertTrue(capped.toTvReturnSections().single().isComplete) + } + + @Test + fun `an unhydrated placeholder row is incomplete, not empty`() { + // The distinction this pins: hydrateHomeSections fetches rows that + // arrive with no cards but a non-zero total, then fills them in. + // Reading that as "nothing here" spends the return target on a + // fallback moments before the real row appears. + val placeholder = listOf(section("recent", emptyList(), totalCount = 20)) + + assertTrue(!placeholder.toTvReturnSections().single().isComplete) + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget( + TvReturnTarget("recent", "e", 0, 1), + placeholder.toTvReturnSections(), + ), + ) + } + + @Test + fun `a genuinely empty row is complete`() { + val empty = listOf(section("recent", emptyList(), totalCount = 0)) + assertTrue(empty.toTvReturnSections().single().isComplete) + } + + @Test + fun `an empty feed projects to no sections`() { + assertEquals(emptyList(), emptyList().toTvReturnSections()) + } + + @Test + fun `a projected feed resolves a launch card by identity`() { + // End to end through the contract, so the projection is exercised the + // way the feed will use it rather than only compared field by field. + val feed = listOf( + section("continue", listOf("a", "b")), + section("recent", listOf("c", "d")), + ) + val launched = TvReturnTarget("recent", "d", sectionIndex = 1, itemIndex = 1) + + // The feed reorders and the launch row moves; identity still finds it. + val reordered = listOf( + section("recent", listOf("new", "c", "d")), + section("continue", listOf("a", "b")), + ) + + assertEquals( + TvReturnResolution.Exact(1, 1, "recent", "d"), + resolveTvReturnTarget(launched, feed.toTvReturnSections()), + ) + assertEquals( + TvReturnResolution.Exact(0, 2, "recent", "d"), + resolveTvReturnTarget(launched, reordered.toTvReturnSections()), + ) + } + + // ── Flat paginated surfaces ────────────────────────────────────────── + + @Test + fun `a flat page still loading reports incomplete`() { + val page = flatTvReturnSections(listOf("a", "b"), hasMore = true) + + assertTrue(!page.single().isComplete) + assertEquals(TvFlatSectionId, page.single().id) + } + + @Test + fun `a fully loaded flat surface reports complete`() { + assertTrue(flatTvReturnSections(listOf("a"), hasMore = false).single().isComplete) + } + + @Test + fun `a target beyond the loaded page waits rather than settling nearby`() { + // The regression this pins for every paginated surface: an item on a + // later page is missing exactly as a deleted one is, and settling here + // strands focus on whatever card the first page happens to end with. + val launched = TvReturnTarget(TvFlatSectionId, "z", sectionIndex = 0, itemIndex = 60) + + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget(launched, flatTvReturnSections(listOf("a", "b"), hasMore = true)), + ) + // Once the page carrying it arrives, identity finds it wherever it sits. + assertEquals( + TvReturnResolution.Exact(0, 2, TvFlatSectionId, "z"), + resolveTvReturnTarget(launched, flatTvReturnSections(listOf("a", "b", "z"), hasMore = true)), + ) + } + + @Test + fun `a caller out of patience settles on the nearest loaded card`() { + val launched = TvReturnTarget(TvFlatSectionId, "z", sectionIndex = 0, itemIndex = 60) + + assertEquals( + TvReturnResolution.Nearest(0, 1, TvFlatSectionId, "b"), + resolveTvReturnTarget( + launched, + flatTvReturnSections(listOf("a", "b"), hasMore = true), + treatAbsenceAsFinal = true, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTargetTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTargetTest.kt new file mode 100644 index 000000000..d2ea9fb4e --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvReturnTargetTest.kt @@ -0,0 +1,726 @@ +package org.prairieserver.prairie.tv.ui.focus + +import androidx.compose.runtime.saveable.SaverScope +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The cases here are the ones saved indices got wrong. Each names the real + * event that produces it, because "the data changed while you were away" is the + * normal state of a feed, not an edge case. + */ +class TvReturnTargetTest { + + private val continueWatching = TvReturnSection("continue", listOf("a", "b", "c")) + private val recentlyAdded = TvReturnSection("recent", listOf("d", "e")) + private val because = TvReturnSection("because", listOf("f", "g", "h")) + private val feed = listOf(continueWatching, recentlyAdded, because) + + private fun target( + sectionId: String = "recent", + itemId: String = "e", + sectionIndex: Int = 1, + itemIndex: Int = 1, + ) = TvReturnTarget(sectionId, itemId, sectionIndex, itemIndex) + + // ── The occurrence is still there ──────────────────────────────────── + + @Test + fun `an unchanged feed returns to the launch card`() { + assertEquals( + TvReturnResolution.Exact(1, 1, "recent", "e"), + resolveTvReturnTarget(target(), feed), + ) + } + + @Test + fun `a feed that reordered its rows follows the row, not the saved index`() { + // The launch row moves from index 1 to index 2. A resolver trusting the + // saved index would land in a different row entirely. + val reordered = listOf(because, continueWatching, recentlyAdded) + assertEquals( + TvReturnResolution.Exact(2, 1, "recent", "e"), + resolveTvReturnTarget(target(), reordered), + ) + } + + @Test + fun `items inserted ahead of the launch card do not drag focus along`() { + val shifted = listOf( + continueWatching, + recentlyAdded.copy(itemIds = listOf("new", "d", "e")), + because, + ) + assertEquals( + TvReturnResolution.Exact(1, 2, "recent", "e"), + resolveTvReturnTarget(target(), shifted), + ) + } + + @Test + fun `an item removed from the front of the row shifts the target left`() { + val shifted = listOf(continueWatching, recentlyAdded.copy(itemIds = listOf("e")), because) + assertEquals( + TvReturnResolution.Exact(1, 0, "recent", "e"), + resolveTvReturnTarget(target(), shifted), + ) + } + + @Test + fun `a flat grid resolves by identity after a sort changes the order`() { + val grid = listOf(TvReturnSection(TvFlatSectionId, listOf("r", "q", "p"))) + val launched = TvReturnTarget(TvFlatSectionId, itemId = "p", sectionIndex = 0, itemIndex = 0) + + assertEquals( + TvReturnResolution.Exact(0, 2, TvFlatSectionId, "p"), + resolveTvReturnTarget(launched, grid), + ) + } + + // ── Duplicates and relocation ──────────────────────────────────────── + + @Test + fun `a duplicate elsewhere is not mistaken for the launch card`() { + // Feeds routinely carry the same item in several rows. "b" leaves + // Continue Watching while a copy that was ALWAYS in Recently Added + // stays put. Jumping to that copy throws focus across the feed to a + // card the viewer never touched. + val duplicated = listOf( + continueWatching.copy(itemIds = listOf("a", "c")), + recentlyAdded.copy(itemIds = listOf("d", "b", "e")), + because, + ) + val launched = target(sectionId = "continue", itemId = "b", sectionIndex = 0, itemIndex = 1) + + assertEquals( + TvReturnResolution.Nearest(0, 1, "continue", "c"), + resolveTvReturnTarget(launched, duplicated), + ) + } + + @Test + fun `a surface with disjoint rows can opt into following the item`() { + val moved = listOf( + continueWatching.copy(itemIds = listOf("a", "c")), + recentlyAdded, + because.copy(itemIds = listOf("f", "b", "g", "h")), + ) + val launched = target(sectionId = "continue", itemId = "b", sectionIndex = 0, itemIndex = 1) + + assertEquals( + TvReturnResolution.Exact(2, 1, "because", "b"), + resolveTvReturnTarget(launched, moved, TvReturnRelocation.FollowAcrossSections), + ) + } + + @Test + fun `following the item prefers the copy nearest the original row`() { + // With several copies, display order must not decide: a refresh that + // merely reorders those rows would change where focus lands. + val everywhere = listOf( + TvReturnSection("s0", listOf("x")), + TvReturnSection("s1", listOf("b")), + TvReturnSection("gone-from", listOf("y")), + TvReturnSection("s3", listOf("b")), + ) + val launched = target(sectionId = "gone-from", itemId = "b", sectionIndex = 2, itemIndex = 0) + + assertEquals( + TvReturnResolution.Exact(3, 0, "s3", "b"), + resolveTvReturnTarget(launched, everywhere, TvReturnRelocation.FollowAcrossSections), + ) + } + + // ── Incomplete data ────────────────────────────────────────────────── + + @Test + fun `a half-loaded section does not count as proof the item is gone`() { + // Grids, Search, personal lists, Collections and people all paginate. + // An item on page four is missing from page one in exactly the way a + // deleted item is, and consuming the target here strands focus on a + // stand-in permanently. + val firstPage = listOf(TvReturnSection(TvFlatSectionId, listOf("p", "q"), isComplete = false)) + val launched = TvReturnTarget(TvFlatSectionId, itemId = "z", sectionIndex = 0, itemIndex = 40) + + assertEquals(TvReturnResolution.Pending, resolveTvReturnTarget(launched, firstPage)) + } + + @Test + fun `a fully loaded section that lacks the item is proof enough`() { + val complete = listOf(TvReturnSection(TvFlatSectionId, listOf("p", "q"), isComplete = true)) + val launched = TvReturnTarget(TvFlatSectionId, itemId = "z", sectionIndex = 0, itemIndex = 40) + + assertEquals( + TvReturnResolution.Nearest(0, 1, TvFlatSectionId, "q"), + resolveTvReturnTarget(launched, complete), + ) + } + + @Test + fun `an empty but still loading surface waits rather than giving up`() { + val loading = listOf(TvReturnSection("recent", emptyList(), isComplete = false)) + assertEquals(TvReturnResolution.Pending, resolveTvReturnTarget(target(), loading)) + } + + @Test + fun `a loading sibling does not hold up a section that has finished`() { + // The launch row is complete and does not list the item, so its absence + // is already authoritative — another row still loading is irrelevant + // unless the surface follows items across rows. + val mixed = listOf( + continueWatching.copy(isComplete = false), + recentlyAdded.copy(itemIds = listOf("d"), isComplete = true), + because, + ) + assertEquals( + TvReturnResolution.Nearest(1, 0, "recent", "d"), + resolveTvReturnTarget(target(), mixed), + ) + } + + @Test + fun `a vanished row waits on a loading sibling only when following items`() { + // With the row gone, a cross-section match is the only thing that could + // still turn up — so completeness matters only to a surface that would + // accept one. + val loading = listOf(continueWatching.copy(isComplete = false), because) + val launched = target(sectionId = "gone", itemId = "e", sectionIndex = 1, itemIndex = 0) + + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget(launched, loading, TvReturnRelocation.FollowAcrossSections), + ) + assertEquals( + TvReturnResolution.Nearest(1, 0, "because", "f"), + resolveTvReturnTarget(launched, loading), + ) + } + + @Test + fun `a feed still loading its rows waits before falling back`() { + // The launch row may simply not have arrived yet. A per-section flag + // cannot say so — a section that has not loaded is not in the list to + // carry one — which is why completeness is also asked of the list. + val partialFeed = listOf(continueWatching) + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget(target(), partialFeed, sectionsComplete = false), + ) + // Once the feed says that is all the rows there are, the launch row is + // genuinely gone and the fallback is honest. + assertEquals( + TvReturnResolution.Nearest(0, 1, "continue", "b"), + resolveTvReturnTarget(target(), partialFeed, sectionsComplete = true), + ) + } + + @Test + fun `following items waits on any loading row even when the launch row is done`() { + // The launch row has finished and does not list the item, but a surface + // that accepts the item from anywhere could still see it arrive in the + // row that is still filling. + val mixed = listOf( + continueWatching.copy(isComplete = false), + recentlyAdded.copy(itemIds = listOf("d"), isComplete = true), + because, + ) + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget(target(), mixed, TvReturnRelocation.FollowAcrossSections), + ) + // A surface that only looks in its own row has its answer already. + assertEquals( + TvReturnResolution.Nearest(1, 0, "recent", "d"), + resolveTvReturnTarget(target(), mixed), + ) + } + + // ── The occurrence is gone ─────────────────────────────────────────── + + @Test + fun `a removed item leaves focus on whatever took its place`() { + val without = listOf(continueWatching, recentlyAdded.copy(itemIds = listOf("d", "x")), because) + assertEquals( + TvReturnResolution.Nearest(1, 1, "recent", "x"), + resolveTvReturnTarget(target(), without), + ) + } + + @Test + fun `a removed last item falls back inside its row rather than off the end`() { + val shorter = listOf(continueWatching, recentlyAdded.copy(itemIds = listOf("d")), because) + assertEquals( + TvReturnResolution.Nearest(1, 0, "recent", "d"), + resolveTvReturnTarget(target(), shorter), + ) + } + + @Test + fun `a removed row falls back to the row that slid into its place`() { + val without = listOf(continueWatching, because) + assertEquals( + TvReturnResolution.Nearest(1, 1, "because", "g"), + resolveTvReturnTarget(target(), without), + ) + } + + @Test + fun `a removed row with no exact successor picks the nearest by distance`() { + // The launch row was index 3 of a longer feed; only earlier rows + // survive, so the fallback has to measure distance rather than reuse + // the saved index. + val survivors = listOf(continueWatching, recentlyAdded) + val launched = target(sectionId = "gone", itemId = "z", sectionIndex = 3, itemIndex = 0) + + assertEquals( + TvReturnResolution.Nearest(1, 0, "recent", "d"), + resolveTvReturnTarget(launched, survivors), + ) + } + + @Test + fun `an emptied row is never chosen as a fallback`() { + val emptied = listOf( + continueWatching, + recentlyAdded.copy(itemIds = emptyList()), + because, + ) + assertEquals( + TvReturnResolution.Nearest(2, 1, "because", "g"), + resolveTvReturnTarget(target(), emptied), + ) + } + + @Test + fun `a tie between the row above and below goes to the one below`() { + // Only reachable for an emptied-but-present row: a removed row makes + // its successor land at distance zero, which is no tie. This is a plain + // forward bias — moving focus backwards lands the viewer in content + // they have already scrolled past. + val emptiedInPlace = listOf( + continueWatching, + recentlyAdded.copy(itemIds = emptyList()), + because, + ) + assertEquals( + TvReturnResolution.Nearest(2, 0, "because", "f"), + resolveTvReturnTarget(target(itemIndex = 0), emptiedInPlace), + ) + } + + @Test + fun `an entirely empty feed has nothing to restore`() { + assertEquals(TvReturnResolution.Empty, resolveTvReturnTarget(target(), emptyList())) + assertEquals( + TvReturnResolution.Empty, + resolveTvReturnTarget(target(), listOf(TvReturnSection("recent", emptyList()))), + ) + } + + @Test + fun `no recorded target restores nothing`() { + assertEquals(TvReturnResolution.Empty, resolveTvReturnTarget(null, feed)) + } + + // ── Surviving process death ────────────────────────────────────────── + + private fun roundTrip(target: TvReturnTarget?): TvReturnTarget? { + val scope = SaverScope { true } + val saved = with(TvReturnTargetSaver) { scope.save(target) } + return saved?.let { TvReturnTargetSaver.restore(it) } + } + + @Test + fun `a target survives process death intact`() { + val launched = target() + assertEquals(launched, roundTrip(launched)) + } + + @Test + fun `a flat surface's target survives process death`() { + val flat = TvReturnTarget(TvFlatSectionId, itemId = "r", sectionIndex = 0, itemIndex = 2) + assertEquals(flat, roundTrip(flat)) + } + + @Test + fun `a flat surface restores a surviving item by identity, not by position`() { + // The defect this replaces: the flat section id used to be null while + // no section could ever BE null, so a flat surface never matched its + // own launch section and every surviving item came back as a positional + // fallback. An earlier version of this test asserted that behaviour and + // so made the bug look intended. + val grid = listOf(TvReturnSection(TvFlatSectionId, listOf("r", "q", "p"))) + val launched = TvReturnTarget(TvFlatSectionId, itemId = "q", sectionIndex = 0, itemIndex = 2) + + assertEquals( + TvReturnResolution.Exact(0, 1, TvFlatSectionId, "q"), + resolveTvReturnTarget(launched, grid), + ) + } + + @Test + fun `no target survives as no target`() { + assertEquals(null, roundTrip(null)) + } + + // ── Waiting, and stopping waiting ──────────────────────────────────── + + @Test + fun `a finished launch row does not wait on rows that have not loaded`() { + // Under SameSectionOnly only the launch row can answer, so once it is + // here and complete the question is settled. Waiting on rows that + // could not change the answer is a stall, not caution. + val partial = listOf(recentlyAdded.copy(itemIds = listOf("d"), isComplete = true)) + val launched = target(sectionIndex = 0) + + assertEquals( + TvReturnResolution.Nearest(0, 0, "recent", "d"), + resolveTvReturnTarget(launched, partial, sectionsComplete = false), + ) + } + + @Test + fun `identity still wins while the surface is loading`() { + // Finding it ends the question immediately; completeness only governs + // how absence is read. + val partial = listOf(recentlyAdded.copy(isComplete = false)) + assertEquals( + TvReturnResolution.Exact(0, 1, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0), partial, sectionsComplete = false), + ) + } + + @Test + fun `a pending target resolves once the page carrying it arrives`() { + val launched = TvReturnTarget(TvFlatSectionId, itemId = "z", sectionIndex = 0, itemIndex = 40) + val firstPage = listOf(TvReturnSection(TvFlatSectionId, listOf("p", "q"), isComplete = false)) + assertEquals(TvReturnResolution.Pending, resolveTvReturnTarget(launched, firstPage)) + + val withNextPage = listOf( + TvReturnSection(TvFlatSectionId, listOf("p", "q", "y", "z"), isComplete = false), + ) + assertEquals( + TvReturnResolution.Exact(0, 3, TvFlatSectionId, "z"), + resolveTvReturnTarget(launched, withNextPage), + ) + } + + @Test + fun `a caller that has waited long enough can force an answer`() { + // The terminal half of Pending. Without it a caller has to either lie + // about completeness or rebuild the fallback itself at every surface. + val launched = TvReturnTarget(TvFlatSectionId, itemId = "z", sectionIndex = 0, itemIndex = 40) + val stillLoading = listOf(TvReturnSection(TvFlatSectionId, listOf("p", "q"), isComplete = false)) + + assertEquals(TvReturnResolution.Pending, resolveTvReturnTarget(launched, stillLoading)) + assertEquals( + TvReturnResolution.Nearest(0, 1, TvFlatSectionId, "q"), + resolveTvReturnTarget(launched, stillLoading, treatAbsenceAsFinal = true), + ) + } + + @Test + fun `forcing an answer on an empty loading surface gives up rather than waiting`() { + val nothingYet = listOf(TvReturnSection("recent", emptyList(), isComplete = false)) + assertEquals( + TvReturnResolution.Empty, + resolveTvReturnTarget(target(), nothingYet, treatAbsenceAsFinal = true), + ) + } + + // ── Duplicate ids ──────────────────────────────────────────────────── + + @Test + fun `a repeated item within a row resolves to the copy nearest the viewer`() { + val repeated = listOf(TvReturnSection("recent", listOf("e", "d", "x", "e"))) + assertEquals( + TvReturnResolution.Exact(0, 3, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0, itemIndex = 3), repeated), + ) + assertEquals( + TvReturnResolution.Exact(0, 0, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0, itemIndex = 0), repeated), + ) + } + + @Test + fun `a duplicated section id does not shadow the row actually holding the item`() { + // An empty namesake appearing first would otherwise hide the real row + // and send focus to a positional fallback. + val shadowed = listOf( + TvReturnSection("recent", emptyList()), + TvReturnSection("recent", listOf("d", "e")), + ) + assertEquals( + TvReturnResolution.Exact(1, 1, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0), shadowed), + ) + } + + @Test + fun `following the item prefers a nearer copy over a further one`() { + val everywhere = listOf( + TvReturnSection("s0", listOf("b")), + TvReturnSection("s1", listOf("x")), + TvReturnSection("gone-from", listOf("y")), + TvReturnSection("s3", listOf("b")), + ) + val launched = target(sectionId = "gone-from", itemId = "b", sectionIndex = 3, itemIndex = 0) + + // s3 is adjacent to the remembered coordinate; s0 is three rows away. + assertEquals( + TvReturnResolution.Exact(3, 0, "s3", "b"), + resolveTvReturnTarget(launched, everywhere, TvReturnRelocation.FollowAcrossSections), + ) + } + + // ── Duplicate section ids, disambiguated ───────────────────────────── + + @Test + fun `the nearest namesake holding the item wins, not the first`() { + // Matching every namesake but taking the first contradicts the same + // nearest-coordinate policy used everywhere else, and sends focus to + // the far end of the feed. + val namesakes = listOf( + TvReturnSection("recent", listOf("d", "e")), + TvReturnSection("filler", listOf("x")), + TvReturnSection("recent", listOf("d", "e")), + ) + assertEquals( + TvReturnResolution.Exact(2, 1, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 2), namesakes), + ) + assertEquals( + TvReturnResolution.Exact(0, 1, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0), namesakes), + ) + } + + @Test + fun `the nearest populated namesake wins the positional fallback too`() { + val namesakes = listOf( + TvReturnSection("recent", listOf("p")), + TvReturnSection("filler", listOf("x")), + TvReturnSection("recent", listOf("q")), + ) + assertEquals( + TvReturnResolution.Nearest(2, 0, "recent", "q"), + resolveTvReturnTarget(target(sectionIndex = 2, itemIndex = 0), namesakes), + ) + } + + @Test + fun `a namesake still loading keeps the target waiting`() { + val namesakes = listOf( + TvReturnSection("recent", listOf("p"), isComplete = true), + TvReturnSection("recent", listOf("q"), isComplete = false), + ) + assertEquals(TvReturnResolution.Pending, resolveTvReturnTarget(target(), namesakes)) + } + + @Test + fun `equidistant repeated items resolve to the earlier copy`() { + // Deterministic rather than arbitrary: a refresh must not move focus + // between two equally close copies. + val repeated = listOf(TvReturnSection("recent", listOf("e", "x", "e"))) + assertEquals( + TvReturnResolution.Exact(0, 0, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 0, itemIndex = 1), repeated), + ) + } + + // ── Forcing an answer never skips one that exists ──────────────────── + + @Test + fun `forcing an answer still returns an exact match that is present`() { + assertEquals( + TvReturnResolution.Exact(1, 1, "recent", "e"), + resolveTvReturnTarget(target(), feed, treatAbsenceAsFinal = true), + ) + } + + @Test + fun `forcing an answer still returns a relocated match that is present`() { + val moved = listOf( + continueWatching.copy(itemIds = listOf("a", "c")), + recentlyAdded, + because.copy(itemIds = listOf("f", "b")), + ) + val launched = target(sectionId = "continue", itemId = "b", sectionIndex = 0, itemIndex = 1) + + assertEquals( + TvReturnResolution.Exact(2, 1, "because", "b"), + resolveTvReturnTarget( + launched, + moved, + TvReturnRelocation.FollowAcrossSections, + treatAbsenceAsFinal = true, + ), + ) + } + + @Test + fun `following items waits on an unloaded row even when every present row is done`() { + val allComplete = listOf(continueWatching, because) + val launched = target(sectionId = "gone", itemId = "e", sectionIndex = 1, itemIndex = 0) + + assertEquals( + TvReturnResolution.Pending, + resolveTvReturnTarget( + launched, + allComplete, + TvReturnRelocation.FollowAcrossSections, + sectionsComplete = false, + ), + ) + } + + // ── Heterogeneous surfaces ─────────────────────────────────────────── + + @Test + fun `namespaced identities keep two identifier spaces from colliding`() { + // Search mixes library results with request-provider results, where a + // bare "1234" is a content id in one space and a TMDB id in the other. + // The domains have to be made to COMPETE for this to prove anything: + // the launch section is gone and relocation is on, so an unnamespaced + // id would match the unrelated library card and throw focus to it. + val results = listOf(TvReturnSection("search:library", listOf("catalog:1234"))) + + val namespaced = TvReturnTarget("search:requests", "request:movie:1234", 1, 0) + assertEquals( + TvReturnResolution.Nearest(0, 0, "search:library", "catalog:1234"), + resolveTvReturnTarget(namespaced, results, TvReturnRelocation.FollowAcrossSections), + ) + + // The same shape with bare ids resolves as an Exact match on a wholly + // unrelated item — what the namespacing prevents. + val collidingResults = listOf(TvReturnSection("search:library", listOf("1234"))) + val bare = TvReturnTarget("search:requests", "1234", 1, 0) + assertEquals( + TvReturnResolution.Exact(0, 0, "search:library", "1234"), + resolveTvReturnTarget(bare, collidingResults, TvReturnRelocation.FollowAcrossSections), + ) + } + + @Test + fun `a duplicate section id degrades predictably but is not a supported shape`() { + // Section ids are required to be unique: the no-stall rule treats a + // present, finished launch section as final, which only holds if no + // second section can later arrive bearing the same id. This pins the + // known consequence rather than pretending it away — with a namesake + // still unloaded, the present one settles the question and the target + // is spent. + val onePresent = listOf(TvReturnSection("recent", listOf("d"), isComplete = true)) + + assertEquals( + TvReturnResolution.Nearest(0, 0, "recent", "d"), + resolveTvReturnTarget(target(sectionIndex = 0), onePresent, sectionsComplete = false), + ) + } + + @Test + fun `equidistant namesakes resolve forward, like every other tie`() { + val namesakes = listOf( + TvReturnSection("recent", listOf("d", "e")), + TvReturnSection("filler", listOf("x")), + TvReturnSection("recent", listOf("d", "e")), + ) + // Remembered at index 1, so both namesakes are one row away. + assertEquals( + TvReturnResolution.Exact(2, 1, "recent", "e"), + resolveTvReturnTarget(target(sectionIndex = 1), namesakes), + ) + } + + @Test + fun `equidistant populated namesakes fall back forward too`() { + val namesakes = listOf( + TvReturnSection("recent", listOf("p")), + TvReturnSection("filler", listOf("x")), + TvReturnSection("recent", listOf("q")), + ) + assertEquals( + TvReturnResolution.Nearest(2, 0, "recent", "q"), + resolveTvReturnTarget(target(sectionIndex = 1, itemIndex = 0), namesakes), + ) + } + + // ── Targets are partitioned by the surface that saved them ─────────── + + @Test + fun `a saved target is discarded when it comes back on a different surface`() { + // rememberSaveable does not validate restored values against its keys, + // so a process returning on a different tab would otherwise adopt the + // previous surface's target and restore focus to somewhere the viewer + // was in another list entirely. + val saver = keyedTvReturnTargetSaver("browse") + val scope = SaverScope { true } + val saved = with(saver) { scope.save(TvReturnTarget(TvFlatSectionId, "q", 0, 3)) } + + assertEquals(null, keyedTvReturnTargetSaver("genres").restore(saved!!)) + assertEquals( + TvReturnTarget(TvFlatSectionId, "q", 0, 3), + keyedTvReturnTargetSaver("browse").restore(saved), + ) + } + + @Test + fun `an empty target round-trips as empty on its own surface`() { + val saver = keyedTvReturnTargetSaver("browse") + val scope = SaverScope { true } + val saved = with(saver) { scope.save(null) } + assertEquals(null, saved?.let { saver.restore(it) }) + } + + @Test + fun `a right-owner target with malformed fields restores as empty`() { + assertEquals( + null, + keyedTvReturnTargetSaver("browse") + .restore(listOf("browse", TvFlatSectionId, "q", "zero", 3)), + ) + assertEquals( + null, + keyedTvReturnTargetSaver("browse") + .restore(listOf("browse", TvFlatSectionId, "q", 0, 3, "extra")), + ) + } + + @Test + fun `a saved boolean is discarded for a different owner or slot`() { + val saver = keyedBooleanSaver("home", slot = "pending") + val saved = with(saver) { SaverScope { true }.save(true) } + + assertEquals(false, keyedBooleanSaver("library", slot = "pending").restore(saved!!)) + assertEquals(false, keyedBooleanSaver("home", slot = "generation").restore(saved)) + assertEquals(true, keyedBooleanSaver("home", slot = "pending").restore(saved)) + } + + @Test + fun `a right-owner boolean payload with the wrong type restores its default`() { + assertEquals( + false, + keyedBooleanSaver("home", slot = "pending") + .restore(listOf("home", "pending", 1)), + ) + } + + @Test + fun `a saved int is discarded for a different owner or slot`() { + val saver = keyedIntSaver("home", slot = "generation") + val saved = with(saver) { SaverScope { true }.save(7) } + + assertEquals(0, keyedIntSaver("library", slot = "generation").restore(saved!!)) + assertEquals(0, keyedIntSaver("home", slot = "pending").restore(saved)) + assertEquals(7, keyedIntSaver("home", slot = "generation").restore(saved)) + } + + @Test + fun `a right-owner int payload with the wrong type restores its default`() { + assertEquals( + 0, + keyedIntSaver("home", slot = "generation") + .restore(listOf("home", "generation", true)), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSilentFocusClaimSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSilentFocusClaimSourceTest.kt new file mode 100644 index 000000000..0792e0b57 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvSilentFocusClaimSourceTest.kt @@ -0,0 +1,203 @@ +package org.prairieserver.prairie.tv.ui.focus + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Ratchet against silently-failing focus claims in TV screens. + * + * `requestFocus()` THROWS when its node has not attached yet, rather than + * returning false. Wrapping it in `runCatching` therefore does not handle the + * failure — it hides it: focus goes nowhere, no exception surfaces, and the + * viewer is left on a screen with nothing focused and no evidence in any log. + * A leanback app takes no touch input, so there is no fallback either. + * + * That is the first of the six recurring causes in + * `docs/superpowers/specs/2026-08-04-whole-application-focus-hardening-design.md` + * ("a focus request executing without exception is treated as focus + * acquisition"), and it is the mechanism behind both #199 (content focus entry + * found nothing to focus) and #202 (crash-report prompt unreachable, so crash + * reports were never sendable from a television). + * + * The fix already exists: [requestFocusUntilObserved] retries against OBSERVED + * focus and reports when a claim never lands. At the time this ratchet was + * added it was used by 8 files while 44 still called `requestFocus()` directly + * — 18% adoption, which is why the audit's cause #1 was still producing new + * defects four months on. + * + * It began at 78 sites and stopped the 79th. Those 78 have since been migrated + * on this branch, so [BASELINE] is what remains rather than what it started at. + * + * **When you migrate a site, lower [BASELINE] in the same commit.** The + * assertion is equality on purpose: a `<=` ratchet leaves slack that the next + * silent claim quietly fills. + * + * Two limits, both real, both tolerable only because the baseline is at or near + * zero: + * + * 1. The scan is a fixed character window, not a brace-aware parse. It can pair + * a `runCatching` with an unrelated `requestFocus` further down — which + * happened during the migration, where a `runCatching { scrollToItem() }` + * next to a focus claim inflated the count — and conversely it can miss a + * claim written more than [WINDOW] characters from its `runCatching`. A + * lexer would fix both and is a great deal of machinery for a source test. + * + * 2. The assertion compares a total, not a set. While the baseline was + * non-zero, adding one claim and migrating another kept the total and passed. + * At zero there is nothing to offset against, so any occurrence fails — + * which is the only reason a count is sufficient here. **If this baseline is + * ever raised above zero again, that hole reopens**, and the fix is to + * compare discovered sites against an approved set rather than a number. + */ +class TvSilentFocusClaimSourceTest { + + private companion object { + /** + * Known `runCatching { … requestFocus() … }` sites in TV screens. + * + * 2026-08-10: 78 at introduction — player 10, detail 8, settings 7, + * recommendations 7, calendar 6, auth 6, library 6, search 6, people 5, + * settings/diagnostics 4, requests 3, profiles 3, admin 2, + * notifications 2, home 1, audiobook 1, browse 1. + * + * 2026-08-10: 76 — the intro auto-skip banner and the HUD option popup + * migrated to rememberTvContentInitialFocus. + * + * 2026-08-10: 73 — the card-overlay preview relocation and both inbox + * claims migrated to requestFocusUntilObserved. + * + * 2026-08-10: 70 — person detail's filter-chip acquisition, its + * post-filter-change relocation, and the full-bio modal. + * + * NOTE: not every remaining site can adopt the policy. Person detail's + * popup-dismiss restore runs in `DisposableEffect { onDispose { … } }`, + * which is not a suspend context, so a retry loop cannot run there at + * all. Sites like that need a different answer than migration, and + * counting them here is a known limitation of this ratchet rather than + * a debt that can be paid down to zero. + * + * 2026-08-10: 66 — first-run setup, signup, and both login-surface + * claims. + * + * 2026-08-10: 63 — the two library grids and collection detail. The + * library grids also stopped reporting a handover that had not + * happened; see that commit. + * + * 2026-08-10: 60 — calendar's shelf request (same false handover) and + * its hand-rolled six-attempt day claim, replaced by the shared policy. + * + * 2026-08-10: 54 — settings: the four-attempt entry loop and its + * unconditional handover, the detail request, the picker dialog, the + * destructive-confirm Cancel, and the Back-to-category claim, which + * uses claimFocusOrReport because a BackHandler has no suspend point. + * + * There is no longer a category of site that cannot be migrated: a + * caller without a coroutine still gets a reported failure instead of a + * swallowed one, so this baseline's floor is zero. + * + * 2026-08-10: 49 — person detail's onDispose restore and calendar's + * Up-fallback branch (both via claimFocusOrReport), plus library's + * clear-filters pill, sort panel and facet panel. + * + * 2026-08-10: 43 — all six search claims, including the four-way + * post-search target and both return restorations. + * + * 2026-08-10: 36 — recommendations: six Boolean-returning bridge and + * key-handler claims via claimFocusOrReport, plus the For You entry + * claim, which was the fifth false shell handover found this sweep. + * + * 2026-08-10: 31 — admin hub and user edit, browse, the audiobook + * bookmark delete, and home — home being the sixth false handover. + * + * 2026-08-10: 25 — profile form's three D-pad-down key handlers, and + * requests' entry claim (seventh false handover) plus its post-search + * target. + * + * 2026-08-10: 17 — all eight item-detail sites, including the + * `runCatching{}.isSuccess` pair that treated "did not throw" as + * "focused". + * + * 2026-08-10: 9 — the player: HUD tab seed and picker return, the + * hidden-overlay root claim, the idle overlay target, both transport + * handoffs and the up-next primary action. + * + * 2026-08-10: 2 — diagnostics settings, server setup, person detail's + * focusBio, and calendar's NavHost-restore handoff. + * + * The two that remain are both in TvDiagnosticsPromptScreen, and they + * are deliberately NOT migrated here. Retrying that claim cannot work + * from inside the shell's content Box: its focusRestorer intercepts + * focus ENTRY and reroutes it, so the retry loops into the same + * interception forever. The fix is to give the prompt its own Dialog + * window, which is a separate change; migrating these two here would + * make the code look correct while the prompt stayed unreachable. + * + * Drop this to 0 when that change lands. + * + * Everywhere else is zero. Any new `runCatching { requestFocus() }` in + * a TV screen fails the build, and the two tools between them cover + * every context: requestFocusUntilObserved where a coroutine exists, + * claimFocusOrReport where the caller must answer synchronously. + */ + const val BASELINE = 0 + + const val SCREENS_ROOT = "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens" + + /** + * How far past `runCatching` to look for the call. Wide enough for the + * multi-line form, narrow enough not to pair a `runCatching` with an + * unrelated `requestFocus()` further down the file. + */ + const val WINDOW = 220 + } + + @Test + fun tvScreensDoNotAddNewSilentFocusClaims() { + val offenders = mutableListOf() + var count = 0 + + File(SCREENS_ROOT).walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .sortedBy { it.path } + .forEach { file -> + val text = file.readText() + var found = 0 + var index = text.indexOf("runCatching") + while (index >= 0) { + val end = (index + "runCatching".length + WINDOW).coerceAtMost(text.length) + if (text.substring(index, end).contains("requestFocus(")) found++ + index = text.indexOf("runCatching", index + 1) + } + if (found > 0) { + count += found + offenders += "${file.path}: $found" + } + } + + assertEquals( + BASELINE, + count, + buildString { + appendLine("Silent focus claims in TV screens changed: expected $BASELINE, found $count.") + appendLine() + if (count > BASELINE) { + appendLine("A new `runCatching { ... requestFocus() ... }` was added.") + appendLine("requestFocus() throws when its node has not attached, so runCatching") + appendLine("hides the failure instead of handling it — focus goes nowhere and") + appendLine("nothing is logged. On a television there is no touch fallback.") + appendLine() + appendLine("Use requestFocusUntilObserved (ui/focus/TvObservedFocusPolicy.kt),") + appendLine("which retries against observed focus and reports a claim that never") + appendLine("lands.") + } else { + appendLine("Sites were migrated — thank you. Lower BASELINE to $count in this") + appendLine("same commit so the ratchet keeps its zero slack.") + } + appendLine() + appendLine("Current sites:") + offenders.forEach { appendLine(" $it") } + }, + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRoutingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRoutingTest.kt index d0f362cec..18c8eb7d4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRoutingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAudiobookRoutingTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * Pure routing decision: audiobook-type items go to the audiobook player route, @@ -109,4 +110,71 @@ class TvAudiobookRoutingTest { assertEquals(TvPlaybackDeepLinkArgs(), parseTvPlaybackDeepLinkArgs(query::get)) } + + @Test + fun exactVideoPlaybackDeepLinkIsAlreadyArrived() { + assertTrue( + tvPlaybackDeepLinkArrived( + currentRoute = TvRoute.Player.ROUTE, + currentContentId = "movie-1", + currentFileId = 42, + currentQuality = "original", + currentAudioTrackIndex = 1, + currentSubtitleTrackIndex = 8, + itemType = "movie", + contentId = "movie-1", + requested = TvPlaybackDeepLinkArgs( + fileId = 42, + quality = "original", + audioTrackIndex = 1, + subtitleTrackIndex = 8, + ), + ), + ) + } + + @Test + fun sameContentWithAnotherSubtitleIsANewPlaybackRequest() { + assertFalse( + tvPlaybackDeepLinkArrived( + currentRoute = TvRoute.Player.ROUTE, + currentContentId = "movie-1", + currentFileId = 42, + currentQuality = "original", + currentAudioTrackIndex = 1, + currentSubtitleTrackIndex = 10, + itemType = "movie", + contentId = "movie-1", + requested = TvPlaybackDeepLinkArgs( + fileId = 42, + quality = "original", + audioTrackIndex = 1, + subtitleTrackIndex = 8, + ), + ), + "a warm test link must replace the player instead of reusing stale subtitle state", + ) + } + + @Test + fun audiobookArrivalIgnoresVideoOnlyTrackArguments() { + assertTrue( + tvPlaybackDeepLinkArrived( + currentRoute = TvRoute.AudiobookPlayer.ROUTE, + currentContentId = "book-1", + currentFileId = 7, + currentQuality = null, + currentAudioTrackIndex = null, + currentSubtitleTrackIndex = null, + itemType = "audiobook", + contentId = "book-1", + requested = TvPlaybackDeepLinkArgs( + fileId = 7, + quality = "720p", + audioTrackIndex = 2, + subtitleTrackIndex = 4, + ), + ), + ) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvItemDetailNavigationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvItemDetailNavigationTest.kt new file mode 100644 index 000000000..897b5f5d5 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvItemDetailNavigationTest.kt @@ -0,0 +1,263 @@ +package org.prairieserver.prairie.tv.ui.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Item detail used `launchSingleTop`, which matches the destination NODE rather + * than its arguments. Every item-detail route shares one node, so navigating + * from detail A to related item B reused A's entry instead of pushing — Back + * from B skipped A and returned to whatever was underneath. + * + * The replacement collapses only an EXACT repeat, which is what the original + * comment claimed `launchSingleTop` did. + */ +class TvItemDetailNavigationTest { + + + @Test + fun `a different item is not the current page`() { + assertFalse( + tvIsAlreadyShowingItemDetail( + currentRoute = TvRoute.ItemDetail.ROUTE, + currentContentId = "item-a", + currentSeasonNumber = null, + contentId = "item-b", + seasonNumber = null, + ), + "a related item must push its own entry so Back returns to the item it came from", + ) + } + + @Test + fun `the same item is the current page`() { + assertTrue( + tvIsAlreadyShowingItemDetail( + currentRoute = TvRoute.ItemDetail.ROUTE, + currentContentId = "item-a", + currentSeasonNumber = null, + contentId = "item-a", + seasonNumber = null, + ), + "a double Select on one card must not stack a duplicate page", + ) + } + + @Test + fun `the same series at a different season is not the current page`() { + assertFalse( + tvIsAlreadyShowingItemDetail( + currentRoute = TvRoute.ItemDetail.ROUTE, + currentContentId = "series-a", + currentSeasonNumber = 1, + contentId = "series-a", + seasonNumber = 2, + ), + ) + } + + @Test + fun `the same series at the same season is the current page`() { + assertTrue( + tvIsAlreadyShowingItemDetail( + currentRoute = TvRoute.ItemDetail.ROUTE, + currentContentId = "series-a", + currentSeasonNumber = 3, + contentId = "series-a", + seasonNumber = 3, + ), + ) + } + + @Test + fun `the identical request on the same entry is suppressed`() { + val destination = TvRoute.Player(contentId = "movie-a").route + assertEquals( + TvPlaybackNavAction.Suppress, + tvPlaybackNavAction( + currentRoute = TvRoute.Player.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = TvPlaybackNavigation(destination, "entry-1"), + destination = destination, + ), + "a double Select must not start a second session on the same title", + ) + } + + /** + * The same title at another version is a different request; dropping it + * would make the version picker silently do nothing. + */ + @Test + fun `the same title at a different version still navigates`() { + assertEquals( + TvPlaybackNavAction.ReplaceCurrentPlayer, + tvPlaybackNavAction( + currentRoute = TvRoute.Player.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = TvPlaybackNavigation( + TvRoute.Player(contentId = "movie-a", fileId = 1).route, + "entry-1", + ), + destination = TvRoute.Player(contentId = "movie-a", fileId = 2).route, + ), + ) + } + + @Test + fun `a different title takes over the player entry`() { + assertEquals( + TvPlaybackNavAction.ReplaceCurrentPlayer, + tvPlaybackNavAction( + currentRoute = TvRoute.Player.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = TvPlaybackNavigation( + TvRoute.Player(contentId = "movie-a").route, + "entry-1", + ), + destination = TvRoute.Player(contentId = "movie-b").route, + ), + "stacking players leaves Back walking through dead sessions", + ) + } + + @Test + fun `playback arriving over an audiobook takes over that entry too`() { + assertEquals( + TvPlaybackNavAction.ReplaceCurrentPlayer, + tvPlaybackNavAction( + currentRoute = TvRoute.AudiobookPlayer.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = TvPlaybackNavigation( + TvRoute.AudiobookPlayer(contentId = "book-a").route, + "entry-1", + ), + destination = TvRoute.Player(contentId = "movie-b").route, + ), + "Back must not resurrect the audiobook the viewer replaced", + ) + } + + @Test + fun `playing from a non player screen pushes`() { + val destination = TvRoute.Player(contentId = "movie-a").route + assertEquals( + TvPlaybackNavAction.Push, + tvPlaybackNavAction( + currentRoute = TvRoute.ItemDetail.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = TvPlaybackNavigation(destination, "entry-1"), + destination = destination, + ), + "Play from detail must push a player, not be mistaken for a repeat", + ) + } + + /** + * The case that broke every weaker key: cast and auto-advance navigate + * without going through the helper, and can put up the SAME title with + * different arguments (an auto-advance handoff, another file). Keying on + * route + content id suppressed the user's real request; keying on the entry + * the request produced does not, because those paths pop and push, so the id + * differs. (Watch Together used to belong on this list; it now routes + * through the helper precisely because it single-topped the player entry in + * place, preserving the id.) + */ + @Test + fun `a bypass path putting up the same title does not suppress`() { + val destination = TvRoute.Player(contentId = "movie-a").route + assertEquals( + TvPlaybackNavAction.ReplaceCurrentPlayer, + tvPlaybackNavAction( + currentRoute = TvRoute.Player.ROUTE, + // A cast launch replaced our entry with its own for the same title. + currentEntryId = "entry-2", + lastPlaybackNavigation = TvPlaybackNavigation(destination, "entry-1"), + destination = destination, + ), + ) + } + + /** With nothing recorded there is nothing to collide with. */ + @Test + fun `a request that never arrived can be retried`() { + val destination = TvRoute.Player(contentId = "movie-a").route + assertEquals( + TvPlaybackNavAction.ReplaceCurrentPlayer, + tvPlaybackNavAction( + currentRoute = TvRoute.Player.ROUTE, + currentEntryId = "entry-1", + lastPlaybackNavigation = null, + destination = destination, + ), + ) + } + + @Test + fun `a matching id on some other destination is not the current page`() { + assertFalse( + tvIsAlreadyShowingItemDetail( + currentRoute = TvRoute.Player.ROUTE, + currentContentId = "item-a", + currentSeasonNumber = null, + contentId = "item-a", + seasonNumber = null, + ), + "playing an item must not suppress opening its detail page", + ) + } + + // --- what gets recorded --- + + /** + * The case that made the previous key unsound: replacing a Watch Together + * player for the SAME title with a solo request, where the navigation is + * dropped during teardown. Confirming arrival on route + content id alone + * would adopt the untouched Watch Together entry as ours, and the retry + * would then be suppressed — the user presses Play and nothing happens. + */ + @Test + fun `a dropped navigation records nothing even when the same title is up`() { + assertNull( + tvRecordedPlaybackNavigation( + destination = TvRoute.Player(contentId = "movie-a").route, + contentId = "movie-a", + entryIdBefore = "entry-1", + // Unchanged: navigate() was dropped. + arrivedEntryId = "entry-1", + arrivedContentId = "movie-a", + ), + ) + } + + @Test + fun `a landed navigation records its own entry`() { + val destination = TvRoute.Player(contentId = "movie-a").route + assertEquals( + TvPlaybackNavigation(destination, "entry-2"), + tvRecordedPlaybackNavigation( + destination = destination, + contentId = "movie-a", + entryIdBefore = "entry-1", + arrivedEntryId = "entry-2", + arrivedContentId = "movie-a", + ), + ) + } + + @Test + fun `landing somewhere other than the requested content records nothing`() { + assertNull( + tvRecordedPlaybackNavigation( + destination = TvRoute.Player(contentId = "movie-a").route, + contentId = "movie-a", + entryIdBefore = "entry-1", + arrivedEntryId = "entry-2", + arrivedContentId = null, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt index 1ba08d069..bc31a6260 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt @@ -1,8 +1,14 @@ package org.prairieserver.prairie.tv.ui.navigation +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff +import org.prairieserver.prairie.common.player.video.EpisodeSourceIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TvPlayerRouteTest { @@ -10,6 +16,36 @@ class TvPlayerRouteTest { "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", ).readText() + /** + * A fresh detail-page pick and a durable value seeded onto that page arrive + * as the same ordinal. Provenance has to survive navigation, or the player + * treats a restore as a new decision and pins it onto every later episode. + */ + @Test + fun playerRouteCarriesAudioPickProvenance() { + val picked = TvRoute.Player( + contentId = "movie-1", + audioTrackIndex = 1, + audioPickedThisSession = true, + ).route + val restored = TvRoute.Player( + contentId = "movie-1", + audioTrackIndex = 1, + ).route + + assertContains(picked, "audioTrackIndex=1") + assertContains(picked, "audioPicked=true") + // Same ordinal, no provenance: the route must not claim a fresh pick. + assertContains(restored, "audioTrackIndex=1") + assertFalse(restored.contains("audioPicked"), "got $restored") + } + + @Test + fun playerRoutePatternDeclaresTheAudioPickArgument() { + assertContains(TvRoute.Player.ROUTE, "audioPicked={audioPicked}") + assertEquals("audioPicked", TvRoute.Player.ARG_AUDIO_PICKED) + } + @Test fun playerRouteIncludesResumePositionWhenPresent() { val route = TvRoute.Player( @@ -55,6 +91,133 @@ class TvPlayerRouteTest { assertFalse(route.contains("resumePosition=")) } + @Test + fun sameProcessRegistryDeliversHandoffOnceToMatchingContent() { + val handoff = EpisodeSelectionHandoff( + source = EpisodeSourceIntent(resolution = "1080p", videoCodec = "h264"), + subtitle = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "en", + codecFamily = "srt", + ), + ) + val registry = registryWithNonces("abcdefghijklmnop") + val nonce = registry.register(targetContentId = "episode-123", handoff = handoff) + + val route = TvRoute.Player( + contentId = "episode-123", + episodeSelectionHandoffNonce = nonce, + ).route + + val routedNonce = route.substringAfter("episodeSelectionHandoffNonce=") + assertEquals(handoff, registry.claim(routedNonce, targetContentId = "episode-123")) + assertNull(registry.claim(routedNonce, targetContentId = "episode-123")) + } + + @Test + fun playerRouteDoesNotPersistSemanticHandoffData() { + val handoff = EpisodeSelectionHandoff( + source = EpisodeSourceIntent(resolution = "1080p", videoCodec = "h264"), + subtitle = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "en", + codecFamily = "srt", + ), + ) + val registry = registryWithNonces("abcdefghijklmnop") + val route = TvRoute.Player( + contentId = "episode-123", + episodeSelectionHandoffNonce = registry.register("episode-123", handoff), + ).route + + assertFalse(route.contains("1080p"), "semantic source intent must not enter saved routes") + assertFalse(route.contains("h264"), "codec intent must not enter saved routes") + assertFalse(route.contains("srt"), "subtitle intent must not enter saved routes") + assertFalse(route.contains("language"), "semantic field names must not enter saved routes") + assertFalse(route.contains("{"), "handoff JSON must not enter saved routes") + } + + @Test + fun playerRouteWithoutHandoffKeepsExistingDefaults() { + val route = TvRoute.Player(contentId = "episode-123").route + + assertFalse(route.contains("episodeSelectionHandoffNonce=")) + assertFalse(route.contains("fileId=")) + assertFalse(route.contains("subtitleTrackIndex=")) + } + + @Test + fun contentMismatchRejectsAndConsumesRegistryEntry() { + val registry = registryWithNonces("abcdefghijklmnop") + val nonce = registry.register("episode-123", handoff()) + + assertNull(registry.claim(nonce, targetContentId = "episode-456")) + assertNull(registry.claim(nonce, targetContentId = "episode-123")) + } + + @Test + fun registryClearModelsProcessRecreation() { + val registry = registryWithNonces("abcdefghijklmnop") + val nonce = registry.register("episode-123", handoff()) + + registry.clear() + + assertNull(registry.claim(nonce, targetContentId = "episode-123")) + } + + @Test + fun malformedAndOversizedNoncesAreIgnoredAndOmitted() { + val registry = registryWithNonces("abcdefghijklmnop") + + assertNull(registry.claim("not valid!", targetContentId = "episode-123")) + assertNull(registry.claim("a".repeat(256), targetContentId = "episode-123")) + assertFalse( + TvRoute.Player( + contentId = "episode-123", + episodeSelectionHandoffNonce = "not valid!", + ).route.contains("episodeSelectionHandoffNonce="), + ) + assertFalse( + TvRoute.Player( + contentId = "episode-123", + episodeSelectionHandoffNonce = "a".repeat(256), + ).route.contains("episodeSelectionHandoffNonce="), + ) + } + + @Test + fun registryEvictsOldestEntryAtCapacity() { + val registry = registryWithNonces( + "abcdefghijklmnop", + "bcdefghijklmnopq", + "cdefghijklmnopqr", + maxEntries = 2, + ) + val first = registry.register("episode-1", handoff()) + val second = registry.register("episode-2", handoff()) + val third = registry.register("episode-3", handoff()) + + assertNull(registry.claim(first, "episode-1")) + assertEquals(handoff(), registry.claim(second, "episode-2")) + assertEquals(handoff(), registry.claim(third, "episode-3")) + } + + @Test + fun registryExpiresEntriesAfterBoundedLifetime() { + var nowMillis = 1_000L + val registry = TvEpisodeSelectionHandoffRegistry( + maxEntries = 2, + ttlMillis = 500L, + nowMillis = { nowMillis }, + nonceFactory = { "abcdefghijklmnop" }, + ) + val nonce = registry.register("episode-123", handoff()) + + nowMillis += 500L + + assertNull(registry.claim(nonce, "episode-123")) + } + @Test fun startOverPassesExplicitZeroResumePosition() { assertTrue( @@ -62,4 +225,22 @@ class TvPlayerRouteTest { "Start Over must pass an explicit 0.0 override; null falls back to stored progress", ) } + + private fun registryWithNonces( + vararg nonces: String, + maxEntries: Int = 8, + ): TvEpisodeSelectionHandoffRegistry { + val iterator = nonces.iterator() + return TvEpisodeSelectionHandoffRegistry( + maxEntries = maxEntries, + ttlMillis = 60_000L, + nowMillis = { 1_000L }, + nonceFactory = { iterator.next() }, + ) + } + + private fun handoff() = EpisodeSelectionHandoff( + source = EpisodeSourceIntent(resolution = "1080p", videoCodec = "h264"), + subtitle = EpisodeSubtitleIntent.off(), + ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminGateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminGateTest.kt deleted file mode 100644 index d0f2051e2..000000000 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminGateTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.prairieserver.prairie.tv.ui.screens.admin - -import org.prairieserver.prairie.model.auth.User -import org.prairieserver.prairie.model.profile.Profile -import org.prairieserver.prairie.model.auth.isActingAdmin -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * The TV Settings "Admin" row reachability is the acting-admin gate, identical - * to mobile. This pins the gate the TvSettingsViewModel folds into UiState. - */ -class TvAdminGateTest { - private fun user(role: String) = User(id = 1, username = "u", email = "e@x.io", role = role) - private fun profile(primary: Boolean) = Profile(id = "p", name = "p", isPrimary = primary) - - @Test fun `admin on primary profile sees admin`() = assertTrue(isActingAdmin(user("admin"), profile(true))) - @Test fun `admin on non-primary hidden`() = assertFalse(isActingAdmin(user("admin"), profile(false))) - @Test fun `non-admin hidden`() = assertFalse(isActingAdmin(user("user"), profile(true))) - @Test fun `admin without profile visible`() = assertTrue(isActingAdmin(user("admin"), null)) -} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt new file mode 100644 index 000000000..79d6025c7 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.tv.ui.screens.auth + +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvLoginMatchCodeLayoutTest { + + @Test + fun `standard match code fits inside QR card content width`() { + val rowWidthDp = matchCodeRowWidthDp("WILLOW-GRO") + + assertTrue( + actual = rowWidthDp <= 252, + message = "Expected match-code row to fit within 252dp, but it was ${rowWidthDp}dp", + ) + } + + @Test + fun `long match code fits inside QR card content width`() { + val rowWidthDp = matchCodeRowWidthDp("PRAIRIE-SNOW") + + assertTrue( + actual = rowWidthDp <= 252, + message = "Expected long match-code row to fit within 252dp, but it was ${rowWidthDp}dp", + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt index 6f2480453..5cab81eec 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt @@ -1,6 +1,7 @@ package org.prairieserver.prairie.tv.ui.screens.calendar import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -16,12 +17,106 @@ class TvCalendarFocusRoutingTest { } @Test - fun controlsUseNormalUpMovement() { - assertFalse(shouldReturnCalendarFocusToControls(null, 2, false)) + fun weekStripMovesUpToActiveFilter() { + assertEquals( + CalendarUpFallbackAction.FocusFilter, + calendarUpFallbackAction(null, 0, false, CalendarControlFocusZone.WeekStrip), + ) } @Test fun returnInFlightDoesNotRestartChoreography() { assertFalse(shouldReturnCalendarFocusToControls(2, 2, true)) } + + @Test + fun filterMovesUpToCalendarMenuTab() { + assertEquals( + CalendarUpFallbackAction.EnterMenu, + calendarUpFallbackAction(null, 0, false, CalendarControlFocusZone.Filter), + ) + } + + @Test + fun nullControlZoneUsesNormalContentMovement() { + assertEquals( + CalendarUpFallbackAction.MoveWithinContent, + calendarUpFallbackAction(null, 0, false, null), + ) + } + + @Test + fun heldUpOnControlsDoesNotSkipALayer() { + assertEquals( + CalendarUpFallbackAction.StayInContent, + calendarUpFallbackAction( + null, + 0, + false, + CalendarControlFocusZone.WeekStrip, + isRepeat = true, + ), + ) + } + + @Test + fun heldUpBelowFirstShelfContinuesContentMovement() { + assertEquals( + CalendarUpFallbackAction.MoveWithinContent, + calendarUpFallbackAction( + focusedShelfIndex = 4, + firstFocusableShelfIndex = 2, + isReturningToControls = false, + focusedControlZone = null, + isRepeat = true, + ), + ) + } + + @Test + fun heldUpFreezesWhileTheReturnToControlsIsStillInFlight() { + // The handoff is under way but the shelf has not yet given up focus, + // so it still reports its own index. Previously this fell through to + // geometric movement and the held key walked back into the content. + assertEquals( + CalendarUpFallbackAction.StayInContent, + calendarUpFallbackAction( + focusedShelfIndex = 2, + firstFocusableShelfIndex = 2, + isReturningToControls = true, + focusedControlZone = null, + isRepeat = true, + ), + ) + } + + @Test + fun heldUpFreezesInFlightEvenFromALowerShelf() { + assertEquals( + CalendarUpFallbackAction.StayInContent, + calendarUpFallbackAction( + focusedShelfIndex = 5, + firstFocusableShelfIndex = 2, + isReturningToControls = true, + focusedControlZone = null, + isRepeat = true, + ), + ) + } + + @Test + fun aFreshUpDuringAnInFlightReturnStillMovesWithinContent() { + // Only held repeats are frozen. A deliberate new press is the viewer + // acting again, not the tail of the press that started the handoff. + assertEquals( + CalendarUpFallbackAction.MoveWithinContent, + calendarUpFallbackAction( + focusedShelfIndex = 5, + firstFocusableShelfIndex = 2, + isReturningToControls = true, + focusedControlZone = null, + isRepeat = false, + ), + ) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt new file mode 100644 index 000000000..4150ab524 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun tvMovieHeroUsesSharedDirectorCredit() { + assertTrue(screen.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun tvCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeFavoriteProbeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeFavoriteProbeTest.kt new file mode 100644 index 000000000..b089c4cb6 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvEpisodeFavoriteProbeTest.kt @@ -0,0 +1,432 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.ApiResult +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Covers the episode favourite probe window. A season used to put every + * `GET /favorites/{id}` on the wire at once and re-ask on every season load; + * one series on a tester's Fire TV produced 116 such probes at 150-520 ms each. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvEpisodeFavoriteProbeTest { + + @Test + fun probesOnlyEpisodesWithNoAnswerYet() = runTest(UnconfinedTestDispatcher()) { + val asked = mutableListOf() + + val resolved = probeEpisodeFavorites( + episodeIds = listOf("ep1", "ep2", "ep3"), + knownIds = setOf("ep1", "ep3"), + ) { id -> + asked += id + ApiResult.Success(true) + } + + assertEquals(listOf("ep2"), asked) + assertEquals(listOf("ep2" to true), resolved) + } + + @Test + fun asksNothingWhenEveryEpisodeIsAlreadyKnown() = runTest(UnconfinedTestDispatcher()) { + var called = false + + val resolved = probeEpisodeFavorites( + episodeIds = listOf("ep1", "ep2"), + knownIds = setOf("ep1", "ep2"), + ) { + called = true + ApiResult.Success(true) + } + + assertTrue(resolved.isEmpty()) + assertFalse(called, "a season whose answers are all on screen should not touch the network") + } + + @Test + fun keepsAtMostTheConfiguredNumberOfProbesInFlight() = runTest(UnconfinedTestDispatcher()) { + val inFlight = AtomicInteger() + val peak = AtomicInteger() + val release = CompletableDeferred() + + val episodes = (1..25).map { "ep$it" } + val probing = async { + probeEpisodeFavorites(episodes, knownIds = emptySet(), concurrency = 6) { + val now = inFlight.incrementAndGet() + peak.updateAndGet { seen -> maxOf(seen, now) } + release.await() + inFlight.decrementAndGet() + ApiResult.Success(false) + } + } + + // Every probe that may start has started and is parked on `release`. + assertEquals(6, peak.get(), "a 25-episode season must not open more than the permitted window") + + release.complete(Unit) + val resolved = probing.await() + + assertEquals(25, resolved.size, "every episode still gets an answer") + assertEquals( + // Value derived from the id so identity and answer stay correlated: + // a helper that returned 25 pairs all labelled ep1 would pass a + // bare size check. + episodes.associateWith { false }, + resolved.toMap(), + "each episode must get ITS OWN answer, not a duplicate of another's", + ) + assertEquals(6, peak.get(), "the window holds for the whole season, not just the first batch") + } + + /** + * One slow probe must not hold back answers that already landed. Bounding + * the requests spreads a season over several waves, so waiting for the last + * one is a longer wait than it used to be, not a shorter one. + */ + @Test + fun publishesEachAnswerAsItArrivesRatherThanWaitingForTheSlowest() = runTest(UnconfinedTestDispatcher()) { + val published = mutableListOf>() + val slow = CompletableDeferred() + + val probing = async { + probeEpisodeFavorites( + episodeIds = listOf("fast-1", "fast-2", "slow"), + knownIds = emptySet(), + onResolved = { id, favorite -> published += id to favorite }, + ) { id -> + if (id == "slow") slow.await() + ApiResult.Success(id != "slow") + } + } + + assertEquals( + listOf("fast-1" to true, "fast-2" to true), + published.toList(), + "the quick answers should already be published while one probe is still open", + ) + + slow.complete(Unit) + probing.await() + assertEquals(3, published.size) + } + + /** + * A failed probe publishes nothing, so a transient error cannot be + * mistaken for "not a favourite". + */ + @Test + fun doesNotPublishAnythingForAFailedProbe() = runTest(UnconfinedTestDispatcher()) { + val published = mutableListOf() + + probeEpisodeFavorites( + episodeIds = listOf("ok", "boom"), + knownIds = emptySet(), + onResolved = { id, _ -> published += id }, + ) { id -> + if (id == "boom") { + ApiResult.Error(code = 500, error = "server_error", message = "boom") + } else { + ApiResult.Success(true) + } + } + + assertEquals(listOf("ok"), published) + } + + /** + * Revalidation is how a favourite toggled on an episode's own screen gets + * back to the rail: the parent view model is retained, so its answer for + * that episode is stale but present. Only the changed item is re-asked + * about — re-probing the whole season on every resume is the request volume + * this window exists to prevent. + */ + @Test + fun revalidationReAsksOnlyAboutTheChangedEpisode() = runTest(UnconfinedTestDispatcher()) { + val asked = mutableListOf() + val known = setOf("ep1", "ep2", "ep3") + val changed = setOf("ep2") + + probeEpisodeFavorites( + episodeIds = listOf("ep1", "ep2", "ep3"), + knownIds = known - changed, + ) { id -> + asked += id + ApiResult.Success(true) + } + + assertEquals(listOf("ep2"), asked) + } + + @Test + fun aResumeThatChangedNothingProbesNothing() = runTest(UnconfinedTestDispatcher()) { + var called = false + + probeEpisodeFavorites( + episodeIds = listOf("ep1", "ep2", "ep3"), + knownIds = setOf("ep1", "ep2", "ep3") - emptySet(), + ) { + called = true + ApiResult.Success(true) + } + + assertFalse(called, "foregrounding the app must not re-probe a whole season") + } + + @Test + fun leavesAFailedProbeUnrecordedRatherThanCachingItAsNotFavourite() = runTest(UnconfinedTestDispatcher()) { + val resolved = probeEpisodeFavorites( + episodeIds = listOf("ok", "boom"), + knownIds = emptySet(), + ) { id -> + if (id == "boom") { + ApiResult.Error(code = 500, error = "server_error", message = "boom") + } else { + ApiResult.Success(true) + } + } + + assertEquals(listOf("ok" to true), resolved) + assertFalse( + resolved.any { it.first == "boom" }, + "a transient failure must not stick as a cached 'not a favourite'", + ) + } +} + +/** The versioned channel a child detail screen uses to tell every other screen. */ +class TvFavoriteRevalidationSessionTest { + + @Test + fun reportsChangesAfterAReadersMark() { + TvFavoriteRevalidationSession.reset() + val start = TvFavoriteRevalidationSession.currentVersion() + TvFavoriteRevalidationSession.markChanged("ep-7") + TvFavoriteRevalidationSession.markChanged("ep-9") + + assertEquals(setOf("ep-7", "ep-9"), TvFavoriteRevalidationSession.changedSince(start)) + } + + /** + * The failure that made consume-once wrong: an episode screen resuming from + * playback would swallow the marker meant for the series rail behind it. + * Every reader must see it. + */ + @Test + fun oneReaderCatchingUpDoesNotHideTheChangeFromAnother() { + TvFavoriteRevalidationSession.reset() + val episodeScreenMark = TvFavoriteRevalidationSession.currentVersion() + val seriesScreenMark = TvFavoriteRevalidationSession.currentVersion() + TvFavoriteRevalidationSession.markChanged("ep-7") + + // The episode screen resumes first and catches up. + assertEquals( + setOf("ep-7"), + TvFavoriteRevalidationSession.changedSince(episodeScreenMark), + ) + // The series rail behind it must still be told. + assertEquals( + setOf("ep-7"), + TvFavoriteRevalidationSession.changedSince(seriesScreenMark), + ) + } + + /** + * A reader advances its mark only after a successful revalidation, so a + * failed reload retries rather than losing the change forever. + */ + @Test + fun aReaderThatHasNotCaughtUpKeepsSeeingTheChange() { + TvFavoriteRevalidationSession.reset() + val mark = TvFavoriteRevalidationSession.currentVersion() + TvFavoriteRevalidationSession.markChanged("ep-7") + + assertEquals(setOf("ep-7"), TvFavoriteRevalidationSession.changedSince(mark)) + assertEquals( + setOf("ep-7"), + TvFavoriteRevalidationSession.changedSince(mark), + "reading must not clear anything", + ) + + val caughtUp = TvFavoriteRevalidationSession.currentVersion() + assertEquals(emptySet(), TvFavoriteRevalidationSession.changedSince(caughtUp)) + } + + @Test + fun ignoresABlankId() { + TvFavoriteRevalidationSession.reset() + val start = TvFavoriteRevalidationSession.currentVersion() + TvFavoriteRevalidationSession.markChanged("") + assertEquals(emptySet(), TvFavoriteRevalidationSession.changedSince(start)) + } + + @Test + fun doesNotGrowWithoutBound() { + TvFavoriteRevalidationSession.reset() + val recent = TvFavoriteRevalidationSession.currentVersion() + repeat(400) { TvFavoriteRevalidationSession.markChanged("ep-$it") } + // A reader caught up to just before the last change still gets a delta. + val nearlyCurrent = TvFavoriteRevalidationSession.currentVersion() - 1 + assertTrue((TvFavoriteRevalidationSession.changedSince(nearlyCurrent)?.size ?: 0) <= 256) + assertTrue(recent >= 0) + } + + /** + * Capping the map must not silently lose a change. A reader behind the + * evicted entries is told it cannot be given a delta, so it re-checks + * everything rather than being handed a partial answer that looks complete. + */ + @Test + fun aReaderBehindTheEvictedEntriesIsToldToRecheckEverything() { + TvFavoriteRevalidationSession.reset() + val slowReader = TvFavoriteRevalidationSession.currentVersion() + + repeat(300) { TvFavoriteRevalidationSession.markChanged("ep-$it") } + + assertNull( + TvFavoriteRevalidationSession.changedSince(slowReader), + "a delta that dropped 44 changes would look complete and hide them", + ) + } + + @Test + fun aReaderInsideTheCapStillGetsANormalDelta() { + TvFavoriteRevalidationSession.reset() + repeat(300) { TvFavoriteRevalidationSession.markChanged("old-$it") } + val caughtUp = TvFavoriteRevalidationSession.currentVersion() + + TvFavoriteRevalidationSession.markChanged("fresh") + + assertEquals(setOf("fresh"), TvFavoriteRevalidationSession.changedSince(caughtUp)) + } +} + +/** + * Whether a screen may record itself as caught up. Getting this wrong leaves a + * row permanently stale: the change is marked handled while its probe failed. + */ +class TvFavoriteRevalidationSatisfiedTest { + + @Test + fun everyRequestedVisibleIdMustAnswer() { + assertTrue( + revalidationSatisfied( + requested = setOf("ep2"), + visibleIds = listOf("ep1", "ep2", "ep3"), + answered = setOf("ep2"), + ), + ) + } + + @Test + fun aFailedProbeMeansNotCaughtUp() { + assertFalse( + revalidationSatisfied( + requested = setOf("ep2"), + visibleIds = listOf("ep1", "ep2", "ep3"), + answered = emptySet(), + ), + "advancing here would mark the change handled while the row stays stale", + ) + } + + /** + * The signal is process-wide, so it names episodes from seasons that are not + * on screen. Waiting for those would mean never catching up at all. + */ + @Test + fun idsFromAnotherSeasonDoNotBlockCatchingUp() { + assertTrue( + revalidationSatisfied( + requested = setOf("ep2", "some-other-season-ep"), + visibleIds = listOf("ep1", "ep2"), + answered = setOf("ep2"), + ), + ) + } + + @Test + fun aNullDeltaRequiresEveryVisibleEpisodeToAnswer() { + assertTrue( + revalidationSatisfied(null, listOf("ep1", "ep2"), setOf("ep1", "ep2")), + ) + assertFalse( + revalidationSatisfied(null, listOf("ep1", "ep2"), setOf("ep1")), + "a full re-check that half failed is not a full re-check", + ) + } +} + +/** + * The cache spans seasons but a refresh probes only the visible one, so a + * change to an episode of another season has to be applied by forgetting the + * cached answer — otherwise the visible season records the change as handled + * and the other season keeps showing the stale value. + */ +class TvStaleOffScreenFavoritesTest { + + @Test + fun forgetsAChangedEpisodeFromAnotherSeason() { + assertEquals( + setOf("s2e1"), + staleOffScreenFavorites( + requested = setOf("s2e1"), + cachedIds = setOf("s1e1", "s1e2", "s2e1"), + visibleIds = setOf("s1e1", "s1e2"), + ), + ) + } + + /** + * A visible one is revalidated in place instead: dropping it would render + * that row as "not a favourite" until its probe answered. + */ + @Test + fun leavesAVisibleEpisodeAlone() { + assertEquals( + emptySet(), + staleOffScreenFavorites( + requested = setOf("s1e2"), + cachedIds = setOf("s1e1", "s1e2"), + visibleIds = setOf("s1e1", "s1e2"), + ), + ) + } + + @Test + fun ignoresChangedIdsThisScreenNeverCached() { + assertEquals( + emptySet(), + staleOffScreenFavorites( + requested = setOf("never-seen"), + cachedIds = setOf("s1e1"), + visibleIds = setOf("s1e1"), + ), + ) + } + + /** + * A null change list means the signal could not say what changed, so every + * cached answer that is not on screen is suspect. + */ + @Test + fun aNullChangeListForgetsEveryOffScreenAnswer() { + assertEquals( + setOf("s2e1", "s3e1"), + staleOffScreenFavorites( + requested = null, + cachedIds = setOf("s1e1", "s2e1", "s3e1"), + visibleIds = setOf("s1e1"), + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt new file mode 100644 index 000000000..43a13f5f9 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailSubtitlePreferenceTest.kt @@ -0,0 +1,263 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.DefaultMetadataAiApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The detail row's "Auto" subtitle preview must resolve the same preferences + * playback will use. + * + * The TV settings screen writes `playback.subtitle_language` / `subtitle_mode` + * / `show_forced_subtitles` at `scope=profile` through + * [ProfileSettingsController]. The server does not mirror a canonical write + * back into the `user_profiles` columns `GET /profiles` serves, so reading + * those columns here previewed the preference from *before* the user's last + * edit while `TvVideoPlaybackStarter` — which reads WatchDetail's + * server-resolved `effective_*` fields — played the new one. Same screen, same + * item, two answers. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvItemDetailSubtitlePreferenceTest { + + @Test + fun `the auto preview reads the canonical values, not the stale profile columns`() = + runDetailTest { + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + effective = effectiveOf( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE to JsonPrimitive("ja"), + SettingKeys.PLAYBACK_SUBTITLE_MODE to JsonPrimitive("always"), + SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES to JsonPrimitive(false), + ), + ), + // What GET /profiles still serves: the pre-edit columns. + profileSubtitleLanguage = "en", + profileSubtitleMode = "off", + profileShowForced = true, + ) + awaitState(viewModel) { it.preferredSubtitleLanguage != null } + + val state = viewModel.uiState.value + assertEquals("ja", state.preferredSubtitleLanguage) + assertEquals("always", state.subtitleMode) + assertEquals(false, state.showForcedSubtitles) + } + + @Test + fun `a resolved empty language reads as no preference, not as no subtitles`() = + runDetailTest { + // The canonical snapshot spells "no preference" as ""; the Auto + // preview spells it as null and reads "" as "turn subtitles off". + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + effective = effectiveOf( + SettingKeys.PLAYBACK_SUBTITLE_MODE to JsonPrimitive("always"), + ), + ), + profileSubtitleLanguage = "de", + ) + awaitState(viewModel) { it.subtitleMode == "always" } + + assertEquals(null, viewModel.uiState.value.preferredSubtitleLanguage) + } + + @Test + fun `the profile columns stay the fallback when the contract cannot be resolved`() = + runDetailTest { + val viewModel = createViewModel( + settingsApi = FakeSettingsApi( + capabilities = SettingsCapabilitiesResult.ServerUpgradeRequired, + ), + profileSubtitleLanguage = "de", + profileSubtitleMode = "always", + profileShowForced = false, + ) + awaitState(viewModel) { it.preferredSubtitleLanguage != null } + + val state = viewModel.uiState.value + assertEquals("de", state.preferredSubtitleLanguage) + assertEquals("always", state.subtitleMode) + assertEquals(false, state.showForcedSubtitles) + } + + // ------------------------------------------------------------------ + + private val createdViewModels = mutableListOf() + + private fun runDetailTest(block: suspend () -> Unit) = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + // Cancel viewModelScope work BEFORE resetting Main; a coroutine + // still alive when the next test calls setMain throws from + // TestMainDispatcher. + createdViewModels.forEach { it.viewModelScope.cancel() } + createdViewModels.clear() + Dispatchers.resetMain() + } + } + + private fun createViewModel( + settingsApi: SettingsApi, + profileSubtitleLanguage: String? = null, + profileSubtitleMode: String? = null, + profileShowForced: Boolean? = null, + ): TvItemDetailViewModel { + val client = detailClient(profileSubtitleLanguage, profileSubtitleMode, profileShowForced) + val tokenManager = FakeTokenManager() + return TvItemDetailViewModel( + catalogRepository = CatalogRepository(CatalogApi(client)), + personalDataRepository = PersonalDataRepository(PersonalDataApi(client)), + playerSettingsStore = FakePlayerSettingsStore(), + profileRepository = ProfileRepository(ProfileApi(client), tokenManager), + profileSettings = ProfileSettingsController(SettingsRepository(settingsApi)), + metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client)), + contentId = CONTENT_ID, + tokenManager = tokenManager, + identityTransitions = org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier(), + ).also { createdViewModels += it } + } + + private suspend fun awaitState( + viewModel: TvItemDetailViewModel, + predicate: (TvItemDetailUiState) -> Boolean, + ) { + withContext(Dispatchers.IO) { + withTimeout(30_000) { + while (!predicate(viewModel.uiState.value)) { + delay(10) + } + } + } + } + + private fun effectiveOf(vararg values: Pair) = + EffectiveSettingValuesResponse( + settings = values.map { (key, value) -> + EffectiveSettingValue( + key = key, + value = value, + source = SettingScope.PROFILE.wire, + ) + }, + ) + + private class FakeSettingsApi( + private val capabilities: SettingsCapabilitiesResult = + SettingsCapabilitiesResult.Available(SettingsContractCapabilities(revision = 1)), + private val effective: EffectiveSettingValuesResponse = EffectiveSettingValuesResponse(), + ) : SettingsApi(HttpClient()) { + override suspend fun getContractCapabilities(): SettingsCapabilitiesResult = capabilities + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult = ApiResult.Success(effective) + } + + private class FakeTokenManager : TokenManager { + override val sessionExpired: SharedFlow = MutableSharedFlow() + override suspend fun getAccessToken(): String = "token" + override suspend fun getRefreshToken(): String? = null + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) = Unit + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String = PROFILE_ID + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://tv.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = "server" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit + } + + private fun detailClient( + subtitleLanguage: String?, + subtitleMode: String?, + showForced: Boolean?, + ): HttpClient = HttpClient( + MockEngine { request -> + when (request.url.encodedPath) { + "/api/v1/profiles" -> respond( + content = buildString { + append("""{"profiles":[{"id":"$PROFILE_ID","name":"Profile"""") + subtitleLanguage?.let { append(""","subtitle_language":"$it"""") } + subtitleMode?.let { append(""","subtitle_mode":"$it"""") } + showForced?.let { append(""","show_forced_subtitles":$it""") } + append("}]}") + }, + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + "/api/v1/catalog/items/$CONTENT_ID" -> respond( + content = """ + {"content_id":"$CONTENT_ID","type":"movie","title":"Detail"} + """.trimIndent(), + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + else -> respond( + content = """{"error":"not_found","message":"not found"}""", + status = HttpStatusCode.NotFound, + headers = JSON_HEADERS, + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + private companion object { + const val CONTENT_ID = "movie-1" + const val PROFILE_ID = "profile-1" + val JSON_HEADERS = headersOf(HttpHeaders.ContentType, "application/json") + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoFormattingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoFormattingTest.kt index 7ddf33687..25857d982 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoFormattingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvMediaInfoFormattingTest.kt @@ -44,4 +44,17 @@ class TvMediaInfoFormattingTest { assertEquals("English · 5.1 · DTS · Default", summary.secondary) assertTrue(summary.secondary.orEmpty().contains("5.1")) } + + @Test + fun subtitleSummaryUsesTheSharedAccessibilityClassifier() { + val closedCaptions = tvMediaInfoSubtitleSummary( + SubtitleTrack(index = 1, codec = "srt", language = "en", title = "English CC"), + ) + val unrelatedHearingText = tvMediaInfoSubtitleSummary( + SubtitleTrack(index = 2, codec = "srt", language = "en", title = "Hearing Aid Commentary"), + ) + + assertTrue(closedCaptions.secondary.orEmpty().contains("SDH")) + assertFalse(unrelatedHearingText.secondary.orEmpty().contains("SDH")) + } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt new file mode 100644 index 000000000..03655d4a6 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt @@ -0,0 +1,811 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.prairieserver.prairie.domain.settings.ProfileSettingsController +import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.CatalogApi +import org.prairieserver.prairie.network.api.DefaultMetadataAiApi +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.playback.audioTrackFingerprint +import org.prairieserver.prairie.playback.subtitleTrackFingerprint +import org.prairieserver.prairie.repository.CatalogRepository +import org.prairieserver.prairie.repository.MetadataAiRepository +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.ProfileRepository +import org.prairieserver.prairie.repository.SettingsRepository +import org.prairieserver.prairie.repository.port.LocalTrackSelection +import org.prairieserver.prairie.repository.port.OutboxHandle +import org.prairieserver.prairie.repository.port.UserItemStatePort +import org.prairieserver.prairie.repository.port.WriteOutcome +import org.prairieserver.prairie.tv.testing.FakePlayerSettingsStore +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Exercises the real detail ViewModel refresh path: series -> seasons -> episodes + * -> asynchronous target detail, including session and durable track merging. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TvNextUpSelectionHandoffTest { + + @Test + fun absentVersionCodecAndContainerDecodeAsNull() = runDetailTest { + val scenario = Scenario( + suffix = "-null-version-metadata", + oldVersions = listOf(version(101, "1080p", codec = null, container = null)), + ) + val fixture = createFixture(scenario) + + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + + val version = fixture.viewModel.uiState.value.nextUpPlaybackDetail?.versions?.single() + assertNull(version?.codecVideo) + assertNull(version?.container) + } + + @Test + fun changingNextUpResolvesOldSourceAgainstNewEpisodeFiles() = runDetailTest { + val scenario = Scenario( + suffix = "-source", + oldVersions = listOf(version(101, "720p"), version(102, "1080p", codec = "hevc")), + newVersions = listOf(version(201, "480p"), version(202, "1080p", codec = "hevc")), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpVersionSelected(102) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertEquals(202, fixture.viewModel.uiState.value.selectedNextUpFileId) + } + + @Test + fun changingNextUpResolvesSubtitleAtDifferentCombinedIndex() = runDetailTest { + val french = subtitle(index = 4, language = "fre", external = true, title = "French") + val scenario = Scenario( + suffix = "-subtitle-index", + oldVersions = listOf( + version( + 101, + "1080p", + subtitles = listOf( + subtitle(index = 9, language = "eng"), + french, + ), + ), + ), + newVersions = listOf( + version( + 201, + "1080p", + subtitles = listOf( + subtitle(index = 2, language = "spa", external = true), + subtitle(index = 8, language = "eng"), + french.copy(index = 7), + ), + ), + ), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + // Old combined index 0 is the sole external track. On the target, + // French is the second external track and therefore combined index 1. + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertEquals(1, fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun lastPlayedAutoSourceCapturesAndResolvesSubtitleAgainstTheDisplayedVersions() = runDetailTest { + val scenario = Scenario( + suffix = "-auto-source-subtitle", + oldVersions = listOf( + version(101, "1080p", subtitles = listOf(subtitle(1, "eng"))), + version(102, "720p", subtitles = listOf(subtitle(2, "fre", external = true))), + ), + newVersions = listOf( + version(201, "1080p", subtitles = listOf(subtitle(3, "eng"))), + version( + 202, + "720p", + subtitles = listOf( + subtitle(4, "spa", external = true), + subtitle(5, "fre", external = true), + ), + ), + ), + oldLastFileId = 102, + newLastFileId = 202, + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertNull(fixture.viewModel.uiState.value.selectedNextUpFileId) + assertEquals(1, fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun preferredQualityAutoSourceCapturesAndResolvesSubtitleAgainstTheDisplayedVersions() = runDetailTest { + val scenario = Scenario( + suffix = "-preferred-quality-subtitle", + oldVersions = listOf( + version(101, "1080p", subtitles = listOf(subtitle(1, "eng"))), + version(102, "720p", subtitles = listOf(subtitle(2, "fre", external = true))), + ), + newVersions = listOf( + version(201, "1080p", subtitles = listOf(subtitle(3, "eng"))), + version( + 202, + "720p", + subtitles = listOf( + subtitle(4, "spa", external = true), + subtitle(5, "fre", external = true), + ), + ), + ), + preferredQuality = "720p", + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertNull(fixture.viewModel.uiState.value.selectedNextUpFileId) + assertEquals(1, fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun explicitOffRemainsOffAcrossNextUpRefresh() = runDetailTest { + val scenario = Scenario(suffix = "-off") + TvDetailTrackSelectionSession.remember( + scenario.episodeTwoId, + fileId = 201, + audio = null, + subtitle = 0, + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertEquals(-1, fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun missingExplicitSubtitleUsesAutoAndDoesNotRestoreTargetDurableSubtitle() = runDetailTest { + val oldFrench = subtitle(index = 4, language = "fre", external = true, title = "French") + val targetEnglish = subtitle(index = 8, language = "eng", title = "English") + val targetAudio = listOf( + AudioTrack(index = 3, codec = "aac", language = "eng"), + AudioTrack(index = 7, codec = "ac3", language = "jpn"), + ) + val scenario = Scenario( + suffix = "-missing-subtitle", + oldVersions = listOf(version(101, "1080p", subtitles = listOf(oldFrench))), + newVersions = listOf( + version(201, "1080p", subtitles = listOf(targetEnglish), audio = targetAudio), + ), + ) + val fixture = createFixture(scenario) + fixture.userState.saved[scenario.episodeTwoId to 201] = LocalTrackSelection( + audioFingerprint = audioTrackFingerprint(targetAudio[1]), + subtitleFingerprint = subtitleTrackFingerprint(targetEnglish), + ) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + val state = fixture.viewModel.uiState.value + assertNull(state.selectedNextUpSubtitleIndex) + assertEquals(1, state.selectedNextUpAudioIndex, "durable audio behavior stays unchanged") + } + + @Test + fun autoAllowsExistingTargetDurableSubtitleRestore() = runDetailTest { + val targetEnglish = subtitle(index = 8, language = "eng", title = "English") + val scenario = Scenario( + suffix = "-auto-durable", + newVersions = listOf(version(201, "1080p", subtitles = listOf(targetEnglish))), + ) + val fixture = createFixture(scenario) + fixture.userState.saved[scenario.episodeTwoId to 201] = LocalTrackSelection( + audioFingerprint = null, + subtitleFingerprint = subtitleTrackFingerprint(targetEnglish), + ) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertEquals(0, fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun staleRefreshCompletionCannotApplyHandoffToAnotherEpisode() = runDetailTest { + val scenario = Scenario(suffix = "-stale") + scenario.episodeOneWatchGate = CompletableDeferred() + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + val staleGate = CompletableDeferred() + val freshGate = CompletableDeferred() + scenario.episodeOneResponses.addLast( + DetailResponse(staleGate, itemDetailJson(scenario.episodeOneId, listOf(version(801, "720p")))), + ) + scenario.episodeOneResponses.addLast( + DetailResponse(freshGate, itemDetailJson(scenario.episodeOneId, listOf(version(901, "2160p")))), + ) + scenario.episodeOneDefaultVersions = listOf(version(901, "2160p")) + + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, false) + awaitCondition { scenario.pendingEpisodeOneResponses.get() == 1 } + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitEpisode(fixture.viewModel, scenario.episodeTwoId) + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, false) + staleGate.complete(Unit) + awaitCondition { scenario.pendingEpisodeOneResponses.get() == 0 } + delay(20) + + assertNotEquals( + 801, + fixture.viewModel.uiState.value.nextUpPlaybackDetail?.versions?.singleOrNull()?.fileId, + ) + freshGate.complete(Unit) + awaitCondition { + fixture.viewModel.uiState.value.nextUpPlaybackDetail?.versions?.singleOrNull()?.fileId == 901 + } + } + + @Test + fun carriedSelectionIsNotPersistedBeforeExplicitUserInput() = runDetailTest { + val scenario = Scenario( + suffix = "-no-persist", + oldVersions = listOf(version(101, "1080p", subtitles = listOf(subtitle(4, "fre", external = true)))), + newVersions = listOf(version(201, "1080p", subtitles = listOf(subtitle(7, "fre", external = true)))), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpVersionSelected(101) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + awaitCondition { fixture.userState.writes.isNotEmpty() } + fixture.userState.writes.clear() + + advanceToEpisodeTwo(fixture.viewModel, scenario) + + assertTrue(fixture.userState.writes.none { it.contentId == scenario.episodeTwoId }) + assertNull(TvDetailTrackSelectionSession.recall(scenario.episodeTwoId)) + } + + @Test + fun profileOrServerChangeClearsPendingNextUpHandoff() = runDetailTest { + for (kind in listOf(IdentityTransitionKind.PROFILE_SWITCH, IdentityTransitionKind.SERVER_SWITCH)) { + val scenario = Scenario(suffix = "-${kind.name.lowercase()}") + val gate = CompletableDeferred() + scenario.episodeTwoGate = gate + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpVersionSelected(101) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitCondition { scenario.episodeTwoRequests.get() > 0 } + fixture.identityTransitions.changing(kind) { + when (kind) { + IdentityTransitionKind.PROFILE_SWITCH -> fixture.tokenManager.profileId = "profile-2" + IdentityTransitionKind.SERVER_SWITCH -> fixture.tokenManager.serverId = "server-2" + else -> error("unexpected kind") + } + } + gate.complete(Unit) + awaitCondition { + val state = fixture.viewModel.uiState.value + state.nextUpEpisode?.contentId == scenario.episodeTwoId && + state.didLoadNextUpPlaybackDetail && + !state.isLoadingNextUpPlaybackDetail + } + + val state = fixture.viewModel.uiState.value + assertNull(state.selectedNextUpFileId) + assertNull(state.selectedNextUpSubtitleIndex) + } + } + + @Test + fun explicitSelectorInputBeforePendingInstallWins() = runDetailTest { + val french = subtitle(index = 4, language = "fre", external = true) + val scenario = Scenario( + suffix = "-selector-before-install", + oldVersions = listOf( + version(101, "720p"), + version(102, "1080p", codec = "hevc", subtitles = listOf(french)), + ), + newVersions = listOf( + version(201, "720p"), + version(202, "1080p", codec = "hevc", subtitles = listOf(french.copy(index = 7))), + ), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpVersionSelected(102) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + val identityGate = CompletableDeferred() + val previousSnapshotCalls = fixture.tokenManager.snapshotCalls.get() + fixture.tokenManager.snapshotResponses.addLast( + SnapshotResponse(identityGate, fixture.tokenManager.currentScope()), + ) + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitCondition { fixture.tokenManager.snapshotCalls.get() > previousSnapshotCalls } + + fixture.viewModel.onNextUpVersionSelected(201) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + identityGate.complete(Unit) + awaitEpisode(fixture.viewModel, scenario.episodeTwoId) + + val state = fixture.viewModel.uiState.value + assertEquals(201, state.selectedNextUpFileId) + assertEquals(-1, state.selectedNextUpSubtitleIndex) + } + + @Test + fun explicitSelectorInputDuringDurableResolutionWins() = runDetailTest { + val french = subtitle(index = 4, language = "fre", external = true) + val scenario = Scenario( + suffix = "-selector-during-durable", + oldVersions = listOf( + version(101, "720p"), + version(102, "1080p", codec = "hevc", subtitles = listOf(french)), + ), + newVersions = listOf( + version(201, "720p"), + version(202, "1080p", codec = "hevc", subtitles = listOf(french.copy(index = 7))), + ), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpVersionSelected(102) + fixture.viewModel.onNextUpSubtitleTrackSelected(0) + + val durableGate = CompletableDeferred() + val targetKey = scenario.episodeTwoId to 202 + fixture.userState.readGates[targetKey] = durableGate + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitCondition { targetKey in fixture.userState.startedReads } + + fixture.viewModel.onNextUpVersionSelected(201) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + durableGate.complete(Unit) + awaitEpisode(fixture.viewModel, scenario.episodeTwoId) + + val state = fixture.viewModel.uiState.value + assertEquals(201, state.selectedNextUpFileId) + assertEquals(-1, state.selectedNextUpSubtitleIndex) + } + + @Test + fun nullAuthScopeFailsClosedBeforeHandoffInstall() = runDetailTest { + val scenario = Scenario(suffix = "-null-scope") + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + fixture.tokenManager.snapshotResponses.addLast(SnapshotResponse(gate = null, scope = null)) + + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitCondition { + val state = fixture.viewModel.uiState.value + state.nextUpEpisode?.contentId == scenario.episodeTwoId && + state.didLoadNextUpPlaybackDetail && + !state.isLoadingNextUpPlaybackDetail + } + + assertEquals(0, scenario.episodeTwoRequests.get()) + assertNull(fixture.viewModel.uiState.value.selectedNextUpSubtitleIndex) + } + + @Test + fun nullProfileSnapshotIsNotFilledFromSeparateTokenRead() = runDetailTest { + val scenario = Scenario(suffix = "-null-profile") + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + fixture.viewModel.onNextUpSubtitleTrackSelected(-1) + fixture.tokenManager.snapshotResponses.addLast( + SnapshotResponse(gate = null, scope = fixture.tokenManager.currentScope(profileId = null)), + ) + + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitCondition { + val state = fixture.viewModel.uiState.value + state.nextUpEpisode?.contentId == scenario.episodeTwoId && + state.didLoadNextUpPlaybackDetail && + !state.isLoadingNextUpPlaybackDetail + } + + val state = fixture.viewModel.uiState.value + assertNull(state.nextUpPlaybackDetail) + assertNull(state.selectedNextUpSubtitleIndex) + } + + @Test + fun lateDurableSeedForPriorEpisodeCannotRememberCarriedTarget() = runDetailTest { + val scenario = Scenario( + suffix = "-late-seed", + oldVersions = listOf(version(101, "1080p")), + newVersions = listOf(version(201, "1080p")), + ) + val fixture = createFixture(scenario) + awaitEpisode(fixture.viewModel, scenario.episodeOneId) + val oldKey = scenario.episodeOneId to 101 + fixture.userState.saved[oldKey] = LocalTrackSelection( + audioFingerprint = null, + subtitleFingerprint = null, + ) + val seedGate = CompletableDeferred() + fixture.userState.readGates[oldKey] = seedGate + + fixture.viewModel.onNextUpVersionSelected(101) + awaitCondition { oldKey in fixture.userState.startedReads } + fixture.viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitEpisode(fixture.viewModel, scenario.episodeTwoId) + seedGate.complete(Unit) + delay(20) + + assertNull(TvDetailTrackSelectionSession.recall(scenario.episodeTwoId)) + } + + // ------------------------------------------------------------------ + + private val createdViewModels = mutableListOf() + + private fun runDetailTest(block: suspend () -> Unit) = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + try { + block() + } finally { + createdViewModels.forEach { viewModel -> + viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() + } + createdViewModels.clear() + Dispatchers.resetMain() + } + } + + private fun createFixture(scenario: Scenario): Fixture { + val identityTransitions = DefaultIdentityTransitionBarrier() + val tokenManager = FakeTokenManager(identityTransitions) + val client = scenario.client() + val userState = RecordingUserItemState() + val catalogRepository = CatalogRepository( + catalogApi = CatalogApi(client), + identityTransitions = identityTransitions, + ) + val personalDataRepository = PersonalDataRepository( + personalDataApi = PersonalDataApi(client), + userItemStatePort = userState, + identityTransitions = identityTransitions, + ) + val profileRepository = ProfileRepository( + profileApi = ProfileApi(client), + tokenManager = tokenManager, + identityTransitions = identityTransitions, + ) + val viewModel = TvItemDetailViewModel( + catalogRepository = catalogRepository, + personalDataRepository = personalDataRepository, + playerSettingsStore = FakePlayerSettingsStore().apply { + preferredQualityFlow.value = scenario.preferredQuality + }, + profileRepository = profileRepository, + profileSettings = ProfileSettingsController(SettingsRepository(UnavailableSettingsApi())), + metadataAiRepository = MetadataAiRepository(DefaultMetadataAiApi(client)), + contentId = scenario.seriesId, + userItemState = userState, + tokenManager = tokenManager, + identityTransitions = identityTransitions, + ).also { createdViewModels += it } + return Fixture(viewModel, userState, tokenManager, identityTransitions) + } + + private suspend fun advanceToEpisodeTwo(viewModel: TvItemDetailViewModel, scenario: Scenario) { + viewModel.onSetEpisodeWatched(scenario.episodeOneId, true) + awaitEpisode(viewModel, scenario.episodeTwoId) + } + + private suspend fun awaitEpisode(viewModel: TvItemDetailViewModel, contentId: String) { + awaitCondition { + val state = viewModel.uiState.value + state.nextUpEpisode?.contentId == contentId && + state.nextUpPlaybackDetail?.contentId == contentId && + state.didLoadNextUpPlaybackDetail + } + } + + private suspend fun awaitCondition(predicate: () -> Boolean) { + withContext(Dispatchers.IO) { + withTimeout(30_000) { + while (!predicate()) delay(10) + } + } + } + + private data class Fixture( + val viewModel: TvItemDetailViewModel, + val userState: RecordingUserItemState, + val tokenManager: FakeTokenManager, + val identityTransitions: DefaultIdentityTransitionBarrier, + ) + + private class Scenario( + val suffix: String = "", + val oldVersions: List = listOf(version(101, "1080p")), + val newVersions: List = listOf(version(201, "1080p")), + val oldLastFileId: Int? = null, + val newLastFileId: Int? = null, + val preferredQuality: String = "auto", + ) { + val seriesId = "series$suffix" + val episodeOneId = "episode-1$suffix" + val episodeTwoId = "episode-2$suffix" + var episodeOneWatched = false + var episodeTwoWatched = false + var episodeTwoGate: CompletableDeferred? = null + var episodeOneWatchGate: CompletableDeferred? = null + val episodeTwoRequests = AtomicInteger() + val pendingEpisodeOneResponses = AtomicInteger() + var episodeOneDefaultVersions = oldVersions + val episodeOneResponses = ConcurrentLinkedDeque() + + fun client(): HttpClient = HttpClient( + MockEngine { request -> + fun json(content: String) = respond( + content = content, + status = HttpStatusCode.OK, + headers = JSON_HEADERS, + ) + when (request.url.encodedPath) { + "/api/v1/catalog/items/$seriesId" -> json( + """{"content_id":"$seriesId","type":"series","title":"Series"}""", + ) + "/api/v1/catalog/series/$seriesId/seasons" -> json( + """{"seasons":[{"content_id":"season$suffix","season_number":1,"title":"Season 1"}]}""", + ) + "/api/v1/catalog/series/$seriesId/seasons/1/episodes" -> json(episodesJson()) + "/api/v1/catalog/items/$episodeOneId" -> { + val queued = episodeOneResponses.pollFirst() + if (queued == null) { + json(itemDetailJson(episodeOneId, episodeOneDefaultVersions, oldLastFileId)) + } else { + pendingEpisodeOneResponses.set(episodeOneResponses.size) + queued.gate?.await() + json(queued.json) + } + } + "/api/v1/catalog/items/$episodeTwoId" -> { + episodeTwoRequests.incrementAndGet() + episodeTwoGate?.await() + json(itemDetailJson(episodeTwoId, newVersions, newLastFileId)) + } + "/api/v1/watched/$episodeOneId" -> { + episodeOneWatchGate?.await() + episodeOneWatched = request.method.value != "DELETE" + respond("", HttpStatusCode.NoContent) + } + "/api/v1/watched/$episodeTwoId" -> { + episodeTwoWatched = request.method.value != "DELETE" + respond("", HttpStatusCode.NoContent) + } + "/api/v1/profiles" -> json( + """{"profiles":[{"id":"profile-1","name":"Profile"}]}""", + ) + else -> respond( + content = """{"error":"not_found","message":"not found"}""", + status = HttpStatusCode.NotFound, + headers = JSON_HEADERS, + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + + private fun episodesJson(): String = + """{"episodes":[ + {"content_id":"$episodeOneId","season_number":1,"episode_number":1,"title":"One","user_data":{"played":$episodeOneWatched}}, + {"content_id":"$episodeTwoId","season_number":1,"episode_number":2,"title":"Two","user_data":{"played":$episodeTwoWatched}} + ]}""".trimIndent() + } + + private class RecordingUserItemState : UserItemStatePort { + data class Write(val contentId: String, val fileId: Int, val kind: String, val fingerprint: String?) + + val saved = ConcurrentHashMap, LocalTrackSelection>() + val writes = CopyOnWriteArrayList() + val readGates = ConcurrentHashMap, CompletableDeferred>() + val startedReads: MutableSet> = ConcurrentHashMap.newKeySet() + + override suspend fun recordWatched(contentId: String, watched: Boolean) = OutboxHandle.NONE + override suspend fun recordFavorite(contentId: String, favorite: Boolean) = OutboxHandle.NONE + override suspend fun recordRating(contentId: String, rating: Int?) = OutboxHandle.NONE + override suspend fun resolve(handle: OutboxHandle, outcome: WriteOutcome) = Unit + + override suspend fun recordAudioTrackSelection( + contentId: String, + fileId: Int, + audioFingerprint: String?, + ) { + writes += Write(contentId, fileId, "audio", audioFingerprint) + } + + override suspend fun recordSubtitleTrackSelection( + contentId: String, + fileId: Int, + subtitleFingerprint: String?, + ) { + writes += Write(contentId, fileId, "subtitle", subtitleFingerprint) + } + + override suspend fun localTrackSelection(contentId: String, fileId: Int): LocalTrackSelection? { + val key = contentId to fileId + startedReads += key + readGates[key]?.await() + return saved[key] + } + } + + private class FakeTokenManager( + private val identityTransitions: IdentityTransitionBarrier, + ) : TokenManager { + var serverId = "server-1" + var profileId = "profile-1" + val snapshotCalls = AtomicInteger() + val snapshotResponses = ConcurrentLinkedDeque() + + override val sessionExpired: SharedFlow = MutableSharedFlow() + override suspend fun getAccessToken(): String = "token" + override suspend fun getRefreshToken(): String? = null + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) = Unit + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String = profileId + override suspend fun setProfileId(profileId: String?) { + this.profileId = profileId.orEmpty() + } + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://tv.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String = serverId + override suspend fun switchActiveServer(serverId: String?) { + this.serverId = serverId.orEmpty() + } + override suspend fun signOutCurrentServer() = Unit + fun currentScope(profileId: String? = this.profileId) = AuthScopeSnapshot( + serverId = serverId, + profileId = profileId, + serverUrl = "https://tv.example", + profileToken = null, + identityGeneration = identityTransitions.generation.value, + ) + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? { + snapshotCalls.incrementAndGet() + val queued = snapshotResponses.pollFirst() + queued?.gate?.await() + return if (queued != null) queued.scope else currentScope() + } + } + + private class UnavailableSettingsApi : SettingsApi(HttpClient()) { + override suspend fun getContractCapabilities(): SettingsCapabilitiesResult = + SettingsCapabilitiesResult.ServerUpgradeRequired + } + + private data class DetailResponse(val gate: CompletableDeferred?, val json: String) + private data class SnapshotResponse( + val gate: CompletableDeferred?, + val scope: AuthScopeSnapshot?, + ) + + private data class VersionFixture( + val fileId: Int, + val resolution: String, + val codec: String?, + val container: String?, + val subtitles: List, + val audio: List, + ) + + private companion object { + val JSON_HEADERS = headersOf(HttpHeaders.ContentType, "application/json") + + fun version( + fileId: Int, + resolution: String, + codec: String? = "h264", + container: String? = "mkv", + subtitles: List = emptyList(), + audio: List = emptyList(), + ) = VersionFixture(fileId, resolution, codec, container, subtitles, audio) + + fun subtitle( + index: Int, + language: String, + external: Boolean = false, + title: String? = null, + ) = SubtitleTrack( + index = index, + codec = "srt", + language = language, + title = title, + external = external, + ) + + fun itemDetailJson( + contentId: String, + versions: List, + lastFileId: Int? = null, + ): String = + """{"content_id":"$contentId","type":"episode","title":"Episode","user_data":{"last_file_id":$lastFileId},"versions":[${versions.joinToString(",", transform = ::versionJson)}]}""" + + private fun jsonString(value: String?): String = value?.let { "\"$it\"" } ?: "null" + + private fun versionJson(version: VersionFixture): String = + """{"file_id":${version.fileId},"resolution":"${version.resolution}","codec_video":${jsonString(version.codec)},"container":${jsonString(version.container)},"subtitle_tracks":[${version.subtitles.joinToString(",", transform = ::subtitleJson)}],"audio_tracks":[${version.audio.joinToString(",", transform = ::audioJson)}]}""" + + private fun subtitleJson(track: SubtitleTrack): String = + """{"index":${track.index},"codec":"${track.codec}","language":"${track.language}","title":${track.title?.let { "\"$it\"" } ?: "null"},"forced":${track.forced},"external":${track.external}}""" + + private fun audioJson(track: AudioTrack): String = + """{"index":${track.index},"codec":"${track.codec}","language":"${track.language}"}""" + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt index d7ec7fb6d..b23ca9ec4 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt @@ -22,17 +22,188 @@ class TvPlaybackFormattingTest { assertFalse(isAudioSelectorOptionSelected(0, 1)) } - @Test fun singleChoiceSelectorIsStatic() { + @Test fun selectorNeedsMoreThanOneRealChoice() { assertFalse(selectorIsInteractive(0)) assertFalse(selectorIsInteractive(1)) assertTrue(selectorIsInteractive(2)) } + /** + * The rule counts REAL choices, so the pseudo-entries the menus prepend do + * not make a single-track file interactive. A lone subtitle track assembles + * three menu rows (Auto · Off · the track) but is still one choice, and the + * old enabled-row count read that as a dropdown worth opening — the bug this + * replaced. Apple applies the same `shouldEnableSubtitleSelector` rule. + */ + @Test fun pseudoEntriesDoNotMakeASingleTrackInteractive() { + val subtitleTracksOnAOneTrackFile = 1 + + assertFalse(selectorIsInteractive(subtitleTracksOnAOneTrackFile)) + } + @Test fun automaticNoTrackCopyMatchesTvOs() { assertEquals("Auto - None", automaticTrackLabel(null)) assertEquals("Auto - English", automaticTrackLabel("English")) } + // --- catalog audio, keyed by ordinal --- + + /** + * Audio is addressed by ORDINAL. The server sends no `index` for audio + * tracks (subtitles get one), so [AudioTrack.index] is its `0` default on + * every row: keying on it collapsed both tracks of a two-track file onto + * the first, and the picker rendered the Dutch track with the English label. + */ + @Test fun audioSummaryForOrdinal_distinguishesTracksThatShareTheDefaultIndex() { + val version = fileVersion( + audio = listOf( + AudioTrack(language = "eng", codec = "dts", channels = 6), + AudioTrack(language = "nld", codec = "aac", channels = 2), + ), + ) + assertEquals(0, version.audioTracks!![0].index, "the wire carries no audio index") + assertEquals(0, version.audioTracks!![1].index) + + val first = TvPlaybackFormatting.audioSummaryForOrdinal(version, 0) + val second = TvPlaybackFormatting.audioSummaryForOrdinal(version, 1) + + assertTrue(first != null && first.contains("English"), "got $first") + assertTrue(second != null && second.contains("Dutch"), "got $second") + assertTrue(first != second, "identical indices must not collapse the rows") + } + + @Test fun audioSummaryForOrdinal_nullWhenUnresolvable() { + val version = fileVersion(audio = listOf(AudioTrack(language = "eng"))) + assertEquals(null, TvPlaybackFormatting.audioSummaryForOrdinal(version, null)) + assertEquals(null, TvPlaybackFormatting.audioSummaryForOrdinal(version, 1)) + assertEquals(null, TvPlaybackFormatting.audioSummaryForOrdinal(null, 0)) + assertEquals(null, TvPlaybackFormatting.audioSummaryForOrdinal(fileVersion(), 0)) + } + + @Test fun effectiveAudioOrdinal_prefersPlanThenServerEffectiveThenDefault() { + val tracks = listOf( + AudioTrack(language = "eng"), + AudioTrack(language = "nld", isDefault = true), + ) + + assertEquals(0, TvPlaybackFormatting.effectiveAudioOrdinal(tracks, planOrdinal = 0)) + // Out of range must not be echoed back. + assertEquals(1, TvPlaybackFormatting.effectiveAudioOrdinal(tracks, planOrdinal = 9)) + assertEquals( + 0, + TvPlaybackFormatting.effectiveAudioOrdinal( + tracks, + planOrdinal = null, + version = fileVersion(audio = tracks, effectiveAudioIndex = 0), + ), + "the server's effective ordinal outranks the default flag", + ) + assertEquals(1, TvPlaybackFormatting.effectiveAudioOrdinal(tracks, planOrdinal = null)) + assertEquals(null, TvPlaybackFormatting.effectiveAudioOrdinal(emptyList(), 0)) + } + + /** Title is often all that separates two otherwise identical mixes. */ + @Test fun audioChoiceLabelForOrdinal_keepsTitleAndDefault() { + val tracks = listOf( + AudioTrack(language = "eng", codec = "aac", channels = 2, title = "Main", isDefault = true), + AudioTrack(language = "eng", codec = "aac", channels = 2, title = "Director Commentary"), + ) + + val main = TvPlaybackFormatting.audioChoiceLabelForOrdinal(tracks, 0) + val commentary = TvPlaybackFormatting.audioChoiceLabelForOrdinal(tracks, 1) + + assertTrue(main != commentary, "identical summaries must stay distinguishable: $main / $commentary") + assertTrue(commentary!!.contains("Director Commentary"), "got $commentary") + assertTrue(main!!.contains("Default"), "got $main") + assertEquals(null, TvPlaybackFormatting.audioChoiceLabelForOrdinal(tracks, 2)) + } + + // --- versionPickerLabels --- + + @Test fun versionPickerLabels_leaveDistinctLabelsAlone() { + val versions = listOf( + fileVersion(fileId = 1, resolution = "1080p"), + fileVersion(fileId = 2, resolution = "2160p", hdr = true), + ) + assertEquals(listOf("1080P", "4K · HDR"), TvPlaybackFormatting.versionPickerLabels(versions)) + } + + @Test fun versionPickerLabels_carryCodecsLikeTvOs() { + // Two files that differ only by codec are told apart by the base label + // itself (resolution · video codec · DR · audio codec, as on tvOS). + val versions = listOf( + fileVersion(fileId = 1, resolution = "2160p", codecVideo = "hevc", codecAudio = "truehd", hdr = true), + fileVersion(fileId = 2, resolution = "2160p", codecVideo = "av1", codecAudio = "eac3", hdr = true), + ) + assertEquals( + listOf("4K · HEVC · HDR · TrueHD", "4K · AV1 · HDR · EAC3"), + TvPlaybackFormatting.versionPickerLabels(versions), + ) + } + + @Test fun versionPickerLabels_disambiguateCollidingLabelsBySize() { + // The device case: one title, two 4K DV files, two identical rows. + val dv = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 7")) + val versions = listOf( + fileVersion(fileId = 1, resolution = "1080p"), + fileVersion(fileId = 2, resolution = "2160p", hdr = true, video = dv, fileSize = 62_000_000_000), + fileVersion(fileId = 3, resolution = "2160p", hdr = true, video = dv, fileSize = 18_000_000_000), + ) + + val labels = TvPlaybackFormatting.versionPickerLabels(versions) + + assertEquals("1080P", labels[0]) + assertEquals(labels.distinct().size, labels.size, "colliding rows must be distinguishable") + assertTrue(labels[1].startsWith("4K · HEVC · DV · "), "got ${labels[1]}") + assertTrue(labels[2].startsWith("4K · HEVC · DV · "), "got ${labels[2]}") + } + + @Test fun versionPickerLabels_fallBackToContainerWhenSizesMatch() { + val dv = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 7")) + val versions = listOf( + fileVersion(fileId = 1, resolution = "2160p", hdr = true, video = dv, container = "mkv", fileSize = 42), + fileVersion(fileId = 2, resolution = "2160p", hdr = true, video = dv, container = "mp4", fileSize = 42), + ) + + val labels = TvPlaybackFormatting.versionPickerLabels(versions) + + assertEquals(labels.distinct().size, labels.size) + assertTrue(labels.any { it.endsWith("MP4") }, "got $labels") + } + + /** + * Every codec and every size is shared here — the codec pairs collide on + * the base label and the sizes collide within each pair — so the size + * suffix must be applied per colliding group rather than given up on. + */ + @Test fun versionPickerLabels_widenUntilTheGroupIsActuallySeparated() { + val dv = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 7")) + fun v(id: Int, codec: String, size: Long) = + fileVersion(fileId = id, resolution = "2160p", hdr = true, video = dv, codecVideo = codec, fileSize = size) + val versions = listOf( + v(1, "hevc", 20_000_000_000), + v(2, "av1", 20_000_000_000), + v(3, "hevc", 40_000_000_000), + v(4, "av1", 40_000_000_000), + ) + + val labels = TvPlaybackFormatting.versionPickerLabels(versions) + + assertEquals(4, labels.distinct().size, "every version must be distinguishable; got $labels") + assertTrue(labels.all { it.startsWith("4K · HEVC · DV · ") || it.startsWith("4K · AV1 · DV · ") }, "got $labels") + } + + @Test fun versionPickerLabels_indistinguishableVersionsStayEqual() { + // Nothing to say them apart with: better a duplicate label than a + // fabricated difference. Selection still works on fileId. + val dv = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 7")) + val versions = listOf( + fileVersion(fileId = 1, resolution = "2160p", hdr = true, video = dv), + fileVersion(fileId = 2, resolution = "2160p", hdr = true, video = dv), + ) + assertEquals(listOf("4K · HEVC · DV", "4K · HEVC · DV"), TvPlaybackFormatting.versionPickerLabels(versions)) + } + // --- versionShortLabel --- @Test fun versionShortLabel_4kHdr() { @@ -46,7 +217,27 @@ class TvPlaybackFormattingTest { hdr = true, video = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 7")), ) - assertEquals("4K · DV", TvPlaybackFormatting.versionShortLabel(v)) + assertEquals("4K · HEVC · DV", TvPlaybackFormatting.versionShortLabel(v)) + } + + @Test fun versionShortLabel_includesAudioCodecLikeTvOs() { + val v = fileVersion( + resolution = "2160p", + codecVideo = "hevc", + hdr = true, + video = listOf(VideoTrack(codec = "hevc", dolbyVision = "Profile 8")), + audio = listOf( + audioTrack(codec = "aac", channels = 2), + audioTrack(codec = "truehd", layout = "7.1", default = true), + ), + ) + // Audio codec is the Auto-resolved (default) track's, not the first. + assertEquals("4K · HEVC · DV · TrueHD", TvPlaybackFormatting.versionShortLabel(v)) + } + + @Test fun versionShortLabel_fallsBackToVersionAudioCodec() { + val v = fileVersion(resolution = "1080p", codecVideo = "h264", codecAudio = "eac3") + assertEquals("1080P · H.264 · EAC3", TvPlaybackFormatting.versionShortLabel(v)) } @Test fun versionShortLabel_1080() { @@ -384,6 +575,11 @@ class TvPlaybackFormattingTest { assertEquals("English (SDH)", TvPlaybackFormatting.subtitleValueLabel(v, selectedSubtitleTrackIndex = 0)) } + @Test fun subtitleValueLabel_hindiCodeDoesNotAddHearingImpairedBadge() { + val v = fileVersion(subtitles = listOf(subtitleTrack(index = 1, lang = "eng", title = "EN - HI"))) + assertEquals("English", TvPlaybackFormatting.subtitleValueLabel(v, selectedSubtitleTrackIndex = 0)) + } + @Test fun subtitleOptions_useCombinedSpaceNotStreamIndex() { // Stream indexes are non-ordinal and collide (external tracks decode 0). // selectionIndex must be the COMBINED index the server resolves diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt new file mode 100644 index 000000000..1d833946f --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonPresentationLabelTest.kt @@ -0,0 +1,55 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import org.prairieserver.prairie.model.catalog.ItemDetail +import org.prairieserver.prairie.model.catalog.Season +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvSeasonPresentationLabelTest { + @Test + fun pickerLabelsSeasonZeroWithoutSpecialsFlagAsSpecials() { + assertEquals( + "Specials", + tvSeasonPickerLabel(Season(contentId = "specials", seasonNumber = 0)), + ) + } + + @Test + fun pickerLabelsNonzeroSeasonWithSpecialsFlagAsSpecials() { + assertEquals( + "Specials", + tvSeasonPickerLabel( + Season(contentId = "bonus", seasonNumber = 99, isSpecials = true), + ), + ) + } + + @Test + fun explicitSpecialsEpisodeUsesSpecialsHeader() { + val detail = ItemDetail( + contentId = "episode-special", + type = "episode", + title = "Bonus", + seasonNumber = 0, + ) + + assertEquals("Specials", episodeEyebrowLabel(detail, TvItemDetailUiState())) + } + + @Test + fun specialsOnlySeriesUsesSpecialsHeaderAndPickerLabel() { + val specials = Season(contentId = "specials", seasonNumber = 0) + val detail = ItemDetail( + contentId = "series", + type = "series", + title = "Series", + ) + val state = TvItemDetailUiState( + seasons = listOf(specials), + selectedSeason = 0, + ) + + assertEquals("Specials", episodeEyebrowLabel(detail, state)) + assertEquals("Specials", tvSeasonPickerLabel(specials)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestorationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestorationTest.kt new file mode 100644 index 000000000..3bb7c2307 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSimilarFocusRestorationTest.kt @@ -0,0 +1,59 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvSimilarFocusRestorationTest { + @Test + fun attachmentTimeoutFallsBackToTheHero() = runTest { + var fallbackScrolled = false + var fallbackRequests = 0 + + val result = restoreMoreLikeThisFocus( + awaitTarget = { }, + stillOwned = { true }, + onTargetResolved = { }, + isTargetFocused = { false }, + requestTargetFocus = { }, + awaitFocusAttempt = { awaitCancellation() }, + scrollToFallback = { fallbackScrolled = true }, + requestFallbackFocus = { fallbackRequests += 1 }, + dataTimeoutMillis = 100, + attachmentTimeoutMillis = 100, + ) + + assertEquals(TvSimilarFocusRestoreResult.Fallback, result) + assertTrue(fallbackScrolled) + assertEquals(1, fallbackRequests) + } + + @Test + fun ownershipRevokedMidLoopStopsRequestsWithoutFallback() = runTest { + var owned = true + var targetRequests = 0 + var fallbackScrolled = false + var fallbackRequested = false + + val result = restoreMoreLikeThisFocus( + awaitTarget = { }, + stillOwned = { owned }, + onTargetResolved = { }, + isTargetFocused = { false }, + requestTargetFocus = { targetRequests += 1 }, + awaitFocusAttempt = { owned = false }, + scrollToFallback = { fallbackScrolled = true }, + requestFallbackFocus = { fallbackRequested = true }, + dataTimeoutMillis = 100, + attachmentTimeoutMillis = 100, + ) + + assertEquals(TvSimilarFocusRestoreResult.Revoked, result) + assertEquals(1, targetRequests) + assertFalse(fallbackScrolled) + assertFalse(fallbackRequested) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt new file mode 100644 index 000000000..4d311ea5c --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt @@ -0,0 +1,33 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvStarringOverlaySourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + private val metadata = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt", + ).readText() + private val presentationSources = listOf(hero, screen, metadata) + + @Test + fun tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay() { + assertFalse( + presentationSources.any { source -> + source.contains("starring", ignoreCase = true) + }, + ) + } + + @Test + fun tvDetailStillRendersTheFullCastSection() { + assertTrue(screen.contains("TvCastCrewSection(")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSubtitleLaunchHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSubtitleLaunchHandoffTest.kt new file mode 100644 index 000000000..3d9817ea4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSubtitleLaunchHandoffTest.kt @@ -0,0 +1,233 @@ +package org.prairieserver.prairie.tv.ui.screens.detail + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import org.prairieserver.prairie.tv.ui.navigation.TvSubtitleLaunchSelection +import org.prairieserver.prairie.tv.ui.navigation.explicitTvSubtitleLaunchSelection +import org.prairieserver.prairie.tv.ui.navigation.tvPlayDestinationFor +import org.prairieserver.prairie.tv.ui.screens.player.resolveTvPlaybackStartSelection +import org.prairieserver.prairie.tv.ui.screens.player.resolveTvServerSubtitleTrackIndex + +/** + * Play must launch with exactly the subtitle the detail row is displaying. + * + * The Auto case used to hand over nothing: no `subtitle_track_index` in the + * start request, no sidecar in the initial media item, and a player that then + * re-derived Auto over Media3's mounted tracks — where the external SRT the row + * had previewed did not exist. The row's own answer travels now, tagged so an + * auto-resolved pick is never mistaken for the viewer's own. + */ +class TvSubtitleLaunchHandoffTest { + + private val autoEnglishAlways = TvPlaybackFormatting.SubtitleAutoContext( + preferredLanguage = "en", + mode = "always", + showForced = true, + audioLanguage = "eng", + ) + + /** The Shield repro: embedded PGS "English (SDH)" + an external English SRT. */ + private val pgsPlusSidecar = fileVersion( + subtitles = listOf( + subtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", lang = "eng", title = "English (SDH)"), + subtitleTrack(index = 0, codec = "srt", lang = "eng", external = true), + ), + ) + + @Test + fun autoHandsOverTheResolvedCombinedIndexAndFlagsItAsAutomatic() { + val selection = TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = null, + autoContext = autoEnglishAlways, + ) + + // Externals occupy combined 0..n-1: the SRT sidecar is 0, the embedded + // PGS track is 1. + assertEquals(TvSubtitleLaunchSelection(0, autoResolved = true), selection) + } + + @Test + fun theHandoffIsExactlyWhatThePillShows() { + assertEquals( + "Auto - English · SRT", + TvPlaybackFormatting.subtitleValueLabel(pgsPlusSidecar, null, autoEnglishAlways), + ) + val selection = TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = null, + autoContext = autoEnglishAlways, + ) + assertEquals( + "English · SRT", + TvPlaybackFormatting.subtitleValueLabel(pgsPlusSidecar, selection?.selectionIndex), + ) + } + + @Test + fun autoResolvingToNothingHandsOverAnExplicitOff() { + val version = fileVersion(subtitles = listOf(subtitleTrack(lang = "eng"))) + val context = TvPlaybackFormatting.SubtitleAutoContext( + preferredLanguage = "en", + mode = "auto", + audioLanguage = "eng", + ) + + assertEquals("Auto - None", TvPlaybackFormatting.subtitleValueLabel(version, null, context)) + assertEquals( + TvSubtitleLaunchSelection(-1, autoResolved = true), + TvPlaybackFormatting.subtitleLaunchSelection(version, null, context), + ) + } + + @Test + fun anExplicitPickTravelsAsTheViewersOwn() { + val selection = TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = 1, + autoContext = autoEnglishAlways, + ) + + assertEquals(TvSubtitleLaunchSelection(1, autoResolved = false), selection) + assertEquals(1, selection?.explicitSelectionIndex) + } + + @Test + fun anExplicitOffTravelsAsTheViewersOwn() { + val selection = TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = -1, + autoContext = autoEnglishAlways, + ) + + assertEquals(TvSubtitleLaunchSelection(-1, autoResolved = false), selection) + } + + @Test + fun withoutResolutionInputsNothingIsClaimed() { + // The row itself falls back to a bare "Auto" here, so the player keeps + // its own fallback rather than being handed a guess. + assertNull( + TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = null, + autoContext = null, + ), + ) + } + + // --- routing -------------------------------------------------------- + + @Test + fun theRouteCarriesTheIndexAndTheAutomaticFlag() { + val route = tvPlayDestinationFor( + itemType = "movie", + contentId = "m-1", + fileId = 7, + resumePositionSeconds = null, + audioTrackIndex = null, + audioPickedThisSession = false, + subtitleSelection = TvSubtitleLaunchSelection(0, autoResolved = true), + ) + + assertTrue(route.contains("subtitleTrackIndex=0"), route) + assertTrue(route.contains("subtitleAutoResolved=true"), route) + } + + @Test + fun anExplicitPickNeverCarriesTheAutomaticFlag() { + val route = tvPlayDestinationFor( + itemType = "movie", + contentId = "m-1", + fileId = 7, + resumePositionSeconds = null, + audioTrackIndex = null, + audioPickedThisSession = false, + subtitleSelection = explicitTvSubtitleLaunchSelection(3), + ) + + assertTrue(route.contains("subtitleTrackIndex=3"), route) + assertTrue(!route.contains("subtitleAutoResolved"), route) + } + + @Test + fun aRouteWithNoSubtitleDecisionCarriesNeither() { + val route = TvRoute.Player(contentId = "m-1").route + assertTrue(!route.contains("subtitleTrackIndex"), route) + assertTrue(!route.contains("subtitleAutoResolved"), route) + } + + // --- the start request --------------------------------------------- + + @Test + fun theAutoResolvedIndexReachesTheServerStartRequest() { + // With the index in the start request the initial plan mounts the + // sidecar into the FIRST media item — no replan, no rebuffer. + val selection = TvPlaybackFormatting.subtitleLaunchSelection( + version = pgsPlusSidecar, + selectedSubtitleTrackIndex = null, + autoContext = autoEnglishAlways, + ) + + assertEquals( + 0, + resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff = null, + resolvedEpisodeSelection = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = null, + targetVersions = listOf(pgsPlusSidecar), + targetLastFileId = null, + preferredQuality = null, + ), + requestedSubtitleTrackIndex = selection?.selectionIndex, + ), + ) + } + + @Test + fun anAutoResolvedOffIsNotSentToTheServer() { + // -1 is the client-side "explicit Off"; the server rejects it. + assertNull( + resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff = null, + resolvedEpisodeSelection = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = null, + targetVersions = listOf(pgsPlusSidecar), + targetLastFileId = null, + preferredQuality = null, + ), + requestedSubtitleTrackIndex = -1, + ), + ) + } + + // ------------------------------------------------------------------ + + private fun fileVersion( + fileId: Int = 1, + subtitles: List? = null, + ): FileVersion = FileVersion(fileId = fileId, subtitleTracks = subtitles) + + private fun subtitleTrack( + index: Int = 0, + codec: String? = null, + lang: String? = null, + title: String? = null, + forced: Boolean = false, + external: Boolean = false, + ): SubtitleTrack = SubtitleTrack( + index = index, + codec = codec, + language = lang, + title = title, + forced = forced, + external = external, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt index f6a4716ed..06dfbe098 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt @@ -13,6 +13,7 @@ import org.prairieserver.prairie.repository.port.WriteOutcome import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TvTrackSelectionPersistenceTest { @@ -60,6 +61,80 @@ class TvTrackSelectionPersistenceTest { assertEquals(TvDetailTrackSelectionSession.Saved(23, null, -1), TvDetailTrackSelectionSession.recall("episode-session-b")) } + @Test + fun playbackReturnPreservesPreviouslySelectedAudio() { + val contentId = "episode-playback-return-audio" + TvDetailTrackSelectionSession.remember(contentId, fileId = 22, audio = 1, subtitle = 0) + + TvDetailTrackSelectionSession.rememberPlaybackReturn( + contentId = contentId, + fileId = 22, + audio = null, + subtitle = 2, + positionSeconds = 37.0, + durationSeconds = 120.0, + ) + + assertEquals(1, TvDetailTrackSelectionSession.recall(contentId)?.audio) + } + + @Test + fun playbackReturnPreservesPreviouslySelectedSubtitleForKeepCurrent() { + val contentId = "episode-playback-return-subtitle" + TvDetailTrackSelectionSession.remember(contentId, fileId = 22, audio = 1, subtitle = 2) + + TvDetailTrackSelectionSession.rememberPlaybackReturn( + contentId = contentId, + fileId = 22, + audio = null, + subtitle = null, + positionSeconds = 37.0, + durationSeconds = 120.0, + ) + + assertEquals(2, TvDetailTrackSelectionSession.recall(contentId)?.subtitle) + } + + @Test + fun playbackReturnPreservesPreviouslySelectedFileForUnknownExitFile() { + val contentId = "episode-playback-return-file" + TvDetailTrackSelectionSession.remember(contentId, fileId = 22, audio = 1, subtitle = 2) + + TvDetailTrackSelectionSession.rememberPlaybackReturn( + contentId = contentId, + fileId = null, + audio = null, + subtitle = null, + positionSeconds = 37.0, + durationSeconds = 120.0, + ) + + assertEquals(22, TvDetailTrackSelectionSession.recall(contentId)?.fileId) + } + + @Test + fun playbackReturnProgressIsConsumedOnceWhileTrackChoicesRemain() { + val contentId = "episode-playback-return-progress" + TvDetailTrackSelectionSession.remember(contentId, fileId = 22, audio = 1, subtitle = 2) + TvDetailTrackSelectionSession.rememberPlaybackReturn( + contentId = contentId, + fileId = 22, + audio = null, + subtitle = 2, + positionSeconds = 37.0, + durationSeconds = 120.0, + ) + + val playbackReturn = TvDetailTrackSelectionSession.consumePlaybackReturn(contentId) + + assertEquals(37.0, playbackReturn?.positionSeconds) + assertNull(TvDetailTrackSelectionSession.consumePlaybackReturn(contentId)) + assertEquals( + TvDetailTrackSelectionSession.Saved(fileId = 22, audio = 1, subtitle = 2), + TvDetailTrackSelectionSession.recall(contentId), + ) + } + @Test fun lateRestoreCannotApplyAfterEpisodeOrVersionChanges() { assertTrue(shouldApplyNextUpTrackRestore("episode-42", "episode-42", 22, 22)) @@ -85,6 +160,18 @@ class TvTrackSelectionPersistenceTest { assertEquals(0, merged.subtitleIndex) } + @Test + fun explicitSubtitleOffWinsDurableSubtitleWhileAudioStillRestores() { + val merged = mergeTrackSelection( + currentAudioIndex = null, + currentSubtitleIndex = -1, + durable = TvRestoredTrackSelection(audioIndex = 1, subtitleIndex = 0), + ) + + assertEquals(1, merged.audioIndex) + assertEquals(-1, merged.subtitleIndex) + } + private fun version() = FileVersion( fileId = 22, audioTracks = listOf( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryFocusRestoreTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryFocusRestoreTest.kt index 8c22d27ae..e90ff86eb 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryFocusRestoreTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryFocusRestoreTest.kt @@ -2,27 +2,30 @@ package org.prairieserver.prairie.tv.ui.screens.library import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull +/** + * Restoring the grid used to be a saved index clamped to the current size, + * which is why these once asserted "17 comes back as 17". Identity resolution + * lives in the shared contract now; what remains here is the grid's own + * arithmetic — turning an item position into a LazyGrid position. + */ class TvLibraryFocusRestoreTest { @Test - fun restoresSavedItemWhenStillPresent() { - assertEquals(17, restoredLibraryFocusIndex(17, 40)) - } - - @Test - fun clampsAfterLibraryShrinks() { - assertEquals(4, restoredLibraryFocusIndex(17, 5)) + fun lazyGridTargetIncludesFullSpanHeaders() { + // Sort/filter controls and genre chips occupy full-span slots ahead of + // the cards, so the grid runs ahead of the item index by however many + // are showing. + assertEquals(19, libraryLazyGridIndex(itemIndex = 17, headerCount = 2)) } @Test - fun emptyGridHasNoRestoreTarget() { - assertNull(restoredLibraryFocusIndex(3, 0)) + fun aGridWithNoHeadersAddressesCardsDirectly() { + assertEquals(17, libraryLazyGridIndex(itemIndex = 17, headerCount = 0)) } @Test - fun lazyGridTargetIncludesFullSpanHeaders() { - assertEquals(19, restoredLibraryLazyGridIndex(17, 40, 2)) - assertNull(restoredLibraryLazyGridIndex(0, 0, 2)) + fun negativeInputsClampRatherThanAddressingBeforeTheGrid() { + assertEquals(0, libraryLazyGridIndex(itemIndex = -1, headerCount = 0)) + assertEquals(3, libraryLazyGridIndex(itemIndex = 3, headerCount = -2)) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt new file mode 100644 index 000000000..1baa6669a --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.library + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvLibraryReviewWiringSourceTest { + private val detailScreen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt", + ).readText() + private val mainShell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + + @Test + fun alphabetAndCalendarForwardContentUpFallbackToTheShell() { + val alphabetTab = extractBetween( + source = detailScreen, + startAnchor = "TvLibraryTab.Alphabet -> LibraryTab(", + endAnchor = "TvLibraryTab.RecentlyAdded ->", + ) + val calendarScreen = extractBetween( + source = mainShell, + startAnchor = "TvCalendarScreen(", + endAnchor = "shellComposable(TvMainRoute.Browse.route)", + ) + + assertTrue(alphabetTab.contains("onContentUpFallbackChanged = onContentUpFallbackChanged")) + assertTrue(calendarScreen.contains("onContentUpFallbackChanged = onContentUpFallback")) + } + + private fun extractBetween(source: String, startAnchor: String, endAnchor: String): String { + val start = source.indexOf(startAnchor) + assertTrue(start >= 0, "Missing start anchor: $startAnchor") + + val contentStart = start + startAnchor.length + val end = source.indexOf(endAnchor, contentStart) + assertTrue(end >= 0, "Missing end anchor: $endAnchor") + + return source.substring(contentStart, end) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt index aea12c1d4..9c7cf1ca8 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibrarySubdestinationViewModelTest.kt @@ -94,6 +94,28 @@ class TvLibrarySubdestinationViewModelTest { assertEquals(null, viewModel.uiState.value.selectedAudiobookGroup) } + @Test + fun reselectingTheActiveBrowseTabKeepsTheViewersSort() = runLibraryTest { + val requests = mutableListOf() + val viewModel = viewModelFor(requests, libraryType = "movies") + + viewModel.onTabSelected(TvLibraryTab.Browse) + awaitState { requests.catalogRequestCount() >= 1 } + viewModel.onSortKeySelected(TvLibrarySortOption.ReleaseDate) + awaitState { requests.lastCatalogRequestOrNull()?.query?.get("sort") == "year" } + val requestsBeforeReentry = requests.catalogRequestCount() + + // Re-entering the screen (back out of item detail) re-issues the + // already-committed section against this same ViewModel. That must not + // reset the sort the viewer picked. + viewModel.onTabSelected(TvLibraryTab.Browse) + settle() + + assertEquals("year", viewModel.uiState.value.browseFilter.sort) + assertEquals("desc", viewModel.uiState.value.browseFilter.order) + assertEquals(requestsBeforeReentry, requests.catalogRequestCount()) + } + private val createdViewModels = mutableListOf() private fun runLibraryTest(block: suspend () -> Unit) = runTest { @@ -111,8 +133,13 @@ class TvLibrarySubdestinationViewModelTest { } } + /** Real-time window for any spurious request to land before asserting none did. */ + private suspend fun settle() { + withContext(Dispatchers.IO) { delay(200) } + } + private suspend fun awaitState(predicate: () -> Boolean) { - withContext(Dispatchers.Default.limitedParallelism(1)) { + withContext(Dispatchers.IO) { withTimeout(30_000) { while (!predicate()) { delay(10) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailViewModelTest.kt index 88e9056a9..8b5abcb9d 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailViewModelTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/people/TvPersonDetailViewModelTest.kt @@ -121,7 +121,7 @@ class TvPersonDetailViewModelTest { viewModel: TvPersonDetailViewModel, predicate: (TvPersonDetailUiState) -> Boolean, ) { - withContext(Dispatchers.Default.limitedParallelism(1)) { + withContext(Dispatchers.IO) { withTimeout(30_000) { while (!predicate(viewModel.uiState.value)) { delay(10) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt index 3e019a276..31b5c08e6 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/PlayerTrackEntriesTest.kt @@ -17,12 +17,30 @@ import kotlin.test.assertTrue class PlayerTrackEntriesTest { @Test - fun replanSelectionMapsMedia3OrdinalToStableServerAudioIndex() { - val catalogTracks = listOf(AudioTrack(index = 1), AudioTrack(index = 5)) + fun planOrdinalWinsOverTheMountedMedia3Ordinal() { + // Audio carries no server index, so these are ORDINALS throughout. The + // old code did catalogTracks.getOrNull(ordinal).index, which evaluated + // to 0 for every track: every explicit pick asked for track 0. + val catalogTracks = listOf(AudioTrack(language = "eng"), AudioTrack(language = "nld")) + + // The transcode case: the delivered stream carries only the chosen + // track, so Media3 reports ordinal 0. The plan says 1 and must win, or + // the next replan reverts the audio to the first language. + assertEquals(1, selectedServerAudioTrackIndex(0, catalogTracks, currentPlanTrackIndex = 1)) + assertEquals(0, selectedServerAudioTrackIndex(1, catalogTracks, currentPlanTrackIndex = 0)) + assertEquals(0, selectedServerAudioTrackIndex(null, catalogTracks, currentPlanTrackIndex = 0)) + assertEquals(1, selectedServerAudioTrackIndex(9, catalogTracks, currentPlanTrackIndex = 1)) + } - assertEquals(5, selectedServerAudioTrackIndex(1, catalogTracks, currentPlanTrackIndex = 1)) - assertEquals(1, selectedServerAudioTrackIndex(null, catalogTracks, currentPlanTrackIndex = 1)) - assertEquals(1, selectedServerAudioTrackIndex(4, catalogTracks, currentPlanTrackIndex = 1)) + @Test + fun withoutAPlanTheMountedOrdinalIsAGuardedFallback() { + val catalogTracks = listOf(AudioTrack(language = "eng"), AudioTrack(language = "nld")) + + assertEquals(1, selectedServerAudioTrackIndex(1, catalogTracks, currentPlanTrackIndex = null)) + // Out of the catalog's range is not a usable answer. + assertEquals(null, selectedServerAudioTrackIndex(9, catalogTracks, currentPlanTrackIndex = null)) + assertEquals(null, selectedServerAudioTrackIndex(null, catalogTracks, currentPlanTrackIndex = null)) + assertEquals(null, selectedServerAudioTrackIndex(0, emptyList(), currentPlanTrackIndex = null)) } @Test @@ -273,6 +291,44 @@ class PlayerTrackEntriesTest { ) } + @Test + fun autoSubtitleResolverDoesNotTreatHindiCodeAsHearingImpaired() { + val subtitles = listOf( + PlayerTrackEntry( + index = 1, + label = "EN - HI", + language = "en", + isSelected = false, + codecOrMime = MimeTypes.TEXT_VTT, + ), + PlayerTrackEntry( + index = 2, + label = "English VTT", + language = "en", + isSelected = false, + codecOrMime = MimeTypes.TEXT_VTT, + ), + ) + + assertEquals( + SubtitleAutoSelection.Select(1), + resolveAutoSubtitleSelection( + audioTracks = listOf( + PlayerTrackEntry( + index = 0, + label = "Japanese AAC", + language = "ja", + isSelected = true, + ), + ), + subtitleTracks = subtitles, + preferredLanguage = "en", + subtitleMode = "auto", + showForced = true, + ), + ) + } + @Test fun initialSubtitleOrdinalResolvesThroughMountedSubtitleMetadata() { val tracks = listOf( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt index 15cca926f..9eba8d836 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt @@ -15,7 +15,7 @@ class SubtitleRemountReselectionTest { @Test fun `ViewModel does not settle the first nonempty remount snapshot`() { val tracker = TvSubtitleSnapshotSettlementTracker() - val first = listOf(track(index = 1, trackId = "prairie-subtitle:4")) + val first = listOf(track(index = 1, trackId = "silo-subtitle:4")) assertFalse(tracker.observe(first)) assertTrue(tracker.observe(first)) @@ -24,8 +24,8 @@ class SubtitleRemountReselectionTest { @Test fun `changed remount snapshot must stabilize again before it is terminal`() { val tracker = TvSubtitleSnapshotSettlementTracker() - val first = listOf(track(index = 1, trackId = "prairie-subtitle:4")) - val changed = listOf(track(index = 2, trackId = "prairie-subtitle:4")) + val first = listOf(track(index = 1, trackId = "silo-subtitle:4")) + val changed = listOf(track(index = 2, trackId = "silo-subtitle:4")) assertFalse(tracker.observe(first)) assertFalse(tracker.observe(changed)) @@ -89,7 +89,7 @@ class SubtitleRemountReselectionTest { @Test fun `catalog B followed by embedded C remounts only C`() { val latch = SubtitleRemountReselection() - val b = SubtitleIdentity.ServerSidecar(4, media(trackId = "prairie-subtitle:4")) + val b = SubtitleIdentity.ServerSidecar(4, media(trackId = "silo-subtitle:4")) val c = SubtitleIdentity.Embedded(8, media(trackId = "embedded-c")) latch.arm(b, generation = 1) latch.arm(c, generation = 2) @@ -97,7 +97,7 @@ class SubtitleRemountReselectionTest { val event = assertIs( latch.consume( subtitleTracks = listOf( - track(index = 4, trackId = "prairie-subtitle:4"), + track(index = 4, trackId = "silo-subtitle:4"), track(index = 8, trackId = "embedded-c"), ), snapshotKey = "ready", @@ -107,7 +107,7 @@ class SubtitleRemountReselectionTest { assertEquals(8, event.trackIndex) assertEquals(c, event.owner.identity) - assertNull(latch.consume(listOf(track(index = 4, trackId = "prairie-subtitle:4")), "late-b", true)) + assertNull(latch.consume(listOf(track(index = 4, trackId = "silo-subtitle:4")), snapshotKey = "late-b", settled = true)) } @Test @@ -120,7 +120,7 @@ class SubtitleRemountReselectionTest { val event = assertIs( latch.consume( subtitleTracks = listOf( - track(index = 4, trackId = "prairie-subtitle:4"), + track(index = 4, trackId = "silo-subtitle:4"), track(index = 9, trackId = "local-c"), ), snapshotKey = "ready", @@ -189,7 +189,7 @@ class SubtitleRemountReselectionTest { latch.clear() assertFalse(latch.hasPendingOwner) - assertNull(latch.consume(listOf(track(index = 2, trackId = "b")), "late", true)) + assertNull(latch.consume(listOf(track(index = 2, trackId = "b")), snapshotKey = "late", settled = true)) } @Test @@ -199,7 +199,7 @@ class SubtitleRemountReselectionTest { latch.clear() - assertNull(latch.consume(listOf(track(index = 4, trackId = "prairie-subtitle:4")), "late", true)) + assertNull(latch.consume(listOf(track(index = 4, trackId = "silo-subtitle:4")), snapshotKey = "late", settled = true)) } @Test @@ -221,7 +221,7 @@ class SubtitleRemountReselectionTest { assertTrue(latch.hasPendingOwner) val event = assertIs( - latch.consume(listOf(track(index = 8, trackId = "target")), "ready", true), + latch.consume(listOf(track(index = 8, trackId = "target")), snapshotKey = "ready", settled = true), ) assertEquals(8, event.trackIndex) } @@ -229,8 +229,8 @@ class SubtitleRemountReselectionTest { @Test fun `merged sidecar carrying the Media3 source prefix still mounts`() { // Media3 reports a merged sidecar's Format.id with the MergingMediaSource - // child index: the id authored as "prairie-subtitle:0" comes back as - // "1:prairie-subtitle:0", alongside primary-stream tracks like "0:3". + // child index: the id authored as "silo-subtitle:0" comes back as + // "1:silo-subtitle:0", alongside primary-stream tracks like "0:3". // Exact equality never matched, so the mount timed out and the whole // subtitle transaction rolled back to Off. val latch = SubtitleRemountReselection() @@ -323,8 +323,8 @@ class SubtitleRemountReselectionTest { val event = assertIs( latch.consume( listOf( - track(index = 2, trackId = "prairie-downloaded-subtitle:90", label = "English"), - track(index = 3, trackId = "prairie-downloaded-subtitle:91", label = "English"), + track(index = 2, trackId = "silo-downloaded-subtitle:90", label = "English"), + track(index = 3, trackId = "silo-downloaded-subtitle:91", label = "English"), ), snapshotKey = "ready", settled = true, @@ -342,8 +342,8 @@ class SubtitleRemountReselectionTest { val event = assertIs( latch.consume( listOf( - track(index = 2, trackId = "prairie-subtitle:8"), - track(index = 3, trackId = "prairie-subtitle:7"), + track(index = 2, trackId = "silo-subtitle:8"), + track(index = 3, trackId = "silo-subtitle:7"), ), snapshotKey = "ready", settled = true, @@ -389,9 +389,9 @@ class SubtitleRemountReselectionTest { .substringAfter("private suspend fun adoptSeekRecoveryDecision(") .substringBefore("private fun isCurrentSeekRecovery(") - assertTrue(seekRecoveryBlock.contains("val selectedSubtitle = selectedSubtitleTrackIndex(before)")) - assertTrue(seekRecoveryBlock.contains("subtitleTrackIndex = selectedSubtitle")) - assertTrue(seekRecoveryBlock.contains("nextTransportMountNonce(selectedSubtitle)")) + assertTrue(seekRecoveryBlock.contains("val returnedSubtitleIndex = decision.plan.resolvedSelectedSubtitleIndex()")) + assertTrue(seekRecoveryBlock.contains("subtitleTrackIndex = returnedSubtitleIndex ?: -1")) + assertTrue(seekRecoveryBlock.contains("nextTypedSubtitleMountNonce(returnedSubtitleIdentity)")) } private fun media( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt index 8ee2e6fdf..417a04def 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleTransactionIntegrationTest.kt @@ -11,19 +11,26 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.int @@ -35,7 +42,6 @@ import org.prairieserver.prairie.common.player.PlaybackSessionManager import org.prairieserver.prairie.common.player.SessionState import org.prairieserver.prairie.common.player.StartParams import org.prairieserver.prairie.common.player.VideoSessionStartV3 -import org.prairieserver.prairie.common.player.downloadedSubtitleArtifactTrackId import org.prairieserver.prairie.common.player.subtitleArtifactTrackId import org.prairieserver.prairie.model.catalog.AudioTrack import org.prairieserver.prairie.model.personal.SyncProgressItem @@ -43,26 +49,30 @@ import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.CommittedSubtitle import org.prairieserver.prairie.model.playback.PLAYBACK_PLAN_V3_FEATURE +import org.prairieserver.prairie.model.playback.NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDecisionOutcome import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackDelivery import org.prairieserver.prairie.model.playback.PlaybackEffectiveRecipeV3 -import org.prairieserver.prairie.model.playback.PlaybackEngineKind import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.PlaybackPlanV3 import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol import org.prairieserver.prairie.model.playback.PlaybackStreamV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleArtifactV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3 +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3 import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3 import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3 +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_BURN_IN_ONLY +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference import org.prairieserver.prairie.model.playback.SubtitleIdentity import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.playback.downloadedSubtitleArtifactTrackId import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManager @@ -70,16 +80,38 @@ import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.network.api.HealthStatus import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.api.PlaybackApi -import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.repository.PersonalDataRepository import org.prairieserver.prairie.repository.PlaybackRepository -import org.prairieserver.prairie.repository.ProfileRepository import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +private suspend fun awaitHarnessCondition( + transactionScheduler: TestCoroutineScheduler, + cleanupScheduler: TestCoroutineScheduler, + timeoutMillis: Long, + condition: suspend () -> Boolean, +) { + val started = TimeSource.Monotonic.markNow() + while (!condition()) { + // runTest already owns and drives its transaction scheduler. Driving it + // again from another thread can execute nominally single-threaded test + // tasks concurrently. Only a genuinely separate manager-cleanup + // scheduler needs manual progress here. + if (cleanupScheduler !== transactionScheduler) { + cleanupScheduler.runCurrent() + } + if (started.elapsedNow() >= timeoutMillis.milliseconds) { + throw AssertionError("Timed out waiting for subtitle transaction cleanup") + } + yield() + } +} @OptIn(ExperimentalCoroutinesApi::class) @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") @@ -126,11 +158,79 @@ class SubtitleTransactionIntegrationTest { assertEquals(listOf(Harness.MountedSelection("s2", 9)), harness.media3Selections) harness.assertActiveSession("s2") assertEquals(mapOf("s1" to 1), harness.stopCounts()) - assertEquals(listOf(sidecarB), harness.persistence.map { it.first.identity }) + val persistedSidecar = assertIs( + harness.persistence.single().first.identity, + ) + assertEquals(B_INDEX, persistedSidecar.serverIndex) + assertEquals("file:$FILE_ID:subtitle:$B_INDEX", persistedSidecar.media?.trackId) assertEquals("s2", harness.persistence.single().second.sessionId) harness.assertNoOrphans() } + @Test + fun `cleanup wait advances the manager-owned test scheduler`() = runTest { + val cleanupDispatcher = StandardTestDispatcher() + val cleanupJob = SupervisorJob() + val cleanupScope = CoroutineScope(cleanupJob + cleanupDispatcher) + try { + val harness = harness( + replanResponse = { _, _ -> response(sidecarPlan("s2", FILE_ID, B_INDEX)) }, + committedSessionCleanupScope = cleanupScope, + committedSessionCleanupScheduler = cleanupDispatcher.scheduler, + ) + harness.start(sidecarA) + + harness.adapter.select(sidecarB) + runCurrent() + harness.awaitReplans(1) + harness.awaitAdopted("s2") + harness.mountPending( + expectedSessionId = "s2", + tracks = listOf( + harness.sidecarMountedTrack( + expectedSessionId = "s2", + serverIndex = B_INDEX, + playerIndex = 9, + ), + ), + ) + runCurrent() + + harness.awaitStopped("s1") + + assertEquals(mapOf("s1" to 1), harness.stopCounts()) + harness.assertNoOrphans() + } finally { + cleanupJob.cancelAndJoin() + } + } + + @Test + fun `cleanup wait never drives the shared transaction scheduler concurrently`() = runTest { + val firstTaskRunning = AtomicBoolean(false) + val overlapObserved = AtomicBoolean(false) + val completed = AtomicBoolean(false) + + backgroundScope.launch { + firstTaskRunning.set(true) + Thread.sleep(100) + firstTaskRunning.set(false) + } + backgroundScope.launch { + overlapObserved.set(firstTaskRunning.get()) + completed.set(true) + } + + awaitHarnessCondition( + transactionScheduler = testScheduler, + cleanupScheduler = testScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = completed::get, + ) + + assertFalse(overlapObserved.get()) + } + @Test fun `off supersedes an in-flight sidecar and replans from the committed session`() = runTest { val firstEntered = CompletableDeferred() @@ -156,6 +256,10 @@ class SubtitleTransactionIntegrationTest { harness.awaitReplans(2) harness.awaitStopped("s2") harness.awaitAdopted("s3") + assertTrue( + testScheduler.currentTime < EVENT_TIMEOUT_MS, + "Adoption reached the pending Media3 mount deadline before the test could mount it.", + ) runCurrent() assertEquals(listOf("s1", "s1"), harness.replanBaseSessions) @@ -201,7 +305,11 @@ class SubtitleTransactionIntegrationTest { assertEquals(mapOf("s1" to 1), harness.stopCounts()) assertTrue(harness.media3Selections.isEmpty()) assertNull(harness.adapter.snapshot.localMountIdentity) - assertEquals(listOf(burnIn), harness.persistence.map { it.first.identity }) + val persistedBurnIn = assertIs( + harness.persistence.single().first.identity, + ) + assertEquals(B_INDEX, persistedBurnIn.serverIndex) + assertEquals("file:$FILE_ID:subtitle:$B_INDEX", persistedBurnIn.media?.trackId) assertEquals("s2", harness.persistence.single().second.sessionId) harness.assertNoOrphans() } @@ -345,13 +453,21 @@ class SubtitleTransactionIntegrationTest { private fun TestScope.harness( replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, + committedSessionCleanupScope: CoroutineScope = backgroundScope, + committedSessionCleanupScheduler: TestCoroutineScheduler = testScheduler, ): Harness = Harness( scope = backgroundScope, + transactionScheduler = testScheduler, + committedSessionCleanupScope = committedSessionCleanupScope, + committedSessionCleanupScheduler = committedSessionCleanupScheduler, replanResponse = replanResponse, ) private class Harness( private val scope: CoroutineScope, + private val transactionScheduler: TestCoroutineScheduler, + committedSessionCleanupScope: CoroutineScope, + private val committedSessionCleanupScheduler: TestCoroutineScheduler, private val replanResponse: suspend (Int, JsonObject) -> PlaybackDecisionResponseV3, ) { val stoppedSessions: MutableList = @@ -366,11 +482,10 @@ class SubtitleTransactionIntegrationTest { Collections.synchronizedList(mutableListOf()) private val adoptedPlaybackRows: MutableMap> = Collections.synchronizedMap(mutableMapOf()) + private var mountedSubtitleIdentity: SubtitleIdentity? = null val media3Selections = mutableListOf() - private val stoppedEvents = Channel(Channel.UNLIMITED) private val replanEvents = Channel(Channel.UNLIMITED) - private val adoptedEvents = Channel(Channel.UNLIMITED) private val persistenceEvents = Channel(Channel.UNLIMITED) private val startIndex = AtomicInteger() private val replanIndex = AtomicInteger() @@ -412,7 +527,6 @@ class SubtitleTransactionIntegrationTest { path.startsWith("/api/v1/playback/") -> { val sessionId = path.substringAfterLast('/') stoppedSessions += sessionId - stoppedEvents.send(sessionId) null } else -> null @@ -429,11 +543,10 @@ class SubtitleTransactionIntegrationTest { val manager = PlaybackSessionManager( playbackRepository = PlaybackRepository(PlaybackApi(client)), tokenManager = IntegrationTokenManager, - committedSessionCleanupScope = scope, + committedSessionCleanupScope = committedSessionCleanupScope, ) val lifecycle = PlaybackSessionLifecycle( sessionManager = manager, - profileRepository = IntegrationProfileRepository(), healthApi = IntegrationHealthApi(), personalDataRepository = IntegrationPersonalDataRepository(), scope = scope, @@ -480,8 +593,8 @@ class SubtitleTransactionIntegrationTest { ), session = ready.session, manageProgress = false, - renewMissingSessionWithLegacyStart = false, ) + mountedSubtitleIdentity = committedIdentity adapter = TvSubtitleTransactionAdapter( scope = scope, stagedPort = PlaybackSessionManagerTvSubtitleStagedReplanPort(manager, lifecycle), @@ -497,6 +610,7 @@ class SubtitleTransactionIntegrationTest { }, durablePersistenceScope = scope, settlementScope = scope, + isLocallyMountable = { identity -> identity == mountedSubtitleIdentity }, onCommittedPlayback = { adoption -> val candidate = requireNotNull(adoption.playback.ready) val adopted = lifecycle.adoptActiveSessionIfCurrent( @@ -508,14 +622,13 @@ class SubtitleTransactionIntegrationTest { ), session = candidate.session, manageProgress = false, - renewMissingSessionWithLegacyStart = false, deferPublication = true, isCurrent = adoption::isCurrent, ) if (adopted && adoption.isCurrent()) { adoptedPlaybackRows[candidate.session.sessionId] = adoption.playback.subtitleTracks - adoptedEvents.send(candidate.session.sessionId) + mountedSubtitleIdentity = adoption.committed.identity TvSubtitleAdoptionResult.Adopted } else { TvSubtitleAdoptionResult.Superseded @@ -563,7 +676,6 @@ class SubtitleTransactionIntegrationTest { ), session = ready.session, manageProgress = false, - renewMissingSessionWithLegacyStart = false, ) assertIs>(manager.stopSession("s1")) return context( @@ -628,7 +740,9 @@ class SubtitleTransactionIntegrationTest { playerIndex: Int, ): PlayerTrackEntry { val row = mountedRow(expectedSessionId) { - it.index == serverIndex && it.source == "server_artifact" + it.index == serverIndex && + it.serverTrackId == "file:$FILE_ID:subtitle:$serverIndex" && + it.serverDelivery == SUBTITLE_DELIVERY_SIDECAR } assertEquals("/stream/$expectedSessionId/subtitles/$serverIndex.vtt", row.url) val artifactTrackId = subtitleArtifactTrackId(row.index) @@ -672,38 +786,38 @@ class SubtitleTransactionIntegrationTest { suspend fun awaitStopped(sessionId: String) { if (sessionId in stoppedSessions) return - withContext(Dispatchers.Default) { - withTimeout(EVENT_TIMEOUT_MS) { - while (stoppedEvents.receive() != sessionId) { - // Drain unrelated cleanup completions. - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = committedSessionCleanupScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { sessionId in stoppedSessions }, + ) } suspend fun awaitReplans(count: Int) { while (replanBodies.size < count) { withContext(Dispatchers.Default) { - withTimeout(5_000) { replanEvents.receive() } + withTimeout(AWAIT_POLL_TIMEOUT_MS) { replanEvents.receive() } } } } suspend fun awaitAdopted(sessionId: String) { - if (lifecycle.activeSessionId() == sessionId) return - withContext(Dispatchers.Default) { - withTimeout(5_000) { - while (adoptedEvents.receive() != sessionId) { - // Drain unrelated adoption completions. - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = transactionScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { + manager.activeSessionIdForTest() == sessionId && + lifecycle.activeSessionId() == sessionId + }, + ) } suspend fun awaitPersistence(count: Int) { while (persistence.size < count) { withContext(Dispatchers.Default) { - withTimeout(5_000) { persistenceEvents.receive() } + withTimeout(AWAIT_POLL_TIMEOUT_MS) { persistenceEvents.receive() } } } } @@ -717,13 +831,12 @@ class SubtitleTransactionIntegrationTest { } suspend fun assertNoOrphans() { - withContext(Dispatchers.Default) { - withTimeout(EVENT_TIMEOUT_MS) { - while (manager.orphanedSessionIdsForTest().isNotEmpty()) { - kotlinx.coroutines.yield() - } - } - } + awaitHarnessCondition( + transactionScheduler = transactionScheduler, + cleanupScheduler = committedSessionCleanupScheduler, + timeoutMillis = EVENT_TIMEOUT_MS, + condition = { manager.orphanedSessionIdsForTest().isEmpty() }, + ) assertEquals(emptySet(), manager.orphanedSessionIdsForTest()) } @@ -747,6 +860,7 @@ class SubtitleTransactionIntegrationTest { const val B_INDEX = 4 const val DOWNLOAD_ID = 312 const val OUTPUT_GENERATION = 7L + const val OUTPUT_CONTEXT_ID = "7" val sidecarA = SubtitleIdentity.ServerSidecar(A_INDEX) val sidecarB = SubtitleIdentity.ServerSidecar( @@ -761,7 +875,7 @@ class SubtitleTransactionIntegrationTest { val playbackContext = ClientPlaybackContext( formFactor = "tv", appVersion = "integration-test", - output = PlaybackOutputContext(outputRouteGeneration = OUTPUT_GENERATION), + output = PlaybackOutputContext(outputContextId = OUTPUT_CONTEXT_ID), ) fun startParams( @@ -782,7 +896,10 @@ class SubtitleTransactionIntegrationTest { fun response(plan: PlaybackPlanV3) = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ), outcome = PlaybackDecisionOutcome.PLAYABLE, sessionId = plan.sessionId, playbackPlan = plan, @@ -794,9 +911,9 @@ class SubtitleTransactionIntegrationTest { audioIndex: Int, ) = PlaybackPlanV3( planId = "plan-$sessionId", + planAttemptKey = "v3:test:$sessionId", sessionId = sessionId, delivery = PlaybackDelivery.SERVER_REMUX_HLS, - engine = PlaybackEngineKind.MEDIA3_HLS, stream = PlaybackStreamV3( url = "/stream/$sessionId/master.m3u8", protocol = PlaybackStreamProtocol.HLS, @@ -835,6 +952,11 @@ class SubtitleTransactionIntegrationTest { mimeType = "text/vtt", format = "webvtt", ), + inventory = subtitleInventory( + sessionId = sessionId, + fileId = fileId, + lastIndex = subtitleIndex, + ), ), ) @@ -853,9 +975,51 @@ class SubtitleTransactionIntegrationTest { subtitle = PlaybackSubtitleDecisionV3( mode = PlaybackSubtitleModeV3.BURN_IN, trackId = "file:$fileId:subtitle:$subtitleIndex", + inventory = subtitleInventory( + sessionId = sessionId, + fileId = fileId, + lastIndex = subtitleIndex, + burnInIndex = subtitleIndex, + ), ), ) + private fun subtitleInventory( + sessionId: String, + fileId: Int, + lastIndex: Int, + burnInIndex: Int? = null, + ): List = (0..lastIndex).map { index -> + val burnIn = index == burnInIndex + PlaybackSubtitleInventoryItemV3( + trackId = "file:$fileId:subtitle:$index", + combinedIndex = index, + source = "external", + codec = if (burnIn) "pgs" else "webvtt", + language = "en", + label = "Subtitle $index", + delivery = if (burnIn) { + SUBTITLE_DELIVERY_BURN_IN_ONLY + } else { + SUBTITLE_DELIVERY_SIDECAR + }, + url = if (burnIn) null else "/stream/$sessionId/subtitles/$index.vtt", + ) + } + + /** + * Output identity is nested under the playback context in the neutral + * contract: there is no top-level output field on either request. + */ + fun assertOutputContext(body: JsonObject) { + assertEquals( + OUTPUT_CONTEXT_ID, + body.getValue("client_playback_context").jsonObject + .getValue("output").jsonObject + .getValue("output_context_id").jsonPrimitive.content, + ) + } + fun assertReplan(body: JsonObject, audioIndex: Int, subtitleIndex: Int) { val selected = body.getValue("selected_tracks").jsonObject assertEquals(audioIndex, selected.getValue("audio").jsonObject.getValue("index").jsonPrimitive.int) @@ -867,7 +1031,7 @@ class SubtitleTransactionIntegrationTest { selected.getValue("subtitle").jsonObject.getValue("index").jsonPrimitive.int, ) } - assertEquals(OUTPUT_GENERATION, body.getValue("output_route_generation").jsonPrimitive.content.toLong()) + assertOutputContext(body) } fun assertStart(body: JsonObject, audioIndex: Int, subtitleIndex: Int) { @@ -876,10 +1040,7 @@ class SubtitleTransactionIntegrationTest { subtitleIndex, body["subtitle_track_index"]?.jsonPrimitive?.intOrNull ?: -1, ) - assertEquals( - OUTPUT_GENERATION, - body.getValue("output_route_generation").jsonPrimitive.content.toLong(), - ) + assertOutputContext(body) } fun downloadedIdentity(downloadId: Int) = SubtitleIdentity.Downloaded( @@ -930,13 +1091,6 @@ private fun SubtitleIdentity.serverTrackIndex(): Int = when (this) { -> -1 } -private class IntegrationProfileRepository : ProfileRepository( - profileApi = ProfileApi(HttpClient()), - tokenManager = IntegrationTokenManager, -) { - override suspend fun getActiveProfileId(): String = "profile-1" -} - private class IntegrationHealthApi : HealthApi(HttpClient()) { override suspend fun checkHealth(): ApiResult = ApiResult.Success(HealthStatus(status = "ok")) @@ -967,3 +1121,13 @@ private object IntegrationTokenManager : TokenManager { override suspend fun signOutCurrentServer() {} override suspend fun snapshotCurrentScope(): AuthScopeSnapshot? = null } + +/** + * Wall-clock backstop for the awaits above. + * + * These wait on signals and spins whose progress depends on getting scheduled, + * while the deadline counts real seconds regardless — so on a loaded CI runner + * a merely-slow test failed as if it had raced. The deadline exists to turn a + * hang into a failure, not to police latency. + */ +private const val AWAIT_POLL_TIMEOUT_MS = 30_000L diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt new file mode 100644 index 000000000..f527e7dce --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvAutoSubtitleFallbackTest.kt @@ -0,0 +1,129 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import androidx.media3.common.MimeTypes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity + +/** + * The player's Auto fallback — the only path left for launches that carried no + * decision (deep link, cast, remote start, recovery). + * + * It used to rank Media3's MOUNTED text tracks, so an external sidecar the + * initial plan never mounted could not be a candidate at all: on an + * English/Always profile the lone mounted PGS track won by default while the + * detail row had previewed the SRT. It resolves over the server inventory now. + */ +class TvAutoSubtitleFallbackTest { + + private val pgsRow = PlayerSubtitleInfo( + index = 1, + language = "eng", + codec = "pgs", + label = "English (SDH)", + source = "embedded", + url = "", + catalogLabel = "English (SDH)", + catalogSource = "embedded", + ) + + private val sidecarRow = PlayerSubtitleInfo( + index = 0, + language = "eng", + codec = "srt", + label = "English", + source = "external", + url = "https://silo.example/stream/s1/subtitles/0.vtt", + catalogLabel = "English", + catalogSource = "external", + ) + + /** Only the embedded PGS track is mounted — the sidecar is not in the media item yet. */ + private val mountedPgsOnly = listOf( + PlayerTrackEntry( + index = 0, + label = "English (SDH)", + language = "en", + isSelected = false, + codecOrMime = MimeTypes.APPLICATION_PGS, + ), + ) + + @Test + fun theFallbackPrefersAnUnmountedExternalTextTrackOverTheMountedBitmapOne() { + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = mountedPgsOnly, + subtitleRows = listOf(sidecarRow, pgsRow), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + assertEquals(tvSubtitleIdentity(sidecarRow), identity) + } + + @Test + fun theFallbackStillTakesTheBitmapTrackWhenItIsTheOnlyCandidate() { + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = mountedPgsOnly, + subtitleRows = listOf(pgsRow), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + assertEquals(tvSubtitleIdentity(pgsRow), identity) + } + + @Test + fun autoResolvingToNothingStartsExplicitlyOff() { + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = listOf( + PlayerTrackEntry(index = 0, label = "English", language = "eng", isSelected = true), + ), + subtitleTracks = mountedPgsOnly, + subtitleRows = listOf(sidecarRow, pgsRow), + preferredLanguage = "en", + subtitleMode = "auto", + showForced = false, + ) + + assertEquals(SubtitleIdentity.Off, identity) + } + + @Test + fun withoutAServerInventoryTheMountedTracksAreRanked() { + val identity = resolveTvAutoSubtitleIdentity( + audioTracks = emptyList(), + subtitleTracks = listOf( + PlayerTrackEntry( + index = 0, + label = "English (SDH)", + language = "en", + isSelected = false, + codecOrMime = MimeTypes.TEXT_VTT, + ), + PlayerTrackEntry( + index = 1, + label = "English", + language = "en", + isSelected = false, + codecOrMime = MimeTypes.TEXT_VTT, + ), + ), + subtitleRows = emptyList(), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + // Full dialogue beats SDH, and a player-discovered track keeps its own + // Media3 identity. + val local = assertIs(identity) + assertEquals("English", local.media.label) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvCleanPlaybackSeekTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvCleanPlaybackSeekTest.kt index 40ed4d197..405174ffa 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvCleanPlaybackSeekTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvCleanPlaybackSeekTest.kt @@ -24,48 +24,72 @@ class TvCleanPlaybackSeekTest { @Test fun manualTapsWalkTheSignedRateLadder() { - assertEquals(-2, adjustedCleanPlaybackSeekRate(currentRate = -1, adjustment = -1)) - assertEquals(1, adjustedCleanPlaybackSeekRate(currentRate = -1, adjustment = 1)) - assertEquals(-1, adjustedCleanPlaybackSeekRate(currentRate = 1, adjustment = -1)) - assertEquals(2, adjustedCleanPlaybackSeekRate(currentRate = 1, adjustment = 1)) - assertEquals(32, adjustedCleanPlaybackSeekRate(currentRate = 16, adjustment = 1)) + // A 90-minute item; its ladder ceiling is well above these rungs. + val durationSec = 5_400.0 + assertEquals(4, adjustedCleanPlaybackSeekRate(2, adjustment = 1, durationSec = durationSec)) + assertEquals(2, adjustedCleanPlaybackSeekRate(4, adjustment = -1, durationSec = durationSec)) + assertEquals(-4, adjustedCleanPlaybackSeekRate(-2, adjustment = -1, durationSec = durationSec)) + assertEquals(-2, adjustedCleanPlaybackSeekRate(-4, adjustment = 1, durationSec = durationSec)) + assertEquals(32, adjustedCleanPlaybackSeekRate(16, adjustment = 1, durationSec = durationSec)) } @Test - fun rateAdjustmentClampsAtBothEnds() { - assertEquals(-32, adjustedCleanPlaybackSeekRate(currentRate = -32, adjustment = -1)) - assertEquals(32, adjustedCleanPlaybackSeekRate(currentRate = 32, adjustment = 1)) + fun steppingBelowTheBaseRateStopsRatherThanReversingDirection() { + // The old signed ladder ran ... -1, 1 ... so stepping "slower" past the + // bottom silently flipped a forward scan into a backward one. + val durationSec = 5_400.0 + assertEquals(2, adjustedCleanPlaybackSeekRate(2, adjustment = -1, durationSec = durationSec)) + assertEquals(-2, adjustedCleanPlaybackSeekRate(-2, adjustment = 1, durationSec = durationSec)) } @Test - fun previewAdvancesByAppleParityBaseStepAndRate() { + fun rateAdjustmentClampsAtTheItemsDerivedCeiling() { + // 90 minutes needs ceil(5400 / 10) = 540x to cross in the target time, + // which rounds up to the 1024 rung. + val durationSec = 5_400.0 + assertEquals(1024, adjustedCleanPlaybackSeekRate(1024, adjustment = 1, durationSec = durationSec)) + assertEquals(-1024, adjustedCleanPlaybackSeekRate(-1024, adjustment = -1, durationSec = durationSec)) + } + + @Test + fun shortContentGetsALowerCeilingThanAFeature() { + // A 22-minute episode: ceil(1320 / 10) = 132x, rounded up to 256. + val episodeSec = 1_320.0 + assertEquals(256, adjustedCleanPlaybackSeekRate(256, adjustment = 1, durationSec = episodeSec)) + } + + @Test + fun previewAdvancesByExactlyRateTimesRealTime() { + // 100ms tick, so one tick at 8x covers 0.8s of content — not the 16s + // the old flat 2s-per-tick base step produced for the same "8x" chip. assertEquals( - 116.0, + 100.8, advanceCleanPlaybackSeekPreview(previewSec = 100.0, durationSec = 500.0, rate = 8), ) assertEquals( - 92.0, + 99.6, advanceCleanPlaybackSeekPreview(previewSec = 100.0, durationSec = 500.0, rate = -4), ) } @Test fun previewClampsToKnownTimelineBounds() { + // Rates large enough that a single tick overshoots each end. assertEquals( 0.0, - advanceCleanPlaybackSeekPreview(previewSec = 1.0, durationSec = 500.0, rate = -1), + advanceCleanPlaybackSeekPreview(previewSec = 1.0, durationSec = 500.0, rate = -64), ) assertEquals( 500.0, - advanceCleanPlaybackSeekPreview(previewSec = 499.0, durationSec = 500.0, rate = 1), + advanceCleanPlaybackSeekPreview(previewSec = 499.0, durationSec = 500.0, rate = 64), ) } @Test fun unknownDurationStillAllowsForwardPreview() { assertEquals( - 12.0, - advanceCleanPlaybackSeekPreview(previewSec = 10.0, durationSec = 0.0, rate = 1), + 10.2, + advanceCleanPlaybackSeekPreview(previewSec = 10.0, durationSec = 0.0, rate = 2), ) } } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt new file mode 100644 index 000000000..4611e5007 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt @@ -0,0 +1,171 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.prairieserver.prairie.common.player.video.EpisodeSelectionHandoff +import org.prairieserver.prairie.common.player.video.EpisodeSourceIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleIntent +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.catalog.SubtitleTrack + +class TvEpisodeHandoffPlaybackStartTest { + @Test + fun explicitDetailFileIdWinsOverEpisodeHandoff() { + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = 1080, + episodeSelectionHandoff = handoff(sourceResolution = "2160p"), + targetVersions = versions(), + targetLastFileId = 2160, + preferredQuality = "2160p", + ) + + assertEquals(1080, resolved.fileId) + } + + @Test + fun episodeHandoffWinsOverTargetLastFileAndQuality() { + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = handoff(sourceResolution = "1080p"), + targetVersions = versions(), + targetLastFileId = 2160, + preferredQuality = "2160p", + ) + + assertEquals(1080, resolved.fileId) + } + + @Test + fun noHandoffPreservesExistingLastFileAndQualitySelection() { + val lastFileResolved = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = null, + targetVersions = versions(), + targetLastFileId = 2160, + preferredQuality = "1080p", + ) + val qualityResolved = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = null, + targetVersions = versions(), + targetLastFileId = null, + preferredQuality = "1080p", + ) + + assertEquals(2160, lastFileResolved.fileId) + assertEquals(1080, qualityResolved.fileId) + } + + @Test + fun subtitleIsResolvedAgainstTheChosenTargetVersion() { + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = null, + episodeSelectionHandoff = handoff( + sourceResolution = "1080p", + subtitle = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "nl", + codecFamily = "subrip", + external = false, + ), + ), + targetVersions = versions(), + targetLastFileId = 2160, + preferredQuality = "2160p", + ) + + assertEquals(1080, resolved.fileId) + assertEquals(1, resolved.subtitleTrackIndex) + assertTrue(resolved.subtitleIntentSpecified) + } + + @Test + fun missingExplicitSubtitleReturnsSpecifiedProfileAuto() { + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = 1080, + episodeSelectionHandoff = handoff( + subtitle = EpisodeSubtitleIntent( + mode = EpisodeSubtitleMode.TRACK, + language = "fr", + ), + ), + targetVersions = versions(), + targetLastFileId = null, + preferredQuality = null, + ) + + assertNull(resolved.subtitleTrackIndex) + assertTrue(resolved.subtitleIntentSpecified) + } + + @Test + fun explicitOffIsRetainedClientSide() { + val handoff = handoff(subtitle = EpisodeSubtitleIntent.off()) + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = 1080, + episodeSelectionHandoff = handoff, + targetVersions = versions(), + targetLastFileId = null, + preferredQuality = null, + ) + + assertEquals(-1, resolved.subtitleTrackIndex) + assertTrue(resolved.subtitleIntentSpecified) + assertNull( + resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff = handoff, + resolvedEpisodeSelection = resolved, + requestedSubtitleTrackIndex = 4, + ), + ) + } + + @Test + fun automaticHandoffDropsStaleRequestedSubtitleIndex() { + val handoff = handoff() + val resolved = resolveTvPlaybackStartSelection( + preferredFileId = 1080, + episodeSelectionHandoff = handoff, + targetVersions = versions(), + targetLastFileId = null, + preferredQuality = null, + ) + + assertNull( + resolveTvServerSubtitleTrackIndex( + episodeSelectionHandoff = handoff, + resolvedEpisodeSelection = resolved, + requestedSubtitleTrackIndex = 4, + ), + ) + } + + private fun handoff( + sourceResolution: String? = null, + subtitle: EpisodeSubtitleIntent = EpisodeSubtitleIntent.auto(), + ) = EpisodeSelectionHandoff( + source = sourceResolution?.let(::EpisodeSourceIntent), + subtitle = subtitle, + ) + + private fun versions() = listOf( + FileVersion( + fileId = 2160, + resolution = "2160p", + subtitleTracks = listOf( + SubtitleTrack(index = 9, language = "nl", codec = "srt", external = true), + ), + ), + FileVersion( + fileId = 1080, + resolution = "1080p", + subtitleTracks = listOf( + SubtitleTrack(index = 17, language = "en", codec = "srt", external = true), + SubtitleTrack(index = 18, language = "nl", codec = "srt", external = false), + ), + ), + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvFireTvRcFeedbackOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvFireTvRcFeedbackOwnershipTest.kt new file mode 100644 index 000000000..2fb113115 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvFireTvRcFeedbackOwnershipTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvFireTvRcFeedbackOwnershipTest { + + @Test + fun `release display version keeps the complete tag`() { + val gradle = source("build.gradle.kts") + val workflow = source("../.github/workflows/release.yml") + + assertTrue(gradle.contains("PRAIRIE_DISPLAY_VERSION")) + assertTrue(gradle.contains("\"DISPLAY_VERSION\"")) + assertTrue(workflow.contains("PRAIRIE_DISPLAY_VERSION: \${{ needs.setup.outputs.version }}")) + } + + private fun source(path: String): String = File(path).readText() +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt new file mode 100644 index 000000000..3756fffdb --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt @@ -0,0 +1,48 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class TvHudPickerFocusWiringSourceTest { + private val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt", + ).readText() + private val pickerDialog = composableBody( + start = "internal fun HudPickerDialog(", + end = "@Composable\nprivate fun HudPickerOptionRow(", + ) + private val pickerOptionRow = composableBody( + start = "private fun HudPickerOptionRow(", + end = "private fun formatTime", + ) + + @Test + fun focusedPickerRowsBringTheirOwnFocusedRowIntoView() { + assertContains(pickerOptionRow, "val bringIntoViewRequester = remember { BringIntoViewRequester() }") + assertContains(pickerOptionRow, ".bringIntoViewRequester(bringIntoViewRequester)") + + val focusHandler = pickerOptionRow.substringAfter(".onFocusChanged { state ->") + .substringBefore(".clickable(") + assertContains(focusHandler, "if (state.isFocused)") + + val focusedBranch = focusHandler.substringAfter("if (state.isFocused)") + assertContains(focusedBranch, "onFocused()") + assertContains(focusedBranch, "scope.launch { bringIntoViewRequester.bringIntoView() }") + } + + @Test + fun pickerKeepsTheEagerFocusGraph() { + assertContains(pickerDialog, "Column(") + assertContains(pickerDialog, ".verticalScroll(rememberScrollState())") + assertFalse(Regex("\\bLazyColumn\\s*\\(").containsMatchIn(pickerDialog.withoutComments())) + } + + private fun composableBody(start: String, end: String): String = + source.substringAfter(start).substringBefore(end) + + private fun String.withoutComments(): String = + replace(Regex("/\\*.*?\\*/", setOf(RegexOption.DOT_MATCHES_ALL)), "") + .replace(Regex("//.*$", setOf(RegexOption.MULTILINE)), "") +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt new file mode 100644 index 000000000..4f1f22a74 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt @@ -0,0 +1,303 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.common.player.video.EpisodeSubtitleMode +import org.prairieserver.prairie.common.player.video.ResolvedEpisodeSelection +import org.prairieserver.prairie.common.player.video.encodeEpisodeSelectionHandoff +import org.prairieserver.prairie.model.catalog.FileVersion +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.watchtogether.shouldNavigateToLocalNext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvPlayNextSelectionHandoffTest { + @Test + fun serverUnreachableStartKeepsHandoffForOwnedRetry() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "1080p"), + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + val slot = TvEpisodeSelectionHandoffSlot(handoff) + + val failedStart = requireNotNull(slot.leaseForStart(ownerGeneration = 1L)) + assertEquals(handoff, failedStart.handoff) + assertTrue(slot.retainForRetry(failedStart)) + + assertEquals(handoff, slot.leaseForStart(ownerGeneration = 2L)?.handoff) + } + + @Test + fun startErrorKeepsHandoffForOwnedRetry() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "2160p"), + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 3), + catalogSubtitles = listOf(subtitle(index = 3, language = "nl", codec = "srt")), + hasExplicitSubtitleSelection = true, + ) + val slot = TvEpisodeSelectionHandoffSlot(handoff) + + val failedStart = requireNotNull(slot.leaseForStart(ownerGeneration = 7L)) + assertTrue(slot.retainForRetry(failedStart)) + + assertEquals(handoff, slot.leaseForStart(ownerGeneration = 8L)?.handoff) + } + + @Test + fun successfulReadyAcknowledgesHandoffOnlyForItsLease() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "1080p"), + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + val slot = TvEpisodeSelectionHandoffSlot(handoff) + val readyStart = requireNotNull(slot.leaseForStart(ownerGeneration = 11L)) + + assertTrue(slot.acknowledgeReady(readyStart)) + assertNull(slot.leaseForStart(ownerGeneration = 12L)) + assertTrue(slot.acknowledgeReady(readyStart).not(), "a Ready completion can ack only once") + } + + @Test + fun explicitReplacementInvalidatesHandoffAndStaleCompletionCannotRestoreIt() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "1080p"), + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + val slot = TvEpisodeSelectionHandoffSlot(handoff) + val staleStart = requireNotNull(slot.leaseForStart(ownerGeneration = 21L)) + + slot.invalidate() + + assertTrue(slot.retainForRetry(staleStart).not()) + assertTrue(slot.acknowledgeReady(staleStart).not()) + assertNull(slot.leaseForStart(ownerGeneration = 22L)) + } + + @Test + fun newerLaunchOwnerInvalidatesOlderLease() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "1080p"), + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + val slot = TvEpisodeSelectionHandoffSlot(handoff) + val staleStart = requireNotNull(slot.leaseForStart(ownerGeneration = 31L)) + + assertNull(slot.leaseForStart(ownerGeneration = 32L)) + assertTrue(slot.retainForRetry(staleStart).not()) + assertTrue(slot.acknowledgeReady(staleStart).not()) + } + + @Test + fun nextEpisodeCapturesCurrentSourceAndCommittedSubtitleSemantics() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion( + fileId = 42, + resolution = "2160p", + codecVideo = "hevc", + container = "mkv", + ), + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 9), + catalogSubtitles = listOf(subtitle(index = 9, language = "nl", codec = "srt")), + hasExplicitSubtitleSelection = true, + ) + + assertEquals("2160p", handoff.source?.resolution) + assertEquals("hevc", handoff.source?.videoCodec) + assertEquals("nl", handoff.subtitle.language) + assertEquals("subrip", handoff.subtitle.codecFamily) + assertTrue(handoff.toString().contains("42").not(), "episode-local file IDs must not cross episodes") + assertTrue(handoff.toString().contains("9").not(), "episode-local subtitle indexes must not cross episodes") + } + + @Test + fun nextEpisodeCarriesExplicitOff() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + + assertEquals(EpisodeSubtitleMode.OFF, handoff.subtitle.mode) + } + + @Test + fun nextEpisodeCarriesAutoWhenNoExplicitSubtitleWasCommitted() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.ServerSidecar(serverIndex = 9), + catalogSubtitles = listOf(subtitle(index = 9, language = "nl", codec = "srt")), + hasExplicitSubtitleSelection = false, + ) + + assertEquals(EpisodeSubtitleMode.AUTO, handoff.subtitle.mode) + } + + @Test + fun downloadedPlaybackDropsDownloadIdentityButKeepsPortableMediaSemantics() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion( + fileId = 42, + fileName = "source-42.mkv", + filePath = "/media/source-42.mkv", + resolution = "1080p", + codecVideo = "h264", + ), + committedSubtitleIdentity = SubtitleIdentity.Downloaded( + downloadId = 777, + media = SubtitleMediaIdentity( + trackId = "download-track-777", + language = "NL", + codecFamily = "srt", + forced = true, + hearingImpaired = true, + ), + ), + catalogSubtitles = listOf(subtitle(index = 9, language = "nl", codec = "srt")), + hasExplicitSubtitleSelection = true, + ) + + assertEquals("1080p", handoff.source?.resolution) + assertEquals(EpisodeSubtitleMode.TRACK, handoff.subtitle.mode) + assertEquals("nl", handoff.subtitle.language) + assertEquals("subrip", handoff.subtitle.codecFamily) + assertEquals(true, handoff.subtitle.forced) + assertEquals(true, handoff.subtitle.hearingImpaired) + assertEquals(true, handoff.subtitle.external) + val encoded = encodeEpisodeSelectionHandoff(handoff) + assertTrue(encoded.contains("777").not(), "download identity is episode-local") + assertTrue(encoded.contains("download-track").not(), "Media3 identity is episode-local") + assertTrue(encoded.contains("fileId").not(), "file identity is episode-local") + assertTrue(encoded.contains("index").not(), "subtitle indexes are episode-local") + assertTrue(encoded.contains("source-42").not(), "file name and path are episode-local") + assertTrue(encoded.contains("example.test").not(), "subtitle URLs are episode-local") + } + + @Test + fun localMedia3PlaybackKeepsPortableMediaSemanticsWithoutTrackId() { + val handoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.LocalMedia3( + SubtitleMediaIdentity( + trackId = "media3-opaque-id", + language = "EN-us", + codecFamily = "webvtt", + forced = false, + hearingImpaired = true, + ), + ), + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ) + + assertEquals(EpisodeSubtitleMode.TRACK, handoff.subtitle.mode) + assertEquals("en", handoff.subtitle.language) + assertEquals("webvtt", handoff.subtitle.codecFamily) + assertEquals(false, handoff.subtitle.forced) + assertEquals(true, handoff.subtitle.hearingImpaired) + assertNull(handoff.subtitle.external) + assertTrue(encodeEpisodeSelectionHandoff(handoff).contains("media3-opaque-id").not()) + } + + @Test + fun watchTogetherStillSuppressesSoloAutoAdvance() { + assertTrue(shouldNavigateToLocalNext(inWatchTogetherRoom = false)) + assertTrue(shouldNavigateToLocalNext(inWatchTogetherRoom = true).not()) + } + + @Test + fun profileOrServerReplacementDoesNotReuseAnOldHandoff() { + val slot = TvEpisodeSelectionHandoffSlot( + captureTvEpisodeSelectionHandoff( + activeVersion = FileVersion(fileId = 42, resolution = "1080p"), + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ), + ) + + assertEquals( + EpisodeSubtitleMode.OFF, + slot.leaseForStart(ownerGeneration = 1L)?.handoff?.subtitle?.mode, + ) + slot.invalidate() + assertNull( + slot.leaseForStart(ownerGeneration = 2L), + "profile/server replacement starts must not reuse a prior episode handoff", + ) + } + + @Test + fun resolvedExplicitTargetSubtitleBlocksDurableTargetRestore() { + val application = resolveTvEpisodeInitialSubtitleSelection( + episodeSelectionHandoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ), + resolvedEpisodeSelection = ResolvedEpisodeSelection( + fileId = 1080, + subtitleTrackIndex = null, + subtitleIntentSpecified = true, + ), + existingPendingInitialSubtitleIndex = 4, + ) + + assertNull(application.pendingInitialSubtitleIndex) + assertTrue(application.suppressDurableSubtitleRestore) + } + + @Test + fun resolvedOffAppliesMedia3OffAndAutoPreservesDurableRestore() { + val off = resolveTvEpisodeInitialSubtitleSelection( + episodeSelectionHandoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = true, + ), + resolvedEpisodeSelection = ResolvedEpisodeSelection( + fileId = null, + subtitleTrackIndex = -1, + subtitleIntentSpecified = true, + ), + existingPendingInitialSubtitleIndex = null, + ) + val automatic = resolveTvEpisodeInitialSubtitleSelection( + episodeSelectionHandoff = captureTvEpisodeSelectionHandoff( + activeVersion = null, + committedSubtitleIdentity = SubtitleIdentity.Off, + catalogSubtitles = emptyList(), + hasExplicitSubtitleSelection = false, + ), + resolvedEpisodeSelection = ResolvedEpisodeSelection( + fileId = null, + subtitleTrackIndex = null, + subtitleIntentSpecified = false, + ), + existingPendingInitialSubtitleIndex = null, + ) + + assertEquals(-1, off.pendingInitialSubtitleIndex) + assertTrue(off.suppressDurableSubtitleRestore) + assertTrue(automatic.suppressDurableSubtitleRestore.not()) + } + + private fun subtitle(index: Int, language: String, codec: String) = PlayerSubtitleInfo( + index = index, + language = language, + codec = codec, + url = "https://example.test/$index.$codec", + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackExitSnapshotTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackExitSnapshotTest.kt new file mode 100644 index 000000000..66669f938 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackExitSnapshotTest.kt @@ -0,0 +1,64 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.playback.PlaybackTimeline +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvPlaybackExitSnapshotTest { + @Test + fun suppliedFinalPlayerSampleReplacesStaleState() { + val snapshot = resolveTvPlaybackExitSnapshot( + currentPositionSeconds = 12.0, + currentDurationSeconds = 100.0, + positionMs = 37_000, + durationMs = 120_000, + timeline = null, + serverDurationSeconds = 0.0, + ) + + assertEquals(TvPlaybackExitSnapshot(37.0, 120.0), snapshot) + } + + @Test + fun missingFinalSamplePreservesCurrentState() { + val snapshot = resolveTvPlaybackExitSnapshot( + currentPositionSeconds = 37.0, + currentDurationSeconds = 120.0, + positionMs = null, + durationMs = null, + timeline = null, + serverDurationSeconds = 0.0, + ) + + assertEquals(TvPlaybackExitSnapshot(37.0, 120.0), snapshot) + } + + @Test + fun finalPlayerSampleRetainsReanchoredSourceCoordinates() { + val snapshot = resolveTvPlaybackExitSnapshot( + currentPositionSeconds = 3_001.0, + currentDurationSeconds = 3_600.0, + positionMs = 5_000, + durationMs = 600_000, + timeline = PlaybackTimeline(timelineOffsetSeconds = 3_000.0), + serverDurationSeconds = 3_600.0, + ) + + assertEquals(TvPlaybackExitSnapshot(3_005.0, 3_600.0), snapshot) + } + + @Test + fun protocolV3DoesNotSubstitutePlayerDurationWhenSourceDurationIsUnknown() { + val snapshot = resolveTvPlaybackExitSnapshot( + currentPositionSeconds = 3_001.0, + currentDurationSeconds = 0.0, + positionMs = 5_000, + durationMs = 600_000, + timeline = PlaybackTimeline(timelineOffsetSeconds = 3_000.0), + serverDurationSeconds = 0.0, + allowPlayerDuration = false, + ) + + assertEquals(TvPlaybackExitSnapshot(3_005.0, 0.0), snapshot) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackFreshLoadOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackFreshLoadOwnershipTest.kt index 1d2b6f743..195e491ee 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackFreshLoadOwnershipTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackFreshLoadOwnershipTest.kt @@ -128,6 +128,31 @@ class TvPlaybackFreshLoadOwnershipTest { assertTrue(rollback.contains("subtitleTransactions.resetContent(")) } + @Test + fun `retryable Ready failures retain the episode handoff before exposing retry UI`() { + val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt", + ).readText() + val ready = source + .substringAfter("is VideoPlayerUiState.Ready ->") + .substringBefore("is VideoPlayerUiState.Error ->") + val missingSession = ready + .substringAfter("?: run {") + .substringBefore("unpublishedReadySession.acquire") + val failedPublication = ready + .substringAfter("if (!jointlyConfirmed) {") + .substringBefore("if (result.resolvedEpisodeSelection != null)") + + assertTrue( + missingSession.indexOf("episodeSelectionHandoffSlot.retainForRetry(") in + 0 until missingSession.indexOf("fail(\"Playback start returned no session.\")"), + ) + assertTrue( + failedPublication.indexOf("episodeSelectionHandoffSlot.retainForRetry(") in + 0 until failedPublication.indexOf("fail(\"Playback publication could not be confirmed.\")"), + ) + } + @Test fun `post Ready local selection hydration and publish exceptions rollback exactly once`() = runTest { diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt new file mode 100644 index 000000000..9943786ec --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackQualityOptionsTest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.playback.PlaybackAvailableQualityV3 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvPlaybackQualityOptionsTest { + @Test + fun authoritativeMenuPreservesServerMembershipOrderAndLabels() { + val options = authoritativePlaybackQualityOptions( + available = listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + PlaybackAvailableQualityV3("720p", 720, 4_000), + ), + selectedLabel = "720p", + ) + + assertEquals(listOf("original", "720p"), options.map { it.id }) + assertEquals(listOf("original", "720p"), options.map { it.label }) + assertFalse(options.first().isSelected) + assertTrue(options.last().isSelected) + } + + @Test + fun autoIntentDoesNotInventASelectedServerRow() { + val options = authoritativePlaybackQualityOptions( + available = listOf( + PlaybackAvailableQualityV3("original", 1080, 8_000, preservesSource = true), + ), + selectedLabel = "auto", + ) + + assertEquals(listOf("original"), options.map { it.id }) + assertFalse(options.single().isSelected) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackSourceStartTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackSourceStartTest.kt new file mode 100644 index 000000000..2c25e780b --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackSourceStartTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvPlaybackSourceStartTest { + @Test + fun adoptedSourceStartUsesTheRewoundServerRequest() { + assertEquals( + 593.0, + resolveTvSourceStartPosition( + startRequestPosition = 593.0, + serverSourceStartPosition = 600.0, + playerStartPosition = 0.0, + ), + ) + } + + @Test + fun explicitStartOverKeepsZeroSourceStart() { + assertEquals( + 0.0, + resolveTvSourceStartPosition( + startRequestPosition = 0.0, + serverSourceStartPosition = 600.0, + playerStartPosition = 0.0, + ), + ) + } + + @Test + fun serverSourceAnchorWinsWhenNoPositionWasRequested() { + assertEquals( + 3_005.0, + resolveTvSourceStartPosition( + startRequestPosition = null, + serverSourceStartPosition = 3_005.0, + playerStartPosition = 5.0, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerBackendLifecycleSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerBackendLifecycleSourceTest.kt new file mode 100644 index 000000000..dfd9089de --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerBackendLifecycleSourceTest.kt @@ -0,0 +1,25 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvPlayerBackendLifecycleSourceTest { + private val sourceFile = java.io.File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt", + ) + + @Test + fun backendOwnershipFollowsTheActualPlayerRatherThanPlaybackRouteState() { + val source = sourceFile.readText() + + assertTrue(source.contains("val backendPlayer = sessionPlayer ?: mediaController")) + assertTrue(source.contains("val videoBackend = remember(backendPlayer, backendFactory)")) + } + + @Test + fun v3MountDoesNotAttachTheCompleteSubtitlePickerInventory() { + val source = sourceFile.readText() + + assertTrue(source.contains("subtitleIdentity = state.pendingSubtitleIdentity")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt index 98ef3705c..b63a18d08 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt @@ -62,30 +62,46 @@ class TvPlayerRemoteKeyActionTest { } @Test - fun downMovesFocusToTransportAndMenuAndSettingsOpenHudFromIdleOverlay() { - // tvOS parity (QA 2026-07-08): with nothing on screen, Down opens the - // hover menu (HUD); with a focus-owning overlay up it still routes - // focus into the transport. + fun `down opens the playback hud from clean playback`() { assertEquals( - TvPlayerRemoteKeyAction.OpenHud, + TvPlayerRemoteKeyAction.OpenPlaybackHud, tvPlayerRemoteKeyAction( keyCode = KeyEvent.KEYCODE_DPAD_DOWN, action = KeyEvent.ACTION_DOWN, repeatCount = 0, + dpadDownOpensHud = true, ), ) + } + + @Test + fun `down still reaches the transport once the overlay is up`() { + // With chrome visible Down is the press that moves focus into the + // button row under the scrubber; taking it for the HUD would strand + // the transport. assertEquals( TvPlayerRemoteKeyAction.FocusTransport, tvPlayerRemoteKeyAction( keyCode = KeyEvent.KEYCODE_DPAD_DOWN, action = KeyEvent.ACTION_DOWN, repeatCount = 0, - dpadHorizontalSeek = false, ), ) + assertEquals( + TvPlayerRemoteKeyAction.FocusTransport, + tvPlayerIdleOverlayRemoteKeyAction( + keyCode = KeyEvent.KEYCODE_DPAD_DOWN, + action = KeyEvent.ACTION_DOWN, + repeatCount = 0, + ), + ) + } + + @Test + fun `menu and settings keys open the settings hud`() { listOf(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS).forEach { keyCode -> assertEquals( - TvPlayerRemoteKeyAction.OpenHud, + TvPlayerRemoteKeyAction.OpenSettingsHud, tvPlayerRemoteKeyAction( keyCode = keyCode, action = KeyEvent.ACTION_UP, @@ -95,6 +111,29 @@ class TvPlayerRemoteKeyActionTest { } } + @Test + fun `repeated down is consumed without refocusing transport`() { + assertEquals( + TvPlayerRemoteKeyAction.ConsumeOnly, + tvPlayerRemoteKeyAction( + keyCode = KeyEvent.KEYCODE_DPAD_DOWN, + action = KeyEvent.ACTION_DOWN, + repeatCount = 1, + ), + ) + // Auto-repeat must not reopen the HUD either — a held Down would + // otherwise fire OpenPlaybackHud on every repeat. + assertEquals( + TvPlayerRemoteKeyAction.ConsumeOnly, + tvPlayerRemoteKeyAction( + keyCode = KeyEvent.KEYCODE_DPAD_DOWN, + action = KeyEvent.ACTION_DOWN, + repeatCount = 1, + dpadDownOpensHud = true, + ), + ) + } + @Test fun leftAndRightSeekDuringPlaybackInsteadOfOpeningChrome() { assertEquals( @@ -194,7 +233,7 @@ class TvPlayerRemoteKeyActionTest { ), ) assertEquals( - TvPlayerRemoteKeyAction.OpenHud, + TvPlayerRemoteKeyAction.OpenSettingsHud, tvPlayerIdleOverlayRemoteKeyAction( keyCode = KeyEvent.KEYCODE_MENU, action = KeyEvent.ACTION_UP, @@ -203,6 +242,22 @@ class TvPlayerRemoteKeyActionTest { ) } + @Test + fun `playback entry point prefers audio then subtitles then video`() { + assertEquals( + HudTab.Audio, + preferredPlaybackHudTab(hasAudioTracks = true, hasSubtitleTracks = true), + ) + assertEquals( + HudTab.Subtitles, + preferredPlaybackHudTab(hasAudioTracks = false, hasSubtitleTracks = true), + ) + assertEquals( + HudTab.Video, + preferredPlaybackHudTab(hasAudioTracks = false, hasSubtitleTracks = false), + ) + } + @Test fun nonMatchingActionsAndUnhandledKeysFallThrough() { assertNull( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt index 0dd6d2426..5075a8a2d 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerSubtitleIntegrationPolicyTest.kt @@ -2,13 +2,22 @@ package org.prairieserver.prairie.tv.ui.screens.player import kotlin.test.Test import kotlin.test.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.model.catalog.AudioTrack +import org.prairieserver.prairie.model.playback.CommittedSubtitle import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.playback.audioTrackFingerprint +import org.prairieserver.prairie.playback.encodeSubtitleIdentityPreference import org.prairieserver.prairie.repository.port.TrackSelectionFingerprintUpdate import kotlin.test.assertIs +import kotlin.test.assertTrue +@OptIn(ExperimentalCoroutinesApi::class) class TvPlayerSubtitleIntegrationPolicyTest { @Test fun `unresolved audio during subtitle persistence preserves the existing preference`() { @@ -22,15 +31,19 @@ class TvPlayerSubtitleIntegrationPolicyTest { @Test fun `resolved audio during subtitle persistence writes the exact fingerprint`() { - val selected = AudioTrack(index = 7, language = "ja", codec = "ac3") + // The committed value is an ORDINAL. Resolving it against + // AudioTrack.index matched nothing above 0, so the chosen track was + // silently never persisted and reopening the item lost it. + val english = AudioTrack(language = "en", codec = "aac") + val japanese = AudioTrack(language = "ja", codec = "ac3") val update = tvAudioTrackPersistenceUpdate( - committedAudioTrackIndex = 7, - audioTracks = listOf(selected), + committedAudioTrackIndex = 1, + audioTracks = listOf(english, japanese), ) assertEquals( - TrackSelectionFingerprintUpdate.Set(audioTrackFingerprint(selected)), + TrackSelectionFingerprintUpdate.Set(audioTrackFingerprint(japanese)), update, ) } @@ -56,6 +69,77 @@ class TvPlayerSubtitleIntegrationPolicyTest { ) } + @Test + fun `fresh restore resolves an exact authoritative downloaded sidecar`() { + val plannedRow = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "vtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "/stream/s1/subtitles/4.vtt", + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ) + val eventRow = plannedRow.copy(downloadId = 91) + val persistedIdentity = assertIs( + tvSubtitleIdentity(eventRow), + ) + val plannedIdentity = assertIs( + tvSubtitleIdentity(plannedRow), + ) + + assertEquals( + TvFreshSubtitlePreferenceResolution(plannedIdentity), + resolveTvFreshSubtitlePreference( + preference = encodeSubtitleIdentityPreference(persistedIdentity), + catalogTracks = emptyList(), + hydratedRows = listOf(plannedRow), + ), + ) + assertEquals("file:22:subtitle:4", persistedIdentity.media?.trackId) + } + + @Test + fun `fresh restore migrates a legacy downloaded identity by unique plan metadata`() { + val legacy = SubtitleIdentity.Downloaded( + downloadId = 91, + media = org.prairieserver.prairie.model.playback.SubtitleMediaIdentity( + trackId = "silo-downloaded-subtitle:91", + label = "Downloaded English", + language = "en", + codecFamily = "webvtt", + forced = false, + hearingImpaired = false, + ), + ) + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "vtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "/stream/s1/subtitles/4.vtt", + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ) + val exact = tvSubtitleIdentity(row) + + assertEquals( + TvFreshSubtitlePreferenceResolution( + identity = exact, + migratedPreference = encodeSubtitleIdentityPreference(exact), + ), + resolveTvFreshSubtitlePreference( + preference = encodeSubtitleIdentityPreference(legacy), + catalogTracks = emptyList(), + hydratedRows = listOf(row), + ), + ) + } + @Test fun `download auto selection uses the same canonical identity as the HUD row`() { val row = downloadedRow(index = 4, downloadId = 91) @@ -147,7 +231,7 @@ class TvPlayerSubtitleIntegrationPolicyTest { isSelected = false, displayLabel = "English", codecOrMime = "srt", - trackId = "prairie-subtitle:8", + trackId = "silo-subtitle:8", ) val identity = resolveTvRemoteSubtitleIntent( @@ -172,16 +256,18 @@ class TvPlayerSubtitleIntegrationPolicyTest { } @Test - fun `T92 remote audio intent resolves the stable server index for the adapter`() { - val identity = resolveTvRemoteAudioIntent( - playerOrdinal = 1, - audioTracks = listOf( - AudioTrack(index = 3, language = "en", codec = "aac"), - AudioTrack(index = 9, language = "ja", codec = "ac3"), - ), + fun `T92 remote audio intent resolves the catalog ordinal for the adapter`() { + // Audio is addressed by ORDINAL — the wire carries no audio index, so + // AudioTrack.index is its 0 default and reading it made every remote + // pick request track 0. + val audioTracks = listOf( + AudioTrack(language = "en", codec = "aac"), + AudioTrack(language = "ja", codec = "ac3"), ) - assertEquals(9, identity) + assertEquals(1, resolveTvRemoteAudioIntent(playerOrdinal = 1, audioTracks = audioTracks)) + assertEquals(0, resolveTvRemoteAudioIntent(playerOrdinal = 0, audioTracks = audioTracks)) + assertEquals(null, resolveTvRemoteAudioIntent(playerOrdinal = 5, audioTracks = audioTracks)) } @Test @@ -203,6 +289,384 @@ class TvPlayerSubtitleIntegrationPolicyTest { ) } + // ---- Single-owner subtitle selection ----------------------------------- + // + // Regression: TV had two independent subtitle authorities. The legacy + // ordinal auto path selected a text track straight at the player while the + // transaction adapter's committed identity never moved, so on an "English – + // Always" profile the PGS track rendered on screen and the HUD said "Off". + // Everything below pins the pieces of the single-owner flow. + + @Test + fun `english always resolves an embedded PGS track to the same identity the HUD checks`() { + val row = embeddedPgsRow() + val track = embeddedPgsTrack() + + val selection = resolveAutoSubtitleSelection( + audioTracks = listOf( + PlayerTrackEntry( + index = 0, + label = "English", + language = "en", + isSelected = true, + ), + ), + subtitleTracks = listOf(track), + preferredLanguage = "en", + subtitleMode = "always", + showForced = true, + ) + + // Bitmap tracks stay deprioritised-but-allowed: it is the only English + // candidate, so Always must still pick it. + val selected = assertIs(selection) + assertEquals(track.index, selected.index) + + val identity = tvMountedSubtitleIdentity(track, listOf(track), listOf(row)) + assertEquals(tvSubtitleIdentity(row), identity) + assertIs(identity) + + // The identity the auto path commits is the identity the HUD ticks. + val presentation = buildTvSubtitleHudPresentation( + options = buildTvSubtitleHudOptions( + subtitleUrls = listOf(row), + subtitleTracks = listOf(track), + ), + committedIdentity = identity, + pendingIdentity = null, + hudOpen = true, + focusedStableId = null, + ) + val checked = presentation.rows.single { it.checked } + assertEquals(identity, checked.identity) + assertEquals(1, presentation.rows.count { it.checked }) + } + + @Test + fun `an automatic pick does not write the durable subtitle preference`() { + val identity = tvSubtitleIdentity(embeddedPgsRow()) + + assertEquals( + TrackSelectionFingerprintUpdate.Preserve, + tvSubtitlePersistenceUpdate( + committedIdentity = identity, + automaticIdentity = identity, + ), + ) + } + + @Test + fun `a viewer pick writes the durable subtitle preference`() { + val automatic = tvSubtitleIdentity(embeddedPgsRow()) + val chosen = SubtitleIdentity.Off + + assertEquals( + TrackSelectionFingerprintUpdate.Set(encodeSubtitleIdentityPreference(chosen)), + // The viewer choosing clears the automatic marker in the ViewModel; + // a stale marker for a different identity must not suppress the write. + tvSubtitlePersistenceUpdate(committedIdentity = chosen, automaticIdentity = automatic), + ) + } + + @Test + fun `reconciliation adopts a text track selected outside the adapter`() { + val row = embeddedPgsRow() + val track = embeddedPgsTrack().copy(isSelected = true) + + assertEquals( + tvSubtitleIdentity(row), + tvExternalSubtitleAdoption( + subtitleTracks = listOf(track), + subtitleRows = listOf(row), + committedIdentity = SubtitleIdentity.Off, + pendingIdentity = null, + selectionInFlight = false, + ), + ) + } + + @Test + fun `reconciliation stands down when the adapter already agrees or is mid-flight`() { + val row = embeddedPgsRow() + val track = embeddedPgsTrack().copy(isSelected = true) + val identity = tvSubtitleIdentity(row) + + assertEquals( + null, + tvExternalSubtitleAdoption( + subtitleTracks = listOf(track), + subtitleRows = listOf(row), + committedIdentity = identity, + pendingIdentity = null, + selectionInFlight = false, + ), + ) + assertEquals( + null, + tvExternalSubtitleAdoption( + subtitleTracks = listOf(track), + subtitleRows = listOf(row), + committedIdentity = SubtitleIdentity.Off, + pendingIdentity = null, + selectionInFlight = true, + ), + ) + assertEquals( + null, + tvExternalSubtitleAdoption( + subtitleTracks = listOf(track), + subtitleRows = listOf(row), + committedIdentity = SubtitleIdentity.Off, + pendingIdentity = identity, + selectionInFlight = false, + ), + ) + // Nothing selected is nothing to reconcile — not an implicit "Off". + assertEquals( + null, + tvExternalSubtitleAdoption( + subtitleTracks = listOf(embeddedPgsTrack()), + subtitleRows = listOf(row), + committedIdentity = identity, + pendingIdentity = null, + selectionInFlight = false, + ), + ) + } + + // ---- Already-mounted picks must never replan --------------------------- + // + // Regression: protocol v3 types EVERY non-burn-in inventory row + // `delivery = sidecar`, including the row that merely describes a track + // muxed into a direct-play stream. The launch auto-pick of an embedded PGS + // track therefore resolved to a ServerSidecar identity, which the mount + // resolver matches by its authored `silo-subtitle:N` id alone — so the + // adapter was told the track on screen was not mounted and staged a server + // replan for it: new session, media-item swap, seconds of rebuffering, and + // a duplicate of the same PGS track re-extracted as a sidecar. + + @Test + fun `a v3 row for a muxed track is a sidecar identity that still resolves in place`() { + val row = planEmbeddedPgsRow() + val track = embeddedPgsTrack() + + // The identity is genuinely ServerSidecar — this is the shape the HUD, + // persistence and the adapter all carry, and it is not being changed. + val identity = assertIs(tvSubtitleIdentity(row)) + assertEquals(identity, tvMountedSubtitleIdentity(track, listOf(track), listOf(row))) + + assertEquals( + track.index, + tvResolveMountedSubtitleTrack( + identity = identity, + subtitleRows = listOf(row), + mounted = listOf(track.toMountedTvSubtitleTrack()), + )?.index, + ) + } + + @Test + fun `a sidecar the player has not mounted still resolves to nothing`() { + val row = unmountedSidecarRow() + + assertEquals( + null, + tvResolveMountedSubtitleTrack( + identity = tvSubtitleIdentity(row), + subtitleRows = listOf(planEmbeddedPgsRow(), row), + mounted = listOf(embeddedPgsTrack().toMountedTvSubtitleTrack()), + ), + ) + } + + @Test + fun `english always commits an already-mounted PGS track in place`() = runTest { + val row = planEmbeddedPgsRow() + val track = embeddedPgsTrack() + val identity = tvSubtitleIdentity(row) + val harness = harness(backgroundScope, rows = listOf(row), mounted = listOf(track)) + + harness.adapter.selectAuto(identity) + runCurrent() + + assertTrue( + harness.staged.isEmpty(), + "an already-mounted track must not ask the server to replan", + ) + assertEquals(identity, harness.adapter.snapshot.localMountIdentity) + + // The mount the adapter armed resolves onto the muxed ordinal… + val remount = SubtitleRemountReselection() + remount.arm(identity, generation = 1L) + val event = assertIs( + remount.consume( + subtitleTracks = listOf(track), + subtitleRows = listOf(row), + snapshotKey = "mounted", + settled = true, + ), + ) + assertEquals(track.index, event.trackIndex) + + // …and acknowledging it commits the identity the HUD ticks. + harness.adapter.reportMountedSelection( + identity = identity, + selected = true, + snapshotKey = "mounted", + settled = true, + ) + runCurrent() + + assertEquals(identity, harness.adapter.snapshot.committedIdentity) + assertTrue(harness.staged.isEmpty()) + val presentation = buildTvSubtitleHudPresentation( + options = buildTvSubtitleHudOptions( + subtitleUrls = listOf(row), + subtitleTracks = listOf(track), + ), + committedIdentity = harness.adapter.snapshot.committedIdentity, + pendingIdentity = harness.adapter.snapshot.pendingIdentity, + hudOpen = true, + focusedStableId = null, + ) + assertEquals(identity, presentation.rows.single { it.checked }.identity) + } + + @Test + fun `an unmounted server sidecar still stages a replan`() = runTest { + val mountedRow = planEmbeddedPgsRow() + val target = unmountedSidecarRow() + val harness = harness( + backgroundScope, + rows = listOf(mountedRow, target), + mounted = listOf(embeddedPgsTrack()), + ) + + harness.adapter.selectAuto(tvSubtitleIdentity(target)) + runCurrent() + + assertEquals( + listOf(target.index), + harness.staged.map { it.subtitleTrackIndex }, + "a subtitle the player has not loaded must still reach the server", + ) + assertEquals(null, harness.adapter.snapshot.localMountIdentity) + } + + private class PolicyHarness( + val adapter: TvSubtitleTransactionAdapter, + val staged: List, + ) + + /** + * Wires the adapter to the PRODUCTION mountability rule — the same + * row-aware resolution `TvPlayerViewModel` installs — so these tests fail + * if that rule stops recognising a mounted track. + */ + private fun harness( + scope: CoroutineScope, + rows: List, + mounted: List, + ): PolicyHarness { + val staged = mutableListOf() + val adapter = TvSubtitleTransactionAdapter( + scope = scope, + stagedPort = object : TvSubtitleStagedReplanPort { + override suspend fun stage( + request: TvSubtitleStageRequest, + ): ApiResult { + staged += request + return ApiResult.Error(500, "unused", "Staging is not exercised here.") + } + + override suspend fun commit( + candidate: TvStagedSubtitleCandidate, + ): ApiResult = + error("The staged replan path must not commit in these tests.") + + override suspend fun discard(candidate: TvStagedSubtitleCandidate) = Unit + + override suspend fun abandonCommitted(playback: TvSubtitleCommittedPlayback) = Unit + }, + persistencePort = object : TvSubtitlePersistencePort { + override suspend fun persist( + committed: CommittedSubtitle, + context: TvSubtitlePlaybackContext, + ): Boolean = true + }, + durablePersistenceScope = scope, + settlementScope = scope, + hasMountableTracks = { mounted.isNotEmpty() }, + isLocallyMountable = { identity -> + tvResolveMountedSubtitleTrack( + identity = identity, + subtitleRows = rows, + mounted = mounted.map { it.toMountedTvSubtitleTrack() }, + ) != null + }, + ) + adapter.resetContent( + context = TvSubtitlePlaybackContext( + contentId = "movie-1", + mediaFileId = 22, + versionId = "22:plan-1", + sessionId = "s1", + positionSeconds = 236.816, + audioTrackIndex = 0, + qualityPreference = "original", + subtitleTracks = rows, + ), + committedIdentity = SubtitleIdentity.Off, + ) + return PolicyHarness(adapter, staged) + } + + /** An embedded PGS track exactly as a v3 plan describes it: delivery `sidecar`. */ + private fun planEmbeddedPgsRow() = embeddedPgsRow().copy( + url = "/stream/s1/subtitles/8.sup", + catalogLabel = "English (SDH)", + catalogSource = "embedded", + mediaTrackId = null, + serverTrackId = "file:22:subtitle:8", + serverDelivery = "sidecar", + ) + + private fun unmountedSidecarRow() = PlayerSubtitleInfo( + index = 9, + language = "nld", + codec = "subrip", + label = "Dutch", + source = "external", + forced = false, + url = "/stream/s1/subtitles/9.vtt", + catalogLabel = "Dutch", + catalogSource = "external", + serverTrackId = "file:22:subtitle:9", + serverDelivery = "sidecar", + ) + + private fun embeddedPgsRow() = PlayerSubtitleInfo( + index = 8, + language = "eng", + codec = "hdmv_pgs_subtitle", + label = "English (SDH)", + source = "embedded", + forced = false, + url = "", + mediaTrackId = "1:pgs:8", + ) + + private fun embeddedPgsTrack() = PlayerTrackEntry( + index = 3, + label = "English (SDH)", + language = "en", + isSelected = false, + displayLabel = "English (SDH)", + codecOrMime = "application/pgs", + isHearingImpaired = true, + trackId = "1:pgs:8", + ) + private fun downloadedRow( index: Int, downloadId: Int?, @@ -215,6 +679,6 @@ class TvPlayerSubtitleIntegrationPolicyTest { forced = false, url = "/subtitles/${downloadId ?: "legacy"}.vtt", downloadId = downloadId, - mediaTrackId = downloadId?.let { "prairie-downloaded-subtitle:$it" }, + mediaTrackId = downloadId?.let { "silo-downloaded-subtitle:$it" }, ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicyTest.kt new file mode 100644 index 000000000..efab25f7d --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicyTest.kt @@ -0,0 +1,23 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvPlayerTransportVisualPolicyTest { + + @Test + fun primaryAndSecondaryControlsMeetMinimumButtonTarget() { + assertTrue(tvTransportControlMetrics(isPrimary = true).buttonSizeDp >= 44f) + assertTrue(tvTransportControlMetrics(isPrimary = false).buttonSizeDp >= 44f) + } + + @Test + fun primaryControlMeetsMinimumGlyphSize() { + assertTrue(tvTransportControlMetrics(isPrimary = true).symbolSizeDp >= 22f) + } + + @Test + fun secondaryControlMeetsMinimumGlyphSize() { + assertTrue(tvTransportControlMetrics(isPrimary = false).symbolSizeDp >= 20f) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicyTest.kt new file mode 100644 index 000000000..914cb6e90 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicyTest.kt @@ -0,0 +1,86 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvQuickSubtitlePickerChromePolicyTest { + @Test + fun selectionClosesPickerAndPlaybackControls() { + assertEquals( + TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = false, + ), + tvQuickSubtitlePickerChromeState(TvQuickSubtitlePickerExit.Selection), + ) + } + + @Test + fun backClosesPickerButKeepsPlaybackControlsVisible() { + assertEquals( + TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = true, + ), + tvQuickSubtitlePickerChromeState(TvQuickSubtitlePickerExit.Back), + ) + } + + @Test + fun selectingAResolvedRowSelectsBeforeDismissingChrome() { + val off = row(SubtitleIdentity.Off, "off") + val english = row(SubtitleIdentity.ServerSidecar(4), "english") + val events = mutableListOf() + val selected = mutableListOf() + + val handled = dispatchTvQuickSubtitlePickerSelection( + presentation = presentation(off, english), + stableId = english.stableId, + onSelect = { identity -> + selected += identity + events += "select" + }, + onSelectionComplete = { events += "dismiss" }, + ) + + assertTrue(handled) + assertEquals(listOf(SubtitleIdentity.ServerSidecar(4)), selected) + assertEquals(listOf("select", "dismiss"), events) + } + + @Test + fun selectingAnUnknownRowKeepsPickerOpen() { + val events = mutableListOf() + + val handled = dispatchTvQuickSubtitlePickerSelection( + presentation = presentation(row(SubtitleIdentity.Off, "off")), + stableId = "unknown", + onSelect = { events += "select" }, + onSelectionComplete = { events += "dismiss" }, + ) + + assertFalse(handled) + assertEquals(emptyList(), events) + } + + private fun presentation(vararg rows: TvSubtitleHudRow): TvSubtitleHudPresentation = + TvSubtitleHudPresentation( + rows = rows.toList(), + hudOpen = true, + focusedStableId = rows.firstOrNull()?.stableId, + focusTrapActive = true, + ) + + private fun row(identity: SubtitleIdentity, stableId: String): TvSubtitleHudRow = + TvSubtitleHudRow( + stableId = stableId, + identity = identity, + label = stableId, + checked = false, + applying = false, + focused = false, + ) +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt new file mode 100644 index 000000000..a5681b2ea --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class TvRoomDeliveryKeySourceTest { + private val reportingStartAnchor = "// Drift reporting loop" + private val reportingEndAnchor = "// ready / buffering during the waiting barrier." + + private val controllerSource = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt", + ).readText() + + @Test + fun stateReportRequiresAnExplicitNonNullDeliveryKey() { + val reportingBlock = reportingBlock(controllerSource) + val guardIndex = reportingBlock.indexOf("deliveryKey != null") + val stateReportIndex = reportingBlock.indexOf("repository.stateReport(") + + assertFalse(reportingBlock.contains("deliveryKey!!")) + assertTrue(guardIndex >= 0, "Drift reporting block must guard deliveryKey explicitly.") + assertTrue(stateReportIndex >= 0, "Drift reporting block must report state.") + assertTrue(guardIndex < stateReportIndex) + } + + @Test + fun reportingBlockFailsClosedWhenEitherAnchorIsMissing() { + assertFailsWith { + reportingBlock(controllerSource.replace(reportingStartAnchor, "")) + } + assertFailsWith { + reportingBlock(controllerSource.replace(reportingEndAnchor, "")) + } + } + + private fun reportingBlock(source: String): String { + val startAnchorIndex = source.indexOf(reportingStartAnchor) + assertTrue(startAnchorIndex >= 0, "Missing drift-reporting start anchor.") + val blockStart = startAnchorIndex + reportingStartAnchor.length + val endAnchorIndex = source.indexOf(reportingEndAnchor, startIndex = blockStart) + assertTrue(endAnchorIndex >= blockStart, "Missing drift-reporting end anchor.") + return source.substring(blockStart, endAnchorIndex) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvScrubPreviewPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvScrubPreviewPolicyTest.kt new file mode 100644 index 000000000..ed40d46cc --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvScrubPreviewPolicyTest.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvScrubPreviewPolicyTest { + @Test + fun unknownDurationDoesNotClampScrubPreviewToZero() { + assertEquals(90.0, clampTvScrubPreview(seconds = 90.0, duration = 0.0)) + } + + @Test + fun knownDurationClampsScrubPreviewToTheSourceRuntime() { + assertEquals(120.0, clampTvScrubPreview(seconds = 150.0, duration = 120.0)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadderTest.kt new file mode 100644 index 000000000..43c89f15a --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSeekRateLadderTest.kt @@ -0,0 +1,179 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TvSeekRateLadderTest { + + private companion object { + const val EPISODE = 2_700.0 // 45 min + const val SHORT_EPISODE = 1_320.0 // 22 min + const val LONG_FILM = 10_800.0 // 3 h + } + + /** + * The defect this ladder exists to prevent: the chip said "8×" while the + * scrubber advanced 2.0s every 100ms, which is 160× real time. A rate must + * mean its own multiple of real time and nothing else. + */ + @Test + fun aRateAdvancesExactlyThatMultipleOfRealTime() { + val tickSeconds = TvSeekRateLadder.TICK_MILLIS / 1000.0 + TvSeekRateLadder.rates.forEach { rate -> + val advancedPerSecond = TvSeekRateLadder.tickSeconds(rate) / tickSeconds + assertEquals(rate.toDouble(), advancedPerSecond, 0.0001, "rate ${rate}x") + } + } + + @Test + fun reverseRatesMirrorForwardOnes() { + TvSeekRateLadder.rates.forEach { rate -> + assertEquals( + -TvSeekRateLadder.tickSeconds(rate), + TvSeekRateLadder.tickSeconds(-rate), + 0.0001, + ) + } + } + + /** + * The point of deriving the ceiling from runtime: holding to the end costs + * roughly the same whether the item is twenty minutes or three hours. A + * fixed 32x ceiling took 41s for a short episode and 338s for a long film. + * + * Asserted against [TvSeekRateLadder.traverseSeconds], which models the + * ramp the implementation actually performs. The previous version computed + * `duration / topRate` — steady-state arithmetic the code never does — and + * so reported 10.55s for a three-hour film that really takes 17.75s, + * passing a 15s tolerance it should have failed. + */ + @Test + fun holdingToTheEndCostsAboutTheSameAtAnyRuntime() { + val durations = listOf(SHORT_EPISODE, EPISODE, 5_400.0, LONG_FILM) + val costs = durations.map { TvSeekRateLadder.traverseSeconds(it) } + + costs.forEachIndexed { index, seconds -> + assertTrue( + seconds <= 20.0, + "a ${durations[index]}s item takes ${seconds}s to cross", + ) + } + assertTrue( + costs.max() / costs.min() <= 2.0, + "runtimes should cost within 2x of each other, got $costs", + ) + } + + /** + * The honest envelope, pinned. If a ladder or cadence change moves these, + * the numbers in TRAVERSE_TARGET_SECONDS' documentation are wrong too. + */ + @Test + fun traversalCostIncludesTheRampNotJustTheCeiling() { + assertEquals(10.56, TvSeekRateLadder.traverseSeconds(SHORT_EPISODE), 0.01) + assertEquals(17.75, TvSeekRateLadder.traverseSeconds(LONG_FILM), 0.01) + assertTrue( + TvSeekRateLadder.traverseSeconds(LONG_FILM) > + LONG_FILM / TvSeekRateLadder.maxRateFor(LONG_FILM), + "the ramp must cost something; steady-state division understates it", + ) + } + + /** + * Protocol v3 declares duration server-side and deliberately does not fall + * back to Media3/catalog, so an omitted duration reaches the ladder as 0. + * That lands on the MIN_TOP_RATE floor rather than a derived ceiling — + * slow for a long item, but the alternative is guessing a ceiling for + * content of unknown length. + */ + @Test + fun anUnknownDurationFallsBackToTheFloorRatherThanGuessing() { + assertEquals(TvSeekRateLadder.MIN_TOP_RATE, TvSeekRateLadder.maxRateFor(0.0)) + assertEquals(TvSeekRateLadder.MIN_TOP_RATE, TvSeekRateLadder.maxRateFor(Double.NaN)) + } + + @Test + fun aLongerItemGetsAFasterTopGear() { + assertTrue( + TvSeekRateLadder.maxRateFor(LONG_FILM) > TvSeekRateLadder.maxRateFor(SHORT_EPISODE), + "a three-hour film must reach further than a twenty-minute episode", + ) + } + + /** + * An unknown runtime must not produce a guessed ceiling; live content and + * un-probed files both arrive as zero. + */ + @Test + fun anUnknownRuntimeFallsBackRatherThanGuessing() { + listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY).forEach { bad -> + assertEquals(TvSeekRateLadder.MIN_TOP_RATE, TvSeekRateLadder.maxRateFor(bad)) + } + } + + /** + * The first second of holding stays slow enough to aim with — that is the + * half of the control used to skip an intro, and the half the old ramp + * destroyed by reaching its top speed in three seconds. + */ + @Test + fun theFirstSecondOfHoldingStaysAimable() { + assertEquals(TvSeekRateLadder.BASE_RATE, 2) + assertTrue(TvSeekRateLadder.RAMP_STEP_MILLIS >= 750L) + val afterFirstStep = TvSeekRateLadder.sustainedRate(0, 1, EPISODE) + assertTrue( + afterFirstStep <= TvSeekRateLadder.AIMABLE_MAX_RATE, + "one second of holding jumped to ${afterFirstStep}x", + ) + } + + @Test + fun aSustainedHoldClimbsToTheItemsCeilingAndStops() { + val ceiling = TvSeekRateLadder.maxRateFor(EPISODE) + val reached = (0 until TvSeekRateLadder.rampSteps(EPISODE)).map { + TvSeekRateLadder.sustainedRate(it, 1, EPISODE) + } + assertEquals(ceiling, reached.last()) + assertTrue(reached.zipWithNext().all { (a, b) -> b >= a }, "the ramp must not go backwards") + } + + @Test + fun sustainedRateFollowsTheHeldDirection() { + (0 until TvSeekRateLadder.rampSteps(EPISODE)).forEach { step -> + assertEquals( + -TvSeekRateLadder.sustainedRate(step, 1, EPISODE), + TvSeekRateLadder.sustainedRate(step, -1, EPISODE), + ) + } + } + + @Test + fun bumpsWalkTheLadderAndClampToTheItemsCeiling() { + assertEquals(4, TvSeekRateLadder.bumped(2, 1, EPISODE)) + assertEquals(2, TvSeekRateLadder.bumped(4, -1, EPISODE)) + + val ceiling = TvSeekRateLadder.maxRateFor(EPISODE) + assertEquals(ceiling, TvSeekRateLadder.bumped(ceiling, 1, EPISODE)) + } + + /** + * delta is a direction along the signed ladder as the key handlers use it, + * so -1 is leftwards: faster when already seeking backwards. The property + * that matters is that a bump never crosses zero and flips direction. + */ + @Test + fun bumpingWhileSeekingBackwardsKeepsTheDirection() { + assertEquals(-4, TvSeekRateLadder.bumped(-2, -1, EPISODE)) + assertEquals(-2, TvSeekRateLadder.bumped(-4, 1, EPISODE)) + assertEquals(-2, TvSeekRateLadder.bumped(-2, 1, EPISODE)) + listOf(-2, -4, -32).forEach { rate -> + listOf(-1, 1).forEach { delta -> + assertTrue( + TvSeekRateLadder.bumped(rate, delta, EPISODE) < 0, + "bumping $rate by $delta flipped direction", + ) + } + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceApplicabilityTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceApplicabilityTest.kt new file mode 100644 index 000000000..4acd2ecc1 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAppearanceApplicabilityTest.kt @@ -0,0 +1,68 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvSubtitleAppearanceApplicabilityTest { + + private fun identity(codec: String?): SubtitleIdentity = + SubtitleIdentity.Embedded( + serverIndex = 2, + media = SubtitleMediaIdentity(codecFamily = codec), + ) + + @Test + fun `text subtitles keep every appearance control`() { + listOf("subrip", "webvtt", "text/vtt", "ass", "ttml").forEach { codec -> + val applicability = tvSubtitleAppearanceApplicability(identity(codec)) + assertTrue(applicability.geometryApplies, codec) + assertTrue(applicability.stylingApplies, codec) + assertNull(applicability.note, codec) + } + } + + @Test + fun `image subtitles keep Position and Size but lose the styling rows`() { + listOf( + "pgs", + "hdmv_pgs_subtitle", + "application/pgs", + "dvbsub", + "dvd_subtitle", + ).forEach { codec -> + val applicability = tvSubtitleAppearanceApplicability(identity(codec)) + assertTrue(applicability.geometryApplies, codec) + assertFalse(applicability.stylingApplies, codec) + assertNotNull(applicability.note, codec) + } + } + + @Test + fun `burned-in subtitles take nothing at all`() { + val applicability = tvSubtitleAppearanceApplicability( + SubtitleIdentity.ServerBurnIn( + serverIndex = 1, + media = SubtitleMediaIdentity(codecFamily = "subrip"), + ), + ) + + assertFalse(applicability.geometryApplies) + assertFalse(applicability.stylingApplies) + assertNotNull(applicability.note) + } + + @Test + fun `Off and an unknown selection fall back to the full appearance block`() { + listOf(null, SubtitleIdentity.Off, identity(null)).forEach { identity -> + val applicability = tvSubtitleAppearanceApplicability(identity) + assertTrue(applicability.geometryApplies) + assertTrue(applicability.stylingApplies) + assertNull(applicability.note) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt new file mode 100644 index 000000000..1420d6295 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt @@ -0,0 +1,42 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvSubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryAnchor = ") as PlayerView).apply {" + val updateAnchor = "update = { view ->" + val endAnchor = "if (!isInPictureInPictureMode" + val factoryIndex = source.indexOf(factoryAnchor) + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf(updateAnchor, factoryIndex) + require(updateIndex > factoryIndex) { "PlayerView update lambda is missing or misordered" } + val endIndex = source.indexOf(endAnchor, updateIndex) + require(endIndex > updateIndex) { "PlayerView update lambda terminator is missing or misordered" } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterFillModeUpdate() { + val source = source( + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + val aspectCall = "applyPlayerViewVideoFillMode(view, state.videoFillMode)" + val subtitleCall = "subtitleManager.syncSubtitleVideoBounds(view)" + assertTrue(update.contains(aspectCall)) + assertTrue(update.contains(subtitleCall)) + assertTrue(update.indexOf(aspectCall) < update.indexOf(subtitleCall)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt new file mode 100644 index 000000000..6fdd71f42 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt @@ -0,0 +1,20 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvSubtitleAspectSyncWiringTest { + @Test + fun televisionSubtitleManagerUsesTelevisionPresentation() { + val source = source("org/prairieserver/prairie/tv/di/AndroidTvModule.kt") + + assertTrue(source.contains("SubtitleManager(\n libassBridge = get(),\n presentation = AndroidSubtitlePresentation.Television,")) + } + + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleFinalRollbackTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleFinalRollbackTest.kt index fcb0c49c6..4259f2292 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleFinalRollbackTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleFinalRollbackTest.kt @@ -288,6 +288,7 @@ class TvSubtitleFinalRollbackTest { stagedPort = port, persistencePort = persistence, durablePersistenceScope = scope, + isLocallyMountable = { identity -> identity == port.mountedSidecarIdentity }, onCommittedPlayback = { adoption -> port.lifecycleSession = adoption.playback.sessionId port.backendIdentity = adoption.committed.identity @@ -364,6 +365,7 @@ private class FinalRollbackStagedPort : TvSubtitleStagedReplanPort { var managerSession: String = "session-1" var lifecycleSession: String = "session-1" var backendIdentity: SubtitleIdentity = SubtitleIdentity.ServerSidecar(3) + var mountedSidecarIdentity: SubtitleIdentity? = null val rolledBackSessions = mutableListOf() override suspend fun stage( @@ -374,6 +376,9 @@ private class FinalRollbackStagedPort : TvSubtitleStagedReplanPort { candidate: TvStagedSubtitleCandidate, ): ApiResult { managerSession = candidate.sessionId + mountedSidecarIdentity = candidate.selectedSubtitleIndex + ?.takeIf { it >= 0 } + ?.let(SubtitleIdentity::ServerSidecar) return ApiResult.Success(TvSubtitleCommittedPlayback( sessionId = candidate.sessionId, subtitleTracks = candidate.subtitleTracks, diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRefreshOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRefreshOwnershipTest.kt index 6d342feb0..a48b2c31e 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRefreshOwnershipTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRefreshOwnershipTest.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.model.playback.CommittedSubtitle import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo import org.prairieserver.prairie.model.playback.SubtitleIdentity -import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity import org.prairieserver.prairie.network.ApiResult import kotlin.test.Test import kotlin.test.assertEquals @@ -179,18 +178,18 @@ class TvSubtitleRefreshOwnershipTest { } @Test - fun `newest authoritative empty refresh removes stale downloaded rows`() = runTest { + fun `newest authoritative empty refresh clears every stale row`() = runTest { val harness = harness(backgroundScope, tracks = listOf(server(3), downloaded(91))) val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Realtime) assertTrue(harness.adapter.applyRefresh(owner, emptyList(), autoSelectDownloadId = null)) - assertEquals(listOf(3), harness.adapter.snapshot.subtitleTracks.map { it.index }) + assertEquals(emptyList(), harness.adapter.snapshot.subtitleTracks) assertEquals(1L, harness.adapter.snapshot.subtitleRefreshNonce) } @Test - fun `accepted refresh rebases downloaded URLs to the owned session`() = runTest { + fun `accepted authoritative refresh preserves the exact server URL`() = runTest { val harness = harness(backgroundScope) val owner = harness.adapter.beginRefresh(TvSubtitleRefreshSource.Download) @@ -203,7 +202,7 @@ class TvSubtitleRefreshOwnershipTest { ) assertEquals( - "https://prairie.test/api/v1/stream/s1/subtitles/91.vtt?token=stale", + "https://silo.test/api/v1/stream/stale/subtitles/91.vtt?token=stale", harness.adapter.snapshot.subtitleTracks.single { it.downloadId == 91 }.url, ) } @@ -256,7 +255,7 @@ class TvSubtitleRefreshOwnershipTest { codec = "webvtt", label = "Server $index", source = "server_artifact", - url = "https://prairie.test/api/v1/stream/s1/subtitles/$index.vtt", + url = "https://silo.test/api/v1/stream/s1/subtitles/$index.vtt", ) private fun downloaded( @@ -269,22 +268,12 @@ class TvSubtitleRefreshOwnershipTest { codec = "webvtt", label = label, source = "downloaded", - url = "https://prairie.test/api/v1/stream/$sessionId/subtitles/$id.vtt?token=$sessionId", + url = "https://silo.test/api/v1/stream/$sessionId/subtitles/$id.vtt?token=$sessionId", downloadId = id, ) private fun downloadedIdentity(id: Int): SubtitleIdentity.Downloaded = - SubtitleIdentity.Downloaded( - downloadId = id, - media = SubtitleMediaIdentity( - trackId = "prairie-downloaded-subtitle:$id", - label = if (id == 40) "English" else "English", - language = "en", - codecFamily = "webvtt", - forced = false, - hearingImpaired = false, - ), - ) + tvSubtitleIdentity(downloaded(id)) as SubtitleIdentity.Downloaded private fun sidecar(index: Int): SubtitleIdentity = SubtitleIdentity.ServerSidecar(index) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt index 61a4dd030..8d9ee7a94 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSettlementOwnershipTest.kt @@ -804,7 +804,7 @@ class TvSubtitleSettlementOwnershipTest { assertBefore( exitBody, "subtitleTransactions.invalidateAndAwaitSettlement()", - "sessionLifecycle.stop(", + "lifecycleTeardown.stopOrdered(", ) // Every exit path runs prepareSessionExit, which blanks uiState.sessionId. // If the id is not latched BEFORE that write, each of the three stops @@ -818,7 +818,7 @@ class TvSubtitleSettlementOwnershipTest { "lastAdoptedSessionId = it", "sessionId = null", ) - assertTrue(exitBody.contains("sessionLifecycle.stop(expectedSessionId = exitSessionId)")) + assertTrue(exitBody.contains("lifecycleTeardown.stopOrdered(expectedSessionId = exitSessionId)")) assertBefore( clearBody, "subtitleTransactions.reserveDurableFinalPersistence()", @@ -837,13 +837,15 @@ class TvSubtitleSettlementOwnershipTest { assertBefore( clearBody, "subtitleTransactions::requestDurableFinalPersistence", - "sessionLifecycle.stop(", + "lifecycleTeardown.stopDetached(", ) - // stop(expectedSessionId = …) is still stop(): teardown is deferred - // behind settlement work, so it must name the session it is ending or it - // lands on whatever the next screen has since adopted. - assertTrue(clearBody.contains("sessionLifecycle.stop(expectedSessionId")) - assertFalse(clearBody.contains("sessionLifecycle.stopAsync()")) + // Teardown is deferred behind settlement work, so it must name the + // session it is ending or it lands on whatever the next screen has since + // adopted. It also has to go through the gate: an unguarded stop here + // bumps stopEpoch after the next episode captured its ownership epoch + // and supersedes it, which is how auto-advance broke. + assertTrue(clearBody.contains("lifecycleTeardown.stopDetached(expectedSessionId")) + assertFalse(clearBody.contains("sessionLifecycle.stop")) val adoptionBody = source .substringAfter("private suspend fun adoptSubtitlePlayback(") .substringBefore("private suspend fun confirmSubtitlePlaybackPublication(") @@ -913,6 +915,7 @@ class TvSubtitleSettlementOwnershipTest { }, durablePersistenceScope = durableScope, settlementScope = durableScope, + isLocallyMountable = { identity -> identity == port.mountedSidecarIdentity }, onCommittedPlayback = { adoption -> lifecycle.adopt(adoption.playback.sessionId) adoptionGate?.await() @@ -950,7 +953,7 @@ class TvSubtitleSettlementOwnershipTest { formFactor = "tv", appVersion = "test", output = PlaybackOutputContext( - outputRouteGeneration = outputRouteGeneration, + outputContextId = outputRouteGeneration.toString(), ), ) return TvSubtitlePlaybackContext( @@ -1009,6 +1012,8 @@ class TvSubtitleSettlementOwnershipTest { val requests = mutableListOf() var pendingPlayback: TvSubtitleCommittedPlayback? = null private set + var mountedSidecarIdentity: SubtitleIdentity? = null + private set private var settlement = CompletableDeferred().apply { complete(Unit) } private var sessionSequence = 0 private var stageStarted: CompletableDeferred? = null @@ -1085,6 +1090,9 @@ class TvSubtitleSettlementOwnershipTest { subtitleTracks = candidate.subtitleTracks, outputRouteGeneration = candidate.outputRouteGeneration, ) + mountedSidecarIdentity = candidate.selectedSubtitleIndex + ?.takeIf { it >= 0 } + ?.let(SubtitleIdentity::ServerSidecar) pendingPlayback = playback settlement = CompletableDeferred() return ApiResult.Success(playback) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSingleOwnerSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSingleOwnerSourceTest.kt new file mode 100644 index 000000000..db42c13de --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleSingleOwnerSourceTest.kt @@ -0,0 +1,86 @@ +package org.prairieserver.prairie.tv.ui.screens.player + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins the invariant the ordinal-auto-path bug violated: on TV exactly one + * thing may enable or disable a text track, and it is the subtitle transaction + * adapter. + * + * The old shape was a bare `SharedFlow` that the auto, persisted-restore + * and detail-pick paths all emitted into without arming an owner. Playback then + * obeyed those emissions while the HUD kept reporting the adapter's untouched + * committed identity — subtitles on screen, "Off" in the HUD. + * + * Source-level because the failure is structural: a second emitter compiles and + * passes every behavioural test right up until it races the adapter on a real + * device. + */ +class TvSubtitleSingleOwnerSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val viewModel: String + get() = source("org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt") + + private val screen: String + get() = source("org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt") + + @Test + fun theLegacyOrdinalSelectionChannelIsGone() { + assertTrue(!viewModel.contains("_subtitleSelectRequests")) + assertTrue(!screen.contains("subtitleSelectRequests")) + } + + @Test + fun onlyTheRemountLatchEmitsAMountRequest() { + val emissions = Regex("_subtitleMountRequests\\.tryEmit").findAll(viewModel).count() + assertEquals(1, emissions) + + val resolver = viewModel.substringAfter("private fun resolveSubtitleRemountReselection(") + .substringBefore("\n fun ") + assertTrue(resolver.contains("_subtitleMountRequests.tryEmit")) + } + + @Test + fun theScreenIsTheOnlyPlayerFacingSubtitleSelector() { + // Two calls, both inside the mount-request collector: the -1 disable + // and the track selection. + assertEquals(2, Regex("backend\\.selectSubtitle\\(").findAll(screen).count()) + } + + @Test + fun anAppliedSelectionCarriesItsOwnerRatherThanLookingOneUp() { + // The silent `pendingSubtitleMountAcknowledgement ?: return` bail-out is + // what swallowed every app-originated selection. The owner now travels + // with the request, so an ownerless mount is unrepresentable. + assertTrue(!viewModel.contains("pendingSubtitleMountAcknowledgement")) + assertTrue( + viewModel.contains( + "internal fun onSubtitleSelectionApplied(request: TvSubtitleMountRequest)", + ), + ) + assertTrue( + viewModel.contains( + "internal fun onSubtitleSelectionFailed(request: TvSubtitleMountRequest)", + ), + ) + } + + @Test + fun appDerivedSelectionsGoThroughTheAdapter() { + val auto = viewModel.substringAfter("private fun resolveAutoPreferredTextSubtitle(") + .substringBefore("\n /**") + assertTrue(auto.contains("applyAutomaticSubtitleSelection")) + + val apply = viewModel.substringAfter("private fun applyAutomaticSubtitleSelection(") + .substringBefore("\n /**") + assertTrue(apply.contains("subtitleTransactions.selectAuto")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt index 0aab780f0..82468cbe2 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt @@ -82,6 +82,103 @@ class TvSubtitleTransactionAdapterTest { ) } + @Test + fun `new server negotiated sidecar switches locally without replan`() = runTest { + val target = sidecar(4) + val harness = harness( + backgroundScope, + isLocallyMountable = { identity -> identity == target }, + ) + + harness.adapter.select(target) + runCurrent() + + assertTrue( + harness.port.requests.isEmpty(), + "an already-mounted sidecar must not ask the server to replan", + ) + assertEquals(target, harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + + harness.adapter.reportMountedSelection( + identity = target, + selected = true, + snapshotKey = "mounted-sidecar-selected", + settled = true, + ) + runCurrent() + + assertEquals(target, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf(target), harness.persistence.persisted.map { it.identity }) + } + + @Test + fun `old server catalog-only sidecar performs one staged replan at current position`() = runTest { + val adoption = AdoptionControl() + val harness = harness( + backgroundScope, + adoption = adoption, + isLocallyMountable = { false }, + ) + + harness.adapter.select(sidecar(4)) + runCurrent() + + val request = harness.port.requests.single() + assertEquals( + listOf(4), + harness.port.requests.map { it.subtitleTrackIndex }, + "an unmounted sidecar must retain the staged replan fallback", + ) + assertEquals(42.0, request.positionSeconds) + assertEquals(2, request.audioTrackIndex) + assertEquals("auto", request.qualityPreference) + assertNull(harness.adapter.snapshot.localMountIdentity) + + harness.port.completeStage(candidate("old-server-sidecar", 4)) + runCurrent() + confirmPendingPlayerBoundary(harness, "old-server-sidecar-mounted") + runCurrent() + + assertEquals(listOf("old-server-sidecar"), harness.port.committed) + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + assertEquals(listOf(42.0), adoption.requestedSourcePositions) + } + + @Test + fun `burn in route stages one replan before switching to an external sidecar`() = runTest { + // Burn-in plans intentionally mount no negotiated alternatives. The + // selected SRT therefore follows the same safe fallback as an old + // server response and replaces the video route before it is mounted. + val harness = harness(backgroundScope, isLocallyMountable = { false }) + + harness.adapter.select(sidecar(4)) + runCurrent() + + assertEquals(listOf(4), harness.port.requests.map { it.subtitleTrackIndex }) + assertEquals(42.0, harness.port.requests.single().positionSeconds) + harness.port.completeStage(candidate("burn-in-to-sidecar", 4)) + runCurrent() + confirmPendingPlayerBoundary(harness, "burn-in-replacement-mounted") + runCurrent() + + assertEquals(listOf("burn-in-to-sidecar"), harness.port.committed) + assertEquals(sidecar(4), harness.adapter.snapshot.committedIdentity) + } + + @Test + fun `an unmounted committed server sidecar is not restored locally`() = runTest { + val harness = harness(backgroundScope, isLocallyMountable = { false }) + + harness.adapter.restoreCommittedLocalMount() + runCurrent() + + assertFalse(harness.adapter.snapshot.subtitleApplying) + assertNull(harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + } + @Test fun `slow older preference write cannot overwrite newer commit`() = runTest { val harness = harness(backgroundScope, sessionId = null) @@ -198,6 +295,30 @@ class TvSubtitleTransactionAdapterTest { assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) } + @Test + fun `adapted edition commits returned audio and subtitle identities`() = runTest { + val harness = harness(backgroundScope) + + harness.adapter.select(sidecar(4)) + runCurrent() + harness.port.completeStage( + candidate( + id = "adapted", + selectedIndex = 1, + selectedAudioIndex = 5, + effectiveMediaFileId = 22, + selectedSubtitleIdentity = sidecar(1), + ), + ) + runCurrent() + confirmPendingPlayerBoundary(harness, "adapted-mounted") + runCurrent() + + assertEquals(sidecar(1), harness.adapter.snapshot.committedIdentity) + assertEquals(5, harness.adapter.snapshot.transition.committed.audioTrackIndex) + assertEquals(22, harness.committedPlaybacks.single().effectiveMediaFileId) + } + @Test fun `local then audio before mount keeps one client-owned transaction`() = runTest { val downloaded = downloadedIdentity() @@ -242,7 +363,17 @@ class TvSubtitleTransactionAdapterTest { assertEquals(downloaded, harness.adapter.snapshot.committedIdentity) assertEquals(7, harness.adapter.snapshot.transition.committed.audioTrackIndex) assertEquals( - listOf(CommittedSubtitle(downloaded, audioTrackIndex = 7, qualityPreference = "auto")), + listOf( + CommittedSubtitle( + downloaded, + audioTrackIndex = 7, + qualityPreference = "auto", + // This scenario changes AUDIO explicitly, which is now + // recorded so a subtitle-only commit cannot be mistaken for + // the viewer choosing the audio it happened to carry. + audioPreferenceSpecified = true, + ), + ), harness.persistence.persisted, ) } @@ -1522,6 +1653,36 @@ class TvSubtitleTransactionAdapterTest { assertEquals(listOf(downloaded), harness.persistence.persisted.map { it.identity }) } + @Test + fun `authoritative downloaded refresh auto selects the exact server sidecar`() = runTest { + val harness = harness(backgroundScope) + val owner = harness.adapter.beginRefresh() + val row = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "vtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "https://silo.test/api/v1/stream/s1/subtitles/4.vtt", + downloadId = 91, + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ) + + assertTrue( + harness.adapter.applyRefresh( + owner = owner, + subtitleTracks = listOf(row), + autoSelectDownloadId = 91, + ), + ) + runCurrent() + + assertEquals(tvSubtitleIdentity(row), harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf(4), harness.port.requests.map { it.subtitleTrackIndex }) + } + @Test fun `HUD catalog selection while controls are open enters one transaction`() = runTest { val harness = harness(backgroundScope) @@ -2045,7 +2206,9 @@ class TvSubtitleTransactionAdapterTest { tracks: List = emptyList(), adoption: AdoptionControl = AdoptionControl(), durablePersistenceScope: CoroutineScope = scope, - isLocallyMountable: (SubtitleIdentity) -> Boolean = { true }, + isLocallyMountable: (SubtitleIdentity) -> Boolean = { identity -> + identity !is SubtitleIdentity.ServerSidecar + }, persistenceCoordinator: PlaybackTrackSelectionWriteCoordinator = PlaybackTrackSelectionWriteCoordinator(), persistence: RecordingPersistence = RecordingPersistence(), @@ -2060,6 +2223,7 @@ class TvSubtitleTransactionAdapterTest { persistenceCoordinator = persistenceCoordinator, onCommittedPlayback = { adoptionRequest -> adoption.started += 1 + adoption.requestedSourcePositions += adoptionRequest.requestedSourcePositionSeconds if (adoption.suspendAdoption) adoption.completions.receive() adoption.failure?.let { throw it } if (adoption.forceSuperseded || !adoptionRequest.isCurrent()) { @@ -2140,6 +2304,7 @@ class TvSubtitleTransactionAdapterTest { var forceSuperseded: Boolean = false, ) { var started: Int = 0 + val requestedSourcePositions = mutableListOf() val completions = Channel(Channel.UNLIMITED) suspend fun complete() { @@ -2178,6 +2343,8 @@ class TvSubtitleTransactionAdapterTest { tracks: List = emptyList(), qualityPreference: String = "auto", outputRouteGeneration: Long = 0L, + effectiveMediaFileId: Int? = null, + selectedSubtitleIdentity: SubtitleIdentity? = null, ): TvStagedSubtitleCandidate = TvStagedSubtitleCandidate( id = id, sessionId = sessionId, @@ -2186,6 +2353,8 @@ class TvSubtitleTransactionAdapterTest { subtitleMode = mode, hasSidecar = hasSidecar, subtitleTracks = tracks, + effectiveMediaFileId = effectiveMediaFileId, + selectedSubtitleIdentity = selectedSubtitleIdentity, qualityPreference = qualityPreference, outputRouteGeneration = outputRouteGeneration, ) @@ -2315,6 +2484,7 @@ class TvSubtitleTransactionAdapterTest { TvSubtitleCommittedPlayback( sessionId = candidate.sessionId, subtitleTracks = candidate.subtitleTracks, + effectiveMediaFileId = candidate.effectiveMediaFileId, outputRouteGeneration = candidate.outputRouteGeneration, ), ) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionGridScopeTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionGridScopeTest.kt new file mode 100644 index 000000000..f19174507 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/profiles/TvProfileSelectionGridScopeTest.kt @@ -0,0 +1,150 @@ +package org.prairieserver.prairie.tv.ui.screens.profiles + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.profile.Profile +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.ProfileIdentity +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import org.prairieserver.prairie.repository.ProfileRepository +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class TvProfileSelectionGridScopeTest { + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `failed reload under a new scope does not qualify the old grid as new`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val tokens = TvScopeTokenManager(barrier) + val oldProfile = Profile(id = "old-profile", name = "Old profile") + val repository = TvQueueProfileRepository( + tokenManager = tokens, + barrier = barrier, + results = ArrayDeque( + listOf( + ApiResult.Success(listOf(oldProfile)), + ApiResult.Error(500, "failed", "Reload failed"), + ApiResult.Error(500, "failed", "Reload still failed"), + ), + ), + ) + val viewModel = TvProfileSelectionViewModel(repository) + advanceUntilIdle() + + // Establish the init load before changing identity. Otherwise the + // Unconfined dispatcher can let the init response observe the switch + // below and correctly reject what the test intended as its old grid. + assertEquals(listOf(oldProfile), viewModel.uiState.value.profiles) + + barrier.changing(IdentityTransitionKind.SERVER_SWITCH) { + tokens.serverId = "server-b" + } + viewModel.loadProfiles() + advanceUntilIdle() + + // A failed reload leaves the previous grid visible, but must not move + // that grid's scope to server-b. + assertEquals(listOf(oldProfile), viewModel.uiState.value.profiles) + + viewModel.onProfileSelected(oldProfile) + advanceUntilIdle() + + // The retained card is still qualified by server-a. Selecting it under + // server-b therefore fails closed and drops the now-stale grid. + assertEquals(emptyList(), viewModel.uiState.value.profiles) + assertEquals(emptyList(), tokens.committedProfiles) + assertNull(viewModel.uiState.value.selectedProfileId) + } + + @Test + fun `selection dispatched after a scope mismatch cannot use the cleared grid`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val barrier = DefaultIdentityTransitionBarrier() + val tokens = TvScopeTokenManager(barrier) + val oldProfile = Profile(id = "old-profile", name = "Old profile") + val repository = TvQueueProfileRepository( + tokenManager = tokens, + barrier = barrier, + results = ArrayDeque( + listOf( + ApiResult.Success(listOf(oldProfile)), + ApiResult.Success(emptyList()), + ), + ), + beforeResult = { call -> + if (call == 2) { + barrier.changing(IdentityTransitionKind.SERVER_SWITCH) { + tokens.serverId = "server-b" + } + } + }, + ) + val viewModel = TvProfileSelectionViewModel(repository) + + viewModel.loadProfiles() + viewModel.onProfileSelected(oldProfile) + + assertEquals(emptyList(), viewModel.uiState.value.profiles) + assertEquals(emptyList(), tokens.committedProfiles) + } +} + +private class TvQueueProfileRepository( + tokenManager: TokenManager, + barrier: DefaultIdentityTransitionBarrier, + private val results: ArrayDeque>>, + private val beforeResult: suspend (Int) -> Unit = {}, +) : ProfileRepository( + profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") })), + tokenManager = tokenManager, + identityTransitions = barrier, +) { + private var calls = 0 + + override suspend fun listProfiles(): ApiResult> { + beforeResult(++calls) + return results.removeFirst() + } +} + +private class TvScopeTokenManager( + private val barrier: DefaultIdentityTransitionBarrier, +) : TokenManager by TokenManagerImpl(barrier) { + var serverId: String = "server-a" + val committedProfiles = mutableListOf() + + override suspend fun snapshotCurrentScope(): AuthScopeSnapshot = AuthScopeSnapshot( + serverId = serverId, + profileId = null, + serverUrl = "https://$serverId", + profileToken = null, + identityGeneration = barrier.generation.value, + ) + + override suspend fun getCurrentServerId(): String = serverId + + override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) { + committedProfiles += ProfileIdentity(profileId, profileToken) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt new file mode 100644 index 000000000..fa63fdbe3 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt @@ -0,0 +1,74 @@ +package org.prairieserver.prairie.tv.ui.screens.recommendations + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TvForYouEntryRequestTest { + + @Test + fun repeatedSelectionStillCreatesANewRequest() { + val first = TvForYouEntryRequest().next(SavedListSelection.Watchlist) + val second = first.next(SavedListSelection.Watchlist) + + assertEquals(1, first.sequence) + assertEquals(2, second.sequence) + assertEquals(SavedListSelection.Watchlist, second.selection) + } + + @Test + fun recommendationsRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Favorites, + ).next(null) + + assertEquals(5, request.sequence) + assertNull(request.selection) + } + + @Test + fun topLevelForYouRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 9, + selection = SavedListSelection.Watchlist, + ).nextForTopLevelForYou() + + assertEquals(10, request.sequence) + assertNull(request.selection) + } + + @Test + fun unrelatedRecompositionDoesNotOverrideInPageSelection() { + val applied = applyForYouEntryRequest( + currentSelection = SavedListSelection.Favorites, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 3, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Favorites, applied.selection) + assertEquals(3, applied.lastAppliedSequence) + assertFalse(applied.appliedRequest) + } + + @Test + fun newerRequestAppliesRequestedInlineSelection() { + val applied = applyForYouEntryRequest( + currentSelection = null, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Watchlist, applied.selection) + assertEquals(4, applied.lastAppliedSequence) + assertTrue(applied.appliedRequest) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjectionTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjectionTest.kt new file mode 100644 index 000000000..30ab770ed --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/search/TvSearchReturnProjectionTest.kt @@ -0,0 +1,161 @@ +package org.prairieserver.prairie.tv.ui.screens.search + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.request.RequestAvailability +import org.prairieserver.prairie.model.request.RequestMediaResult +import org.prairieserver.prairie.tv.ui.focus.TvReturnRelocation +import org.prairieserver.prairie.tv.ui.focus.TvReturnResolution +import org.prairieserver.prairie.tv.ui.focus.TvReturnTarget +import org.prairieserver.prairie.tv.ui.focus.resolveTvReturnTarget +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Search puts library items and requestable titles on one screen, and a + * requestable title that is already in the library carries both identities. + * These cover the collision that namespacing exists to prevent. + */ +class TvSearchReturnProjectionTest { + + private fun catalogItem(id: String) = BrowseItem(contentId = id, type = "movie", title = id) + + private fun requestResult( + mediaType: String, + tmdbId: Int, + libraryContentId: String? = null, + ) = RequestMediaResult( + mediaType = mediaType, + tmdbId = tmdbId, + title = "t$tmdbId", + availability = if (libraryContentId != null) RequestAvailability.Available else RequestAvailability.Missing, + libraryContentId = libraryContentId, + ) + + @Test + fun aRequestCardsLibraryTwinDoesNotStealTheReturn() { + // The same title in both places: content id "m1" in the grid, and a + // request card that opens that very item. + val sections = tvSearchReturnSections( + catalogItems = listOf(catalogItem("m0"), catalogItem("m1")), + requestResults = listOf(requestResult("movie", 55, libraryContentId = "m1")), + catalogComplete = true, + requestsComplete = true, + ) + + // The viewer opened the REQUEST card, so the return belongs in the + // request row — not on the grid card it happens to navigate to. + val resolved = resolveTvReturnTarget( + target = TvReturnTarget( + sectionId = TvSearchRequestSectionId, + itemId = tvSearchRequestItemId("movie", 55), + sectionIndex = 1, + itemIndex = 0, + ), + sections = sections, + ) + + val located = resolved as TvReturnResolution.Exact + assertEquals(TvSearchRequestSectionId, located.sectionId) + assertEquals(0, located.itemIndex) + } + + @Test + fun aCatalogIdIsNeverMatchedAgainstTheRequestRow() { + val sections = tvSearchReturnSections( + catalogItems = emptyList(), + requestResults = listOf(requestResult("movie", 55, libraryContentId = "m1")), + catalogComplete = true, + requestsComplete = true, + ) + + // "m1" exists on screen — as the request card's library id. An + // un-namespaced projection would match it here. + val resolved = resolveTvReturnTarget( + target = TvReturnTarget( + sectionId = TvSearchCatalogSectionId, + itemId = tvSearchCatalogItemId("m1"), + sectionIndex = 0, + itemIndex = 0, + ), + sections = sections, + relocation = TvReturnRelocation.FollowAcrossSections, + ) + + assertTrue( + resolved !is TvReturnResolution.Exact, + "a catalog id must not resolve exactly against a request card", + ) + } + + @Test + fun aRequestSearchStillInFlightLetsTheTargetWait() { + // Request search clears its results when a query starts, so an empty + // row is not evidence that the card is gone. + val sections = tvSearchReturnSections( + catalogItems = listOf(catalogItem("m0")), + requestResults = emptyList(), + catalogComplete = true, + requestsComplete = false, + ) + + val resolved = resolveTvReturnTarget( + target = TvReturnTarget( + sectionId = TvSearchRequestSectionId, + itemId = tvSearchRequestItemId("movie", 55), + sectionIndex = 1, + itemIndex = 0, + ), + sections = sections, + ) + + assertEquals(TvReturnResolution.Pending, resolved) + } + + @Test + fun aCatalogIdSpelledLikeARequestIdIsStillDistinct() { + // Proves the catalog namespace independently: without it, this content + // id would BE the request row's id. + val encoded = tvSearchRequestItemId("movie", 55) + assertTrue(tvSearchCatalogItemId(encoded) != encoded) + } + + @Test + fun mediaTypeAliasesResolveToOneIdentity() { + // The rendering pipeline accepts all of these for the same card, so + // they must not become different saved identities. + val canonical = tvSearchRequestItemId("audiobook", 9) + assertEquals(canonical, tvSearchRequestItemId("audiobooks", 9)) + assertEquals(canonical, tvSearchRequestItemId(" AudioBook ", 9)) + assertEquals(tvSearchRequestItemId("movie", 9), tvSearchRequestItemId("MOVIE", 9)) + } + + @Test + fun theSameTmdbIdUnderDifferentMediaTypesStaysDistinct() { + assertTrue(tvSearchRequestItemId("movie", 7) != tvSearchRequestItemId("tv", 7)) + } + + @Test + fun anIncompleteCatalogLetsADeepTargetWait() { + val sections = tvSearchReturnSections( + catalogItems = listOf(catalogItem("m0")), + requestResults = emptyList(), + catalogComplete = false, + requestsComplete = true, + ) + + // Not yet loaded rather than not there: settling for a near miss here + // would strand focus on a stand-in for good. + val resolved = resolveTvReturnTarget( + target = TvReturnTarget( + sectionId = TvSearchCatalogSectionId, + itemId = tvSearchCatalogItemId("m99"), + sectionIndex = 0, + itemIndex = 40, + ), + sections = sections, + ) + + assertEquals(TvReturnResolution.Pending, resolved) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt new file mode 100644 index 000000000..5489ebfca --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsCategoryTest.kt @@ -0,0 +1,65 @@ +package org.prairieserver.prairie.tv.ui.screens.settings + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvSettingsCategoryTest { + @Test + fun diagnosticsSitsBetweenSubtitlesAndServer() { + // tvOS TVSettingsCategory order — the rail should read the same on both + // platforms. + assertEquals( + listOf( + TvSettingsCategory.General, + TvSettingsCategory.Playback, + TvSettingsCategory.Subtitles, + TvSettingsCategory.Diagnostics, + TvSettingsCategory.Server, + ), + TvSettingsCategory.entries, + ) + assertEquals("SUPPORT", TvSettingsCategory.Diagnostics.eyebrow) + } + + @Test + fun anIneligibleProfileHidesTheCategoryEntirely() { + assertEquals( + listOf( + TvSettingsCategory.General, + TvSettingsCategory.Playback, + TvSettingsCategory.Subtitles, + TvSettingsCategory.Server, + ), + tvSettingsVisibleCategories(diagnosticsEligible = false), + ) + } + + @Test + fun losingEligibilityWhileShownFallsBackToGeneral() { + assertEquals( + TvSettingsCategory.General, + tvSettingsCategoryForEligibility( + current = TvSettingsCategory.Diagnostics, + diagnosticsEligible = false, + ), + ) + } + + @Test + fun anUnrelatedCategoryIsNeverDisturbed() { + assertEquals( + TvSettingsCategory.Server, + tvSettingsCategoryForEligibility( + current = TvSettingsCategory.Server, + diagnosticsEligible = false, + ), + ) + assertEquals( + TvSettingsCategory.Diagnostics, + tvSettingsCategoryForEligibility( + current = TvSettingsCategory.Diagnostics, + diagnosticsEligible = true, + ), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt index 72ef769b5..e3e114f5f 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt @@ -2,6 +2,7 @@ package org.prairieserver.prairie.tv.ui.screens.settings.diagnostics import org.prairieserver.prairie.common.diagnostics.DiagnosticsAvailabilityUi import org.prairieserver.prairie.common.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.common.diagnostics.DiagnosticsDestinationKind import org.prairieserver.prairie.common.diagnostics.DiagnosticsPrompt import org.prairieserver.prairie.common.diagnostics.DiagnosticsReportSummary import org.prairieserver.prairie.common.diagnostics.DiagnosticsUiState @@ -12,16 +13,78 @@ import org.prairieserver.prairie.tv.ui.navigation.tvShouldShowDiagnosticsPrompt import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class TvDiagnosticsStateTest { + // ----------------------------------------------------------------------- + // Destination + // + // These two choices existed in the UI but no D-pad press could reach them: + // a hand-rolled focus ladder above them consumed Up at its own first row, + // so focus could never leave the consent block upwards. The ladder is gone + // — both choices now live behind the shared settings picker sheet, which is + // reached by a normal focusable row. + // ----------------------------------------------------------------------- + @Test - fun promptDefaultsToDontSend() { - val model = tvDiagnosticsPromptModel( - DiagnosticsPrompt("report-1", DiagnosticsReportType.CRASH, "2026-07-22T00:00:00Z"), + fun bothDestinationsAreOfferedInHostedFirstOrder() { + assertEquals( + listOf(DiagnosticsDestinationKind.HOSTED, DiagnosticsDestinationKind.SELF_HOSTED), + TvDiagnosticsDestinations, ) + assertEquals("Prairie Diagnostics", tvDiagnosticsDestinationTitle(DiagnosticsDestinationKind.HOSTED)) + assertEquals("This Prairie server", tvDiagnosticsDestinationTitle(DiagnosticsDestinationKind.SELF_HOSTED)) + } - assertEquals(TvDiagnosticsPromptFocus.DONT_SEND, model.initialFocus) + @Test + fun selfHostedDestinationReadsAsTheConnectedServer() { + assertEquals( + "Living Room Silo", + tvDiagnosticsDestinationName(DiagnosticsDestinationKind.SELF_HOSTED, "Living Room Silo"), + ) + // An unnamed server must not render an empty value row. + assertEquals( + "This Prairie server", + tvDiagnosticsDestinationName(DiagnosticsDestinationKind.SELF_HOSTED, ""), + ) + assertEquals( + "Prairie Diagnostics", + tvDiagnosticsDestinationName(DiagnosticsDestinationKind.HOSTED, "Living Room Silo"), + ) + } + + // ----------------------------------------------------------------------- + // Consent + // ----------------------------------------------------------------------- + + @Test + fun hostedCollectorDoesNotOfferAlways() { + assertEquals( + listOf(DiagnosticsConsentMode.ASK, DiagnosticsConsentMode.NEVER), + tvDiagnosticsConsentOptions(allowsAutomaticUpload = false), + ) + assertEquals( + DiagnosticsConsentMode.entries, + tvDiagnosticsConsentOptions(allowsAutomaticUpload = true), + ) + } + + @Test + fun storedAlwaysReadsAsAskWhereAutomaticUploadIsNotAllowed() { + // The row must not name a mode the picker cannot even show. + assertEquals( + DiagnosticsConsentMode.ASK, + tvDiagnosticsEffectiveConsent(DiagnosticsConsentMode.ALWAYS, allowsAutomaticUpload = false), + ) + assertEquals( + DiagnosticsConsentMode.ALWAYS, + tvDiagnosticsEffectiveConsent(DiagnosticsConsentMode.ALWAYS, allowsAutomaticUpload = true), + ) + assertEquals( + DiagnosticsConsentMode.NEVER, + tvDiagnosticsEffectiveConsent(DiagnosticsConsentMode.NEVER, allowsAutomaticUpload = false), + ) } @Test @@ -35,12 +98,152 @@ class TvDiagnosticsStateTest { } @Test - fun reportRouteHidesPromptSoReviewIsVisible() { + fun reselectingAlwaysDoesNotReconfirm() { + assertFalse( + tvDiagnosticsConsentAction( + current = DiagnosticsConsentMode.ALWAYS, + requested = DiagnosticsConsentMode.ALWAYS, + ).requiresConfirmation, + ) + } + + // ----------------------------------------------------------------------- + // Section content + // ----------------------------------------------------------------------- + + @Test + fun pendingHeaderCarriesTheCountIncludingZero() { + assertEquals("Pending Reports (0)", tvDiagnosticsPendingHeader(0)) + assertEquals("Pending Reports (3)", tvDiagnosticsPendingHeader(3)) + } + + @Test + fun wireReportTypesRenderAsTitles() { + assertEquals("Crash", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.CRASH)) + assertEquals("Crash", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.NATIVE_CRASH)) + assertEquals("Not Responding", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.ANR)) + assertEquals("Not Responding", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.HANG)) + assertEquals("Unclean Shutdown", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.ABNORMAL_EXIT)) + assertEquals("Manual Report", tvDiagnosticsReportTypeTitle(DiagnosticsReportType.MANUAL)) + } + + @Test + fun statusRowUsesTheShortFeatureStateTitles() { + assertEquals("Available", tvDiagnosticsStatusTitle(DiagnosticsAvailabilityUi.AVAILABLE)) + assertEquals("Disabled by server", tvDiagnosticsStatusTitle(DiagnosticsAvailabilityUi.DISABLED)) + assertEquals("Offline", tvDiagnosticsStatusTitle(DiagnosticsAvailabilityUi.OFFLINE)) + } + + @Test + fun expiryCountsWholeDaysAndNamesAnElapsedReport() { + val day = 24L * 60L * 60L * 1000L + assertEquals("Expires in 30 days", tvDiagnosticsExpiryLabel(30 * day, 0)) + assertEquals("Expires in 1 day", tvDiagnosticsExpiryLabel(day, 0)) + // A part-day still has time left, so it must not read as expired. + assertEquals("Expires in 1 day", tvDiagnosticsExpiryLabel(day / 2, 0)) + assertEquals("Expired", tvDiagnosticsExpiryLabel(0, day)) + } + + @Test + fun promptDefaultsToDontSend() { + val model = tvDiagnosticsPromptModel( + DiagnosticsPrompt("report-1", DiagnosticsReportType.CRASH, "2026-07-22T00:00:00Z"), + ) + + assertEquals(TvDiagnosticsPromptFocus.DONT_SEND, model.initialFocus) + } + + // ----------------------------------------------------------------------- + // Focus context + // + // Read-only rows are outside the focus graph, so the content at the ends of + // the pane is only ever seen because a focused row asked for it. Both + // halves of that — which row asks, and how much it may ask for — are pinned + // here: getting either wrong strands content with no D-pad press able to + // recover it, which is the defect this replaced. + // ----------------------------------------------------------------------- + + @Test + fun theTopmostSectionWithAControlOwnsEntryFocus() { + // PENDING REPORTS sits above CAPTURE and only has focusable rows while + // reports are waiting, so entry focus moves between the two sections. + assertTrue(tvDiagnosticsPendingOwnsFirstFocus(1)) + assertFalse(tvDiagnosticsPendingOwnsFirstFocus(0)) + } + + @Test + fun aContextRequestNeverOverhangsBothViewportEdges() { + // Compose reads a rect taller than the container as "already visible" + // and scrolls by nothing, so an unclamped ask is an ask for no scroll + // at all. Requesting a whole viewport on one side means "as much as + // fits", never more. + val reveal = tvListContextReveal( + nodeHeightPx = 84, + viewportPx = 768, + abovePx = 768, + belowPx = 0, + ) + + assertEquals(TvListContextReveal(topPx = -684f, bottomPx = 84f), reveal) + assertEquals(768f, reveal!!.bottomPx - reveal.topPx) + } + + @Test + fun aFooterSizedRequestIsPassedThroughUntouched() { + assertEquals( + TvListContextReveal(topPx = 0f, bottomPx = 284f), + tvListContextReveal(nodeHeightPx = 84, viewportPx = 768, abovePx = 0, belowPx = 200), + ) + } + + @Test + fun anUnmeasuredOrEmptyRequestAsksForNothing() { + // Before first layout there is no rect worth sending, and a row with no + // context to reveal must not fight Compose's own bring-into-view. + assertNull(tvListContextReveal(nodeHeightPx = 0, viewportPx = 768, abovePx = 768, belowPx = 0)) + assertNull(tvListContextReveal(nodeHeightPx = 84, viewportPx = 0, abovePx = 768, belowPx = 0)) + assertNull(tvListContextReveal(nodeHeightPx = 84, viewportPx = 768, abovePx = 0, belowPx = 0)) + // A row taller than the viewport has no room to spare for anything else. + assertNull(tvListContextReveal(nodeHeightPx = 800, viewportPx = 768, abovePx = 768, belowPx = 0)) + } + + // ----------------------------------------------------------------------- + // Prompt suppression + // ----------------------------------------------------------------------- + + @Test + fun promptStaysHiddenOnEveryDiagnosticsSurface() { + // The report detail is still a route. assertFalse(tvShouldShowDiagnosticsPrompt(TvRoute.DiagnosticsReport.ROUTE)) - assertFalse(tvShouldShowDiagnosticsPrompt(TvRoute.Diagnostics.route)) + // The settings surface is a pane inside Main, so it reports presence + // instead — without this the prompt would reopen over its own list. + assertFalse( + tvShouldShowDiagnosticsPrompt( + currentRoute = TvRoute.Main.route, + diagnosticsSurfaceVisible = true, + ), + ) assertTrue(tvShouldShowDiagnosticsPrompt(TvRoute.Main.route)) } + @Test + fun surfacePresenceSurvivesOverlappingEnterAndLeave() { + // A category swap can compose the next pane before the old one is + // disposed; a plain boolean would latch false and let the prompt in. + TvDiagnosticsSurfacePresence.enter() + TvDiagnosticsSurfacePresence.enter() + TvDiagnosticsSurfacePresence.leave() + assertTrue(TvDiagnosticsSurfacePresence.isVisible) + TvDiagnosticsSurfacePresence.leave() + assertFalse(TvDiagnosticsSurfacePresence.isVisible) + // Never goes negative, so a stray dispose cannot wedge it visible. + TvDiagnosticsSurfacePresence.leave() + TvDiagnosticsSurfacePresence.enter() + assertTrue(TvDiagnosticsSurfacePresence.isVisible) + TvDiagnosticsSurfacePresence.leave() + assertFalse(TvDiagnosticsSurfacePresence.isVisible) + } + @Test fun disabledServerPreservesReviewAndDeleteWithoutSend() { val model = tvDiagnosticsScreenModel( diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt new file mode 100644 index 000000000..1639b4071 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt @@ -0,0 +1,49 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvWatchTogetherDestinationTest { + @Test + fun emptyAndSoloHostRoomsUseLobby() { + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun selectedJoinedRoomUsesSyncedPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selectedFileId = 7, + selfRole = MemberRole.Guest, + memberCount = 2, + anchorPositionSeconds = 12.5, + ) + assertEquals( + TvRoute.Player( + contentId = "movie-1", + fileId = 7, + roomId = "room-1", + resumePositionSeconds = 12.5, + ).route, + tvWatchTogetherDestination(room), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt new file mode 100644 index 000000000..6a74c0602 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt @@ -0,0 +1,35 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun backAndBrowsePreserveRoomWhileLeaveIsExplicit() { + val backHandler = lobby.substringAfter("BackHandler(enabled = true)") + .substringBefore("Box(") + assertTrue(backHandler.contains("onBack()")) + assertFalse(backHandler.contains("viewModel.leave()")) + assertTrue(lobby.contains("title = \"Browse titles\"")) + assertTrue(lobby.contains("title = \"Leave room\"")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun existingOwnerAuthorityAndSuggestionPathRemain() { + assertTrue(lobby.contains("CloseRoomButton(onClick = viewModel::closeRoom)")) + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt new file mode 100644 index 000000000..f225377f4 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyErrorTest.kt @@ -0,0 +1,172 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.model.watchtogether.SuggestionsResponse +import org.prairieserver.prairie.model.watchtogether.UpdatePolicyRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.api.WatchTogetherApi +import org.prairieserver.prairie.repository.WatchTogetherRepository +import org.prairieserver.prairie.watchtogether.RoomSession +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherLobbyErrorTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `rejected lobby operations use the transient repository message path`() = runTest(dispatcher) { + val api = FailingLobbyApi() + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val roomSession = RoomSession(repository, backgroundScope, DefaultIdentityTransitionBarrier()) + val viewModel = TvWatchTogetherLobbyViewModel("room-1", repository, roomSession) + val messages = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.errors.toList(messages) + } + + viewModel.vote("suggestion-1") + viewModel.promote("suggestion-1") + repository.reportDeliveryFailure("Socket request rejected") + runCurrent() + + assertEquals( + listOf( + "Voting is disabled", + "Only the host can promote", + "Socket request rejected", + ), + messages, + ) + collector.cancel() + } + + @Test + fun `suggest rejection remains a visible one shot detail message`() = runTest(dispatcher) { + val repository = WatchTogetherRepository( + api = FailingLobbyApi(), + authScopeProvider = { AUTH_SCOPE }, + ) + repository.createRoom(CreateRoomRequest()) + val viewModel = TvSuggestToRoomViewModel(repository) + + viewModel.suggest("movie-1", "movie", "Movie One", null, null) + runCurrent() + + assertEquals("Suggestions are locked", viewModel.uiState.value.error) + viewModel.clearError() + assertEquals(null, viewModel.uiState.value.error) + } + + private class FailingLobbyApi : WatchTogetherApi { + private val roomResponse = + ApiResult.Success(RoomResponse(RoomSnapshot(roomId = "room-1"), "room-token")) + + override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun joinRoom(request: JoinRoomRequest, scope: AuthScopeSnapshot) = roomResponse + override suspend fun getRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = roomResponse + override suspend fun setSelection( + roomId: String, + roomToken: String, + request: SetSelectionRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun updatePolicy( + roomId: String, + roomToken: String, + request: UpdatePolicyRequest, + scope: AuthScopeSnapshot, + ) = roomResponse + + override suspend fun closeRoom(roomId: String, roomToken: String, scope: AuthScopeSnapshot) = + ApiResult.Success(Unit) + + override suspend fun listSuggestions( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "suggestions_locked", "Suggestions are locked") + + override suspend fun deleteSuggestion( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(409, "voting_disabled", "Voting is disabled") + + override suspend fun unvote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, + ) = ApiResult.Success(SuggestionsResponse()) + + override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, + ): ApiResult = + ApiResult.Error(403, "host_required", "Only the host can promote") + } + + private companion object { + val AUTH_SCOPE = AuthScopeSnapshot( + serverId = "server-1", + profileId = "profile-1", + serverUrl = "https://example.test", + profileToken = "profile-token", + identityGeneration = 1L, + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt new file mode 100644 index 000000000..733467d61 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt @@ -0,0 +1,77 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherMenuEntrySourceTest { + private val shell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + private val dialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt", + ).readText() + + @Test + fun profileRowIsImmediatelyAfterRequestsAndBeforeSettings() { + val profile = shell.substringAfter("private fun TvProfileDropdown(") + val watch = profile.indexOf("label = \"Watch Together\"") + val requests = profile.indexOf("label = \"Requests\"") + val settings = profile.indexOf("label = \"Settings\"") + val watchRow = profile.lastIndexOf("ProfileDropdownRow(", startIndex = watch) + assertTrue(watch >= 0) + assertTrue(requests >= 0) + assertTrue(settings >= 0) + assertTrue(watchRow >= 0) + assertTrue(requests < watch) + assertTrue(watch < settings) + assertFalse( + profile.substring( + startIndex = requests + "label = \"Requests\"".length, + endIndex = watchRow, + ).contains("ProfileDropdownRow("), + ) + assertTrue(shell.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + } + + @Test + fun popupOwnsFocusAndBackRestoresProfileFocus() { + assertTrue(dialog.contains("PopupProperties(")) + assertTrue(dialog.contains("focusable = true")) + assertTrue(dialog.contains("rememberTvDialogInitialFocus(initialFocus)")) + assertTrue(shell.contains("focusState.closeProfileMenuForContent()")) + assertTrue(shell.contains("focusState.dismissProfileMenu()")) + } + + @Test + fun menuSurfaceUsesExistingControllerAndNoCredentials() { + assertTrue(shell.contains("watchTogetherViewModel.createEmptyVoteRoom()")) + assertTrue(shell.contains("watchTogetherViewModel.resumeCurrentRoom()")) + assertTrue(shell.contains("TvJoinCodeDialog(")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(dialog.contains(label)) + } + assertFalse(dialog.contains("WatchTogetherApi")) + assertFalse(dialog.contains("room_token")) + assertFalse(dialog.contains("roomAccessToken")) + assertFalse(dialog.contains("Authorization")) + assertFalse(dialog.contains("CleartextOriginConsent")) + assertFalse(dialog.contains("HttpClient")) + assertTrue(shell.contains("error = watchTogetherState.error")) + assertTrue(dialog.contains("error?.let")) + } + + @Test + fun initialActionPrefersResumeOnlyWhenAvailable() { + assertEquals( + TvWatchTogetherMenuInitialAction.Resume, + tvWatchTogetherMenuInitialAction(canResume = true), + ) + assertEquals( + TvWatchTogetherMenuInitialAction.Host, + tvWatchTogetherMenuInitialAction(canResume = false), + ) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt index d44653ce2..cd87332a7 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt @@ -37,7 +37,12 @@ class TvWatchTogetherSurfaceSourceTest { fun aResolvedRoomReachesTheNavigationCallback() { assertTrue(itemDetailScreen.contains("onWatchTogether(room)")) assertTrue(itemDetailScreen.contains("watchTogetherViewModel.consumeResult()")) - assertTrue(appNavigation.contains("TvRoute.WatchTogetherLobby(roomId = snapshot.roomId).route")) + // The resolved room now goes through navigateToTvWatchTogether, which + // is what builds the destination — a Watch Together PLAYER target is an + // ordinary playback navigation and has to share the player back-stack + // bookkeeping instead of single-topping the current player in place. + assertTrue(appNavigation.contains("navigateToTvWatchTogether(snapshot")) + assertTrue(appNavigation.contains("tvWatchTogetherDestination(room)")) } @Test diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt new file mode 100644 index 000000000..5b125d3d9 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt @@ -0,0 +1,136 @@ +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createEmptyVoteRoom() + + assertEquals( + listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), + gateway.createRequests, + ) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("room-1", viewModel.uiState.value.result?.roomId) + } + + @Test + fun resumeUsesCurrentRoomWithoutNetworkCalls() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals(room, viewModel.uiState.value.result) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun joinCodeNormalizesAndKeepsExistingErrorCopy() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.joinRoom(" abcd1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeResult() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinRoom("efgh5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + @Test + fun titleDetailHostStillSetsSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createRoom(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom( + request: CreateRoomRequest, + ): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom( + request: JoinRoomRequest, + ): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt new file mode 100644 index 000000000..d1053f763 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvContentUpNavigationTest.kt @@ -0,0 +1,17 @@ +package org.prairieserver.prairie.tv.ui.shell + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvContentUpNavigationTest { + @Test + fun heldUpAfterContentCannotMoveStaysInContent() { + assertFalse(shouldRequestMenuAfterContentUp(movedWithinContent = false, isRepeat = true)) + } + + @Test + fun freshUpAfterContentCannotMoveEntersMenu() { + assertTrue(shouldRequestMenuAfterContentUp(movedWithinContent = false, isRepeat = false)) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusStateTest.kt new file mode 100644 index 000000000..9bb121a3d --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusStateTest.kt @@ -0,0 +1,79 @@ +package org.prairieserver.prairie.tv.ui.shell + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvDetailReturnFocusStateTest { + @Test + fun requestedRetryKeepsCardFallbackPending() { + val state = beginTvDetailReturnRetry(previousRequestId = 7, needsRetry = true) + + assertEquals(8, state.requestId) + assertTrue(state.needsRetry) + assertTrue(state.fallbackPending) + } + + @Test + fun completedRetryClearsRetryAndFallback() { + val completed = completeTvDetailReturnRetry( + TvDetailReturnFocusState(requestId = 8, needsRetry = true, fallbackPending = true), + ) + + assertEquals(8, completed.requestId) + assertFalse(completed.needsRetry) + assertFalse(completed.fallbackPending) + } + + @Test + fun explicitRootSelectionResetsReturnState() { + assertEquals( + TvDetailReturnFocusState(), + resetTvDetailReturnFocus(), + ) + } + + @Test + fun otherRootRetryDoesNotArmThisRootsFallback() { + val state = beginTvDetailReturnRetryIfRoot( + previousState = TvDetailReturnFocusState(), + isDetailReturnForRoot = false, + needsRetry = true, + ) + + assertEquals(0, state.requestId) + assertFalse(state.needsRetry) + assertFalse(state.fallbackPending) + } + + @Test + fun rootRetryArmsCardFallbackUntilRetryCompletes() { + val state = beginTvDetailReturnRetryIfRoot( + previousState = TvDetailReturnFocusState(requestId = 7), + isDetailReturnForRoot = true, + needsRetry = true, + ) + + assertEquals(8, state.requestId) + assertTrue(state.needsRetry) + assertTrue(state.fallbackPending) + } + + @Test + fun successfulResumeDoesNotLeaveRetryOrFallbackPending() { + val state = beginTvDetailReturnRetryIfRoot( + previousState = TvDetailReturnFocusState( + requestId = 7, + needsRetry = true, + fallbackPending = true, + ), + isDetailReturnForRoot = true, + needsRetry = false, + ) + + assertEquals(8, state.requestId) + assertFalse(state.needsRetry) + assertFalse(state.fallbackPending) + } +} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt index e5769a1bb..9d16fb7bc 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt @@ -98,6 +98,43 @@ class TvShellFocusStateTest { ) } + @Test + fun backFromRootContentRetainsTheActiveRootAsItsMenuTarget() { + val state = TvShellFocusState() + + assertEquals( + TvShellBackAction.MoveFocusToMenu, + state.onBack( + onTabRoot = true, + menuFocusTarget = moviesPanel, + ), + ) + + assertEquals(moviesPanel, state.menuFocusTarget) + } + + @Test + fun contentUpOnSecondaryRoutesDoesNotFallbackToHomeFocus() { + val state = TvShellFocusState() + val before = state.menuFocusRequest + + state.requestMenuFocusIfAvailable(target = null) + + assertEquals(before, state.menuFocusRequest) + assertNull(state.menuFocusTarget) + } + + @Test + fun contentUpOnSearchMayUseTheSearchOwnedNullTarget() { + val state = TvShellFocusState() + val before = state.menuFocusRequest + + state.requestMenuFocusIfAvailable(target = null, allowNullTarget = true) + + assertEquals(before + 1, state.menuFocusRequest) + assertNull(state.menuFocusTarget) + } + @Test fun backOnSecondaryScreensStillDelegatesToNav() { assertEquals( @@ -122,11 +159,13 @@ class TvShellFocusStateTest { // Entering does NOT nudge the bar — only a Back-close does. assertEquals(menuBefore, s.menuFocusRequest) - s.closePanel(returnFocusToBar = true) + // Closing never moves focus itself: the caller owns that, so a commit's + // own content-focus move cannot be raced back to the bar from here. + s.closePanel() assertNull(s.openPanel) assertFalse(s.panelEntersFocus) - assertEquals(menuBefore + 1, s.menuFocusRequest) - assertEquals(moviesPanel, s.menuFocusTarget) + assertEquals(menuBefore, s.menuFocusRequest) + assertNull(s.menuFocusTarget) } @Test @@ -134,11 +173,220 @@ class TvShellFocusStateTest { val s = TvShellFocusState() s.enterPanel(moviesPanel) val menuBefore = s.menuFocusRequest - s.closePanel(returnFocusToBar = false) + s.closePanel() assertEquals(menuBefore, s.menuFocusRequest) assertNull(s.menuFocusTarget) } + /** + * A dwell preview opens while focus is still on the bar. Back must dismiss + * it and leave the viewer where they are: routing it through ClosePanel + * threw them into content from a menu they were still browsing, and cost + * them the trip back up to reach Home. + */ + @Test + fun backDismissesADwellPreviewWithoutLeavingTheBar() { + val s = TvShellFocusState() + s.previewPanel(moviesPanel) + assertEquals(moviesPanel, s.openPanel) + assertFalse(s.panelEntersFocus) + val menuBefore = s.menuFocusRequest + + val action = s.onBack(onTabRoot = true) + + assertEquals(TvShellBackAction.ClosePanelPreview, action) + assertNull(s.openPanel) + // Focus is on the bar, but not necessarily on the ANCHOR: with no + // target named, the bar falls back to the SELECTED tab — Home, or the + // search icon on the Search route — so backing out of Movies' cascade + // landed on Home. Name the anchor, and suppress its dwell so it does + // not immediately re-preview the cascade Back just dismissed. + assertEquals(menuBefore + 1, s.menuFocusRequest) + assertEquals(moviesPanel, s.menuFocusTarget) + assertTrue(s.menuFocusSuppressesDwell) + } + + /** + * When the bar has installed its synchronous hook, focus moves while the + * panel is still composed and the state request is not needed. Ordering is + * the whole point: closing first lets Compose recover focus onto the bar's + * first child (the search icon) a frame before any request of ours lands, + * which is a visible flash through search on every Back. + */ + @Test + fun theSynchronousAnchorHookReplacesTheDeferredFocusRequest() { + val s = TvShellFocusState() + val anchors = mutableListOf() + var panelStillOpenWhenFocusMoved: TvTopMenuPanel? = null + s.focusBarAnchorNow = { anchor -> + anchors += anchor + panelStillOpenWhenFocusMoved = s.openPanel + true + } + s.previewPanel(moviesPanel) + val menuBefore = s.menuFocusRequest + + assertEquals(TvShellBackAction.ClosePanelPreview, s.onBack(onTabRoot = true)) + + assertEquals(listOf(moviesPanel), anchors) + assertEquals(moviesPanel, panelStillOpenWhenFocusMoved) + assertNull(s.openPanel) + assertEquals(menuBefore, s.menuFocusRequest, "the hook moved focus; no deferred request needed") + } + + /** A hook that could not move focus still falls back to the request. */ + @Test + fun aFailedAnchorHookFallsBackToTheDeferredRequest() { + val s = TvShellFocusState() + s.focusBarAnchorNow = { false } + s.previewPanel(moviesPanel) + val menuBefore = s.menuFocusRequest + + s.onBack(onTabRoot = true) + + assertEquals(menuBefore + 1, s.menuFocusRequest) + assertEquals(moviesPanel, s.menuFocusTarget) + assertTrue(s.menuFocusSuppressesDwell) + } + + /** + * The other half: a panel the viewer actually entered still hands focus to + * content on Back, rather than stranding them in the chrome. + */ + @Test + fun backOutOfAnEnteredPanelStillReturnsToContent() { + val s = TvShellFocusState() + s.enterPanel(moviesPanel) + s.onPanelFocusChanged(true) + assertTrue(s.panelEntersFocus) + + val action = s.onBack(onTabRoot = true) + + assertEquals(TvShellBackAction.ClosePanel, action) + assertNull(s.openPanel) + } + + /** + * Entry intent that never became focus. An empty panel, an unattached + * requester or a silently failed claim all leave the viewer on the bar, and + * Back must return them to the bar's world rather than throwing them into + * content they never reached. + */ + @Test + fun anEnteredPanelThatNeverTookFocusIsStillTreatedAsAPreview() { + val s = TvShellFocusState() + s.enterPanel(moviesPanel) + assertTrue(s.panelEntersFocus, "intent is recorded") + assertFalse(s.panelHasFocus, "but nothing inside it ever focused") + + assertEquals(TvShellBackAction.ClosePanelPreview, s.onBack(onTabRoot = true)) + assertNull(s.openPanel) + } + + @Test + fun closingAPanelForgetsThatItHadFocus() { + val s = TvShellFocusState() + s.enterPanel(moviesPanel) + s.onPanelFocusChanged(true) + s.closePanel() + assertFalse(s.panelHasFocus) + } + + @Test + fun previewAndEnteredRouteDifferentlyFromTheSameOpenPanel() { + assertEquals( + TvShellBackAction.ClosePanelPreview, + tvShellBackAction( + panelOpen = true, + profileMenuOpen = false, + menuFocused = true, + onTabRoot = true, + panelEntered = false, + ), + ) + assertEquals( + TvShellBackAction.ClosePanel, + tvShellBackAction( + panelOpen = true, + profileMenuOpen = false, + menuFocused = false, + onTabRoot = true, + panelEntered = true, + ), + ) + } + + /** + * The stranding bug, reproduced on a Google TV Streamer: Back from content + * asks the bar to take focus, the bar never reports taking it, and every + * subsequent Back re-evaluates to the same request. Four consecutive + * "focus request -> menu" with no "focused -> menu" between them, and Home + * and exit unreachable for as long as it lasts. + */ + @Test + fun aSecondBackGoesHomeWhenTheBarNeverTookFocus() { + val s = TvShellFocusState() + + // First Back climbs toward the bar. + assertEquals(TvShellBackAction.MoveFocusToMenu, s.onBack(onTabRoot = true)) + assertTrue(s.barHandoffAttempted) + + // The bar never answers — updateMenuFocused(true) never arrives. + assertEquals( + TvShellBackAction.MenuBack, + s.onBack(onTabRoot = true), + "a repeat request is what stranded the viewer; the second Back must progress", + ) + } + + /** + * The escalation must never fire on Home, because MenuBack there means + * EXIT. An earlier cut of this branch escalated unconditionally, so a bar + * that never answered on Home turned the second Back into a silent app + * exit — a worse failure than the stranding it was meant to fix. + */ + @Test + fun theEscalationNeverExitsTheAppFromHome() { + val s = TvShellFocusState() + + assertEquals(TvShellBackAction.MoveFocusToMenu, s.onBack(onTabRoot = true, onHome = true)) + assertTrue(s.barHandoffAttempted, "the handoff is outstanding, exactly as off Home") + + assertEquals( + TvShellBackAction.MoveFocusToMenu, + s.onBack(onTabRoot = true, onHome = true), + "on Home the unanswered handoff repeats rather than escalating to exit", + ) + } + + /** When the bar DOES answer, the ladder is unchanged. */ + @Test + fun aBarThatTakesFocusStillGetsTheNormalLadder() { + val s = TvShellFocusState() + + assertEquals(TvShellBackAction.MoveFocusToMenu, s.onBack(onTabRoot = true)) + s.updateMenuFocused(true) + assertFalse(s.barHandoffAttempted, "the bar answered, so nothing is outstanding") + + assertEquals(TvShellBackAction.MenuBack, s.onBack(onTabRoot = true)) + } + + @Test + fun closingAPanelForgetsAnUnansweredHandoff() { + val s = TvShellFocusState() + s.onBack(onTabRoot = true) + assertTrue(s.barHandoffAttempted) + + s.closePanel() + + assertFalse(s.barHandoffAttempted) + assertEquals( + TvShellBackAction.MoveFocusToMenu, + s.onBack(onTabRoot = true), + "a fresh Back after a deliberate focus move should climb again, not skip to Home", + ) + } + @Test fun dwellPreviewNeverOverridesAnEnteredPanel() { val s = TvShellFocusState() @@ -146,7 +394,7 @@ class TvShellFocusStateTest { s.previewPanel(seriesPanel) // ignored while a panel is entered assertEquals(moviesPanel, s.openPanel) - s.closePanel(returnFocusToBar = false) + s.closePanel() s.previewPanel(seriesPanel) // honored once nothing is entered assertEquals(seriesPanel, s.openPanel) @@ -190,6 +438,20 @@ class TvShellFocusStateTest { assertEquals(before, s.profileFocusRequest) } + @Test + fun closingMenuForPopupThenDismissingPopupRefocusesAvatar() { + val state = TvShellFocusState() + state.previewProfileMenu() + state.enterProfileMenu() + val before = state.profileFocusRequest + + state.closeProfileMenuForContent() + assertEquals(before, state.profileFocusRequest) + + state.dismissProfileMenu() + assertEquals(before + 1, state.profileFocusRequest) + } + @Test fun profileDwellPreviewsWithoutStealingFocusAndDownEnters() { val s = TvShellFocusState() @@ -234,11 +496,18 @@ class TvShellFocusStateTest { fun onBackAppliesTheStateHalfAndReportsTheAction() { val s = TvShellFocusState() - // Panel open → ClosePanel, panel cleared, bar nudged. + // Panel open → ClosePanel, panel cleared, and focus returned to the + // anchor tab. s.enterPanel(moviesPanel) + // Entry INTENT is not entry: routing waits for the panel to report that + // something inside it actually holds focus. + s.onPanelFocusChanged(true) val menuBefore = s.menuFocusRequest assertEquals(TvShellBackAction.ClosePanel, s.onBack(onTabRoot = true)) assertNull(s.openPanel) + // Back out of an ENTERED cascade also returns to the anchor tab rather + // than diving into content — the tab the viewer was browsing, with its + // dwell suppressed so the cascade does not spring straight back open. assertEquals(menuBefore + 1, s.menuFocusRequest) assertEquals(moviesPanel, s.menuFocusTarget) diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt new file mode 100644 index 000000000..e19345b18 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.tv.ui.shell + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvTopMenuFocusRequestTest { + @Test + fun focusIsRequestedOnlyAfterTheTargetHasHadAFrameToCompose() = runTest { + val events = mutableListOf() + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { events += "focus"; true }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun aTargetThatIsNotAttachedYetIsRetriedOnTheNextFrame() = runTest { + val events = mutableListOf() + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { + events += "focus" + attempts += 1 + attempts == 2 + }, + ) + + assertEquals(listOf("frame", "focus", "frame", "focus"), events) + } + + @Test + fun retryStopsWhenItsTargetIsNoLongerCurrent() = runTest { + val events = mutableListOf() + var targetIsCurrent = true + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + isTargetCurrent = { targetIsCurrent }, + requestFocus = { + events += "focus" + targetIsCurrent = false + false + }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun retryStopsAfterSixFramesWhenTheTargetNeverAttaches() = runTest { + var frames = 0 + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { frames += 1 }, + requestFocus = { + attempts += 1 + attempts == 7 + }, + ) + + assertEquals(6, frames) + assertEquals(6, attempts) + } + + @Test + fun libraryTargetIsNotAvailableAfterItsDestinationDisappears() { + val movies = TvRootDestination.LibraryType(TvLibraryTabType.Movies) + + assertEquals( + false, + isTopMenuFocusTargetAvailable( + target = TvTopMenuPanel.Root(movies), + destinations = listOf(TvRootDestination.Home, TvRootDestination.Calendar), + ), + ) + } + + @Test + fun focusRequestRemainsPendingUntilItsLibraryDestinationAppears() = runTest { + val movies = TvRootDestination.LibraryType(TvLibraryTabType.Movies) + val identity = 1 to TvTopMenuPanel.Root(movies) + val initialHandled = 0 to null + var focusAttempts = 0 + + val handledWhileAbsent = handleTopMenuFocusRequestIfAvailable( + requestIdentity = identity, + lastHandledRequest = initialHandled, + isFocusSuppressed = false, + isTargetAvailable = false, + requestFocus = { + focusAttempts += 1 + true + }, + ) + val handledAfterAppearing = handleTopMenuFocusRequestIfAvailable( + requestIdentity = identity, + lastHandledRequest = handledWhileAbsent, + isFocusSuppressed = false, + isTargetAvailable = true, + requestFocus = { + focusAttempts += 1 + true + }, + ) + + assertEquals(initialHandled, handledWhileAbsent) + assertEquals(identity, handledAfterAppearing) + assertEquals(1, focusAttempts) + } +} diff --git a/assets/icon.png b/assets/icon.png index 3543ed5d5..1d30a8202 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/baselineprofile-tv/build.gradle.kts b/baselineprofile-tv/build.gradle.kts new file mode 100644 index 000000000..ef833d330 --- /dev/null +++ b/baselineprofile-tv/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.test) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.androidx.baselineprofile) +} + +/** + * Baseline Profile generator for :androidTvApp — the TV twin of :baselineprofile. + * + * Why it matters on TV: ART refuses to AOT-compile debuggable builds, and a + * release build only gets compiled by the device's idle-time dexopt, so first + * launches JIT-compile the big Compose screens (Home feed, top bar, cascades) + * while the user is navigating. Measured on a Shield: 47% janky / p90 121ms + * warm-JIT vs 26% / p90 29ms once AOT-compiled. The profile bakes that + * compilation into the install. + * + * Generation is DEVICE-GATED and needs a signed-in TV running API 33+ (or a + * rooted API 28+ one — androidx.benchmark refuses to collect otherwise; the + * Android 11 Shield cannot). A headless managed emulator would only ever + * record the login screen, so run it against a connected device — the local + * TV AVD (API 36) after pairing it once: + * + * ./gradlew :baselineprofile-tv:generateBaselineProfile -PallowDebugReleaseSigning=true + * + * The output lands in androidTvApp/src/main/generated/baselineProfiles/ and is + * merged into the release APK by the plugin applied in :androidTvApp. + */ +android { + namespace = "org.prairieserver.prairie.baselineprofile.tv" + compileSdk = 36 + + defaultConfig { + minSdk = 28 + targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlinOptions { + jvmTarget = "21" + } + + targetProjectPath = ":androidTvApp" +} + +baselineProfile { + useConnectedDevices = true +} + +dependencies { + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.uiautomator) + implementation(libs.androidx.benchmark.macro.junit4) +} diff --git a/baselineprofile-tv/gradle.lockfile b/baselineprofile-tv/gradle.lockfile new file mode 100644 index 000000000..01972b500 --- /dev/null +++ b/baselineprofile-tv/gradle.lockfile @@ -0,0 +1,490 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +androidx.activity:activity-compose:1.12.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.activity:activity-ktx:1.12.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.activity:activity:1.12.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.annotation:annotation-experimental:1.4.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.5.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.annotation:annotation-jvm:1.7.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.9.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.annotation:annotation:1.7.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.annotation:annotation:1.9.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.appcompat:appcompat-resources:1.7.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.appcompat:appcompat:1.7.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.arch.core:core-common:2.1.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.arch.core:core-runtime:2.1.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.benchmark:benchmark-common:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.benchmark:benchmark-macro-junit4:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.benchmark:benchmark-macro:1.3.4=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.collection:collection-ktx:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.collection:collection:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.collection:collection:1.5.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.animation:animation-core-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.animation:animation-core:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.animation:animation-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.animation:animation:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.foundation:foundation-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.foundation:foundation-layout-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.foundation:foundation-layout:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.foundation:foundation:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.material:material-icons-core-desktop:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.material:material-icons-core:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime-annotation-jvm:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime-annotation:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime-desktop:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime-saveable-desktop:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime-saveable:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.runtime:runtime:1.9.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-geometry-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-geometry:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-graphics-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-graphics:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-text-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-text:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-unit-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-unit:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-util-jvmstubs:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui-util:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose.ui:ui:1.7.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.compose:compose-bom:2024.12.01=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.concurrent:concurrent-futures-ktx:1.1.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.concurrent:concurrent-futures-ktx:1.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.concurrent:concurrent-futures:1.1.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.core:core-ktx:1.16.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.core:core-viewtree:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.core:core:1.16.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.core:core:1.9.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.customview:customview-poolingcontainer:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.customview:customview:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-core-jvm:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-core-okio-jvm:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-core-okio:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-core:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-jvm:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences-core-jvm:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences-core:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences-external-protobuf:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences-jvm:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences-proto:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore-preferences:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.datastore:datastore:1.2.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.documentfile:documentfile:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.drawerlayout:drawerlayout:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.emoji2:emoji2-views-helper:1.3.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.emoji2:emoji2:1.3.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.exifinterface:exifinterface:1.3.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.fragment:fragment-ktx:1.8.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.fragment:fragment:1.8.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.interpolator:interpolator:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.legacy:legacy-support-core-utils:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-common-jvm:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-common:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-common:2.3.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-livedata-core:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-livedata:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-process:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime-compose-desktop:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime-compose:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime-desktop:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime-ktx:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-runtime:2.3.1=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.lifecycle:lifecycle-service:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-compose-jvmstubs:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-desktop:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-savedstate-desktop:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.lifecycle:lifecycle-viewmodel:2.10.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.loader:loader:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-common-ktx:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-common:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-container:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-database:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-datasource-okhttp:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-datasource:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-decoder:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-effect:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-exoplayer-hls:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-exoplayer:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-extractor:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-session:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-ui-compose:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media3:media3-ui:1.10.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.media:media:1.7.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-common-jvmstubs:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-common:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-compose-jvmstubs:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-compose:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-runtime-jvmstubs:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigation:navigation-runtime:2.9.8=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigationevent:navigationevent-compose-jvmstubs:1.0.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigationevent:navigationevent-compose:1.0.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigationevent:navigationevent-desktop:1.0.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.navigationevent:navigationevent:1.0.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.palette:palette-ktx:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.palette:palette:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.print:print:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.profileinstaller:profileinstaller:1.3.1=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.recyclerview:recyclerview:1.3.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.resourceinspection:resourceinspection-annotation:1.0.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.room:room-common-jvm:2.8.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.room:room-common:2.8.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.room:room-ktx:2.8.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.room:room-runtime-jvm:2.8.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.room:room-runtime:2.8.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.savedstate:savedstate-compose-desktop:1.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.savedstate:savedstate-compose:1.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.savedstate:savedstate-desktop:1.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.savedstate:savedstate-ktx:1.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.savedstate:savedstate:1.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.security:security-crypto:1.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.sqlite:sqlite-jvm:2.6.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.sqlite:sqlite:2.6.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.startup:startup-runtime:1.1.1=benchmarkReleaseRuntimeClasspath,benchmarkReleaseTestedApks,nonMinifiedReleaseRuntimeClasspath,nonMinifiedReleaseTestedApks +androidx.test.ext:junit:1.2.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test.services:storage:1.5.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test.uiautomator:uiautomator:2.3.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test:annotation:1.0.1=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test:core:1.6.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test:monitor:1.7.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test:rules:1.5.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.test:runner:1.5.2=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing-ktx:1.1.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing-ktx:1.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.tracing:tracing-perfetto-binary:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing-perfetto-handshake:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing-perfetto:1.0.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing:1.1.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +androidx.tracing:tracing:1.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.tv:tv-material:1.0.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.tvprovider:tvprovider:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.vectordrawable:vectordrawable-animated:1.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.vectordrawable:vectordrawable:1.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.versionedparcelable:versionedparcelable:1.1.1=benchmarkReleaseRuntimeClasspath,benchmarkReleaseTestedApks,nonMinifiedReleaseRuntimeClasspath,nonMinifiedReleaseTestedApks +androidx.viewpager:viewpager:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.work:work-runtime-ktx:2.11.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +androidx.work:work-runtime:2.11.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-concurrency-jvm:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-concurrency:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-concurrent-collections-jvm:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-concurrent-collections:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-strict-jvm:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +co.touchlab:stately-strict:2.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.android.tools.ddms:ddmlib:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib +com.android.tools.emulator:proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-device-provider-ddmlib-proto:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib +com.android.tools.utp:android-device-provider-ddmlib:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib +com.android.tools.utp:android-device-provider-gradle-proto:31.10.1=_internal-unified-test-platform-android-device-provider-gradle +com.android.tools.utp:android-device-provider-gradle:31.10.1=_internal-unified-test-platform-android-device-provider-gradle +com.android.tools.utp:android-device-provider-profile-proto:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle +com.android.tools.utp:android-device-provider-profile:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle +com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output +com.android.tools.utp:android-test-plugin-host-additional-test-output:31.10.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output +com.android.tools.utp:android-test-plugin-host-apk-installer-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-apk-installer +com.android.tools.utp:android-test-plugin-host-apk-installer:31.10.1=_internal-unified-test-platform-android-test-plugin-host-apk-installer +com.android.tools.utp:android-test-plugin-host-coverage-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-coverage +com.android.tools.utp:android-test-plugin-host-coverage:31.10.1=_internal-unified-test-platform-android-test-plugin-host-coverage +com.android.tools.utp:android-test-plugin-host-device-info-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-device-info:31.10.1=_internal-unified-test-platform-android-test-plugin-host-device-info +com.android.tools.utp:android-test-plugin-host-emulator-control-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-test-plugin-host-emulator-control:31.10.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.android.tools.utp:android-test-plugin-host-logcat-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:android-test-plugin-host-logcat:31.10.1=_internal-unified-test-platform-android-test-plugin-host-logcat +com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:31.10.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.utp:android-test-plugin-result-listener-gradle:31.10.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools.utp:utp-common:31.10.1=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat +com.android.tools:annotations:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.android.tools:common:31.10.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core +com.google.api.grpc:proto-google-common-protos:2.48.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto.service:auto-service:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.auto:auto-common:1.2.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.findbugs:jsr305:3.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-core +com.google.code.gson:gson:2.11.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.crypto.tink:tink-android:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.errorprone:error_prone_annotations:2.28.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.errorprone:error_prone_annotations:2.30.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.guava:failureaccess:1.0.2=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.guava:guava:33.3.1-android=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.guava:guava:33.3.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.guava:listenablefuture:1.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.j2objc:j2objc-annotations:2.8=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.j2objc:j2objc-annotations:3.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.google.protobuf:protobuf-java-util:3.22.3=_internal-unified-test-platform-core +com.google.protobuf:protobuf-java-util:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +com.google.protobuf:protobuf-java:3.24.4=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.protobuf:protobuf-java:3.25.5=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.protobuf:protobuf-kotlin:3.24.4=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +com.google.testing.platform:android-device-provider-local:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha03=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-emulator-control +com.google.testing.platform:android-test-plugin:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin +com.google.testing.platform:core-proto:0.0.9-alpha03=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +com.google.testing.platform:core:0.0.9-alpha03=_internal-unified-test-platform-core +com.google.testing.platform:launcher:0.0.9-alpha03=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +com.google.zxing:core:3.5.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.squareup.moshi:moshi:1.13.0=benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseRuntimeClasspath +com.squareup.okhttp3:okhttp-sse:4.12.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.squareup.okhttp3:okhttp:4.12.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.squareup.okio:okio-jvm:3.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.squareup.okio:okio-jvm:3.7.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +com.squareup.okio:okio:3.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +com.squareup.okio:okio:3.7.0=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +com.squareup.wire:wire-runtime-jvm:4.9.7=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +com.squareup.wire:wire-runtime:4.9.7=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +commons-io:commons-io:2.16.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control +io.coil-kt.coil3:coil-compose-core-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-compose-core:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-compose-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-compose:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-core-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-core:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-network-core-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-network-core:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-network-ktor3-jvm:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil-network-ktor3:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.coil-kt.coil3:coil:3.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.github.peerless2012:ass-kt:0.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.github.peerless2012:ass-media:0.4.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.grpc:grpc-api:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-api:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-context:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-context:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-core:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-core:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-inprocess:1.69.1=_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-netty:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-netty:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-protobuf-lite:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-protobuf-lite:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-protobuf:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-protobuf:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-services:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-stub:1.57.2=_internal-unified-test-platform-core +io.grpc:grpc-stub:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.grpc:grpc-util:1.69.1=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.insert-koin:koin-android:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-androidx-workmanager:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-compose-jvm:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-compose-viewmodel-jvm:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-compose-viewmodel:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-compose:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-core-jvm:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-core-viewmodel-jvm:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-core-viewmodel:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.insert-koin:koin-core:4.1.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-auth-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-auth:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-content-negotiation-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-content-negotiation:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-core-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-core:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-logging-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-logging:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-okhttp-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-okhttp:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-websockets-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-client-websockets:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-events-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-events:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-http-cio-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-http-cio:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-http-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-http:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-io-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-io:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-network-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-network:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization-kotlinx-json-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization-kotlinx-json:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization-kotlinx-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization-kotlinx:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-serialization:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-sse-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-sse:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-utils-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-utils:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-websocket-serialization-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-websocket-serialization:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-websockets-jvm:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.ktor:ktor-websockets:3.1.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +io.netty:netty-buffer:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-buffer:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-codec-http2:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-codec-http2:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-codec-http:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-codec-http:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-codec-socks:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-codec-socks:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-codec:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-codec:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-common:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-handler-proxy:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-handler-proxy:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-handler:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-handler:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-resolver:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-resolver:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-transport-native-unix-common:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-transport-native-unix-common:4.1.93.Final=_internal-unified-test-platform-core +io.netty:netty-transport:4.1.110.Final=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +io.netty:netty-transport:4.1.93.Final=_internal-unified-test-platform-core +io.opencensus:opencensus-api:0.31.0=_internal-unified-test-platform-core +io.opencensus:opencensus-proto:0.2.0=_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +io.perfmark:perfmark-api:0.26.0=_internal-unified-test-platform-core +io.perfmark:perfmark-api:0.27.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +javax.annotation:javax.annotation-api:1.3.2=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +javax.inject:javax.inject:1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +junit:junit:4.13.2=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +net.java.dev.jna:jna-platform:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +net.java.dev.jna:jna:5.6.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +net.sf.kxml:kxml2:2.3.0=_internal-unified-test-platform-android-device-provider-ddmlib +org.bouncycastle:bcprov-jdk18on:1.84=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.bouncycastle:bctls-jdk18on:1.84=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.bouncycastle:bcutil-jdk18on:1.84=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.checkerframework:checker-qual:3.33.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.checkerframework:checker-qual:3.43.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.codehaus.mojo:animal-sniffer-annotations:1.23=_internal-unified-test-platform-core +org.codehaus.mojo:animal-sniffer-annotations:1.24=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.hamcrest:hamcrest-core:1.3=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.5=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop:2.9.5=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.5=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.5=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose-desktop:2.9.0-beta01=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0-beta01=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.5=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.androidx.savedstate:savedstate:1.3.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.animation:animation-core-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.animation:animation-core:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.animation:animation-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.animation:animation:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.annotation-internal:annotation:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.collection-internal:collection:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.foundation:foundation-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.foundation:foundation-layout-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.foundation:foundation-layout:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.foundation:foundation:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material3:material3-desktop:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material3:material3:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-icons-core-desktop:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-icons-core:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-icons-extended-desktop:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-icons-extended:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-ripple-desktop:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.material:material-ripple:1.7.3=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.runtime:runtime-desktop:1.9.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.runtime:runtime-saveable-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.runtime:runtime-saveable:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.runtime:runtime:1.9.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-backhandler-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-backhandler:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-geometry-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-geometry:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-graphics-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-graphics:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-text-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-text:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-unit-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-unit:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-util-desktop:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui-util:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.compose.ui:ui:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlin:kotlin-build-tools-api:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-compiler-runner:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-client:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:2.1.20=kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-reflect:1.8.21=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-script-runtime:2.1.20=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-scripting-common:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-scripting-jvm:2.1.20=kotlinBuildToolsApiClasspath +org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-stdlib-common:2.1.20=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,implementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.10=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,implementationDependenciesMetadata +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.10=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlin:kotlin-stdlib:1.8.21=_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core +org.jetbrains.kotlin:kotlin-stdlib:1.9.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-launcher +org.jetbrains.kotlin:kotlin-stdlib:2.1.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle +org.jetbrains.kotlin:kotlin-stdlib:2.1.20=apiDependenciesMetadata,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlin:kotlin-stdlib:2.2.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu-jvm:0.23.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:atomicfu:0.20.2=implementationDependenciesMetadata +org.jetbrains.kotlinx:atomicfu:0.22.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:atomicfu:0.23.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1=benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher +org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-datetime:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-io-bytestring:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-io-core:0.6.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.skiko:skiko-awt:0.9.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains.skiko:skiko:0.9.4=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.jetbrains:annotations:13.0=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains:annotations:23.0.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher,benchmarkReleaseCompileClasspath,benchmarkReleaseRuntimeClasspath,benchmarkReleaseTestedApks,implementationDependenciesMetadata,nonMinifiedReleaseCompileClasspath,nonMinifiedReleaseRuntimeClasspath,nonMinifiedReleaseTestedApks +org.jspecify:jspecify:1.0.0=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +org.slf4j:slf4j-api:2.0.16=benchmarkReleaseTestedApks,nonMinifiedReleaseTestedApks +empty=androidApis,androidJdkImage,androidTestUtil,benchmarkReleaseAnnotationProcessorClasspath,benchmarkReleaseApiDependenciesMetadata,benchmarkReleaseCompileOnlyDependenciesMetadata,benchmarkReleaseImplementationDependenciesMetadata,benchmarkReleaseIntransitiveDependenciesMetadata,compileOnlyDependenciesMetadata,coreLibraryDesugaring,debugApiDependenciesMetadata,debugCompileOnlyDependenciesMetadata,debugImplementationDependenciesMetadata,debugIntransitiveDependenciesMetadata,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinCompilerPluginClasspathBenchmarkRelease,kotlinCompilerPluginClasspathNonMinifiedRelease,kotlinNativeCompilerPluginClasspath,lintChecks,lintPublish,nonMinifiedReleaseAnnotationProcessorClasspath,nonMinifiedReleaseApiDependenciesMetadata,nonMinifiedReleaseCompileOnlyDependenciesMetadata,nonMinifiedReleaseImplementationDependenciesMetadata,nonMinifiedReleaseIntransitiveDependenciesMetadata diff --git a/baselineprofile-tv/src/main/AndroidManifest.xml b/baselineprofile-tv/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b2d3ea123 --- /dev/null +++ b/baselineprofile-tv/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/baselineprofile-tv/src/main/kotlin/org/prairieserver/prairie/baselineprofile/tv/TvBaselineProfileGenerator.kt b/baselineprofile-tv/src/main/kotlin/org/prairieserver/prairie/baselineprofile/tv/TvBaselineProfileGenerator.kt new file mode 100644 index 000000000..836a84130 --- /dev/null +++ b/baselineprofile-tv/src/main/kotlin/org/prairieserver/prairie/baselineprofile/tv/TvBaselineProfileGenerator.kt @@ -0,0 +1,47 @@ +package org.prairieserver.prairie.baselineprofile.tv + +import androidx.benchmark.macro.junit4.BaselineProfileRule +import org.junit.Rule +import org.junit.Test + +/** + * Records the TV app's hot paths: cold start to first frame, then a d-pad + * browse of whatever surface it lands on. On a signed-in device that is Home — + * vertical moves across rows (row composition, card rails, hero crossfade) and + * horizontal moves within a rail (card focus, pinning scroll) — which is + * exactly the code that JIT-stalls on a fresh install. Runs three iterations so + * the profile keeps only methods hot on every pass. + */ +class TvBaselineProfileGenerator { + @get:Rule + val rule = BaselineProfileRule() + + @Test + fun generate() = rule.collect( + packageName = PACKAGE_NAME, + maxIterations = 3, + ) { + pressHome() + startActivityAndWait() + device.waitForIdle() + // Let the feed and hero artwork settle before browsing. + Thread.sleep(SETTLE_MS) + + // Down through the rows, a short right/left within each, then back up. + repeat(ROWS) { + device.pressDPadDown() + device.waitForIdle() + repeat(CARDS) { device.pressDPadRight(); device.waitForIdle() } + repeat(CARDS) { device.pressDPadLeft(); device.waitForIdle() } + } + repeat(ROWS) { device.pressDPadUp(); device.waitForIdle() } + Thread.sleep(SETTLE_MS) + } + + private companion object { + const val PACKAGE_NAME = "org.prairieserver.prairie" + const val ROWS = 5 + const val CARDS = 4 + const val SETTLE_MS = 1_500L + } +} diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts index a1cfa5f3b..3cf974197 100644 --- a/baselineprofile/build.gradle.kts +++ b/baselineprofile/build.gradle.kts @@ -25,7 +25,7 @@ android { defaultConfig { // Baseline Profile generation requires API 28+ (33+ recommended). minSdk = 28 - targetSdk = 35 + targetSdk = 36 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/docs/README.md b/docs/README.md index fc23ee707..99c3b2a68 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Prairie Android Docs +# Silo Android Docs Start with the root [README](../README.md) for architecture/build instructions and [FEATURES](../FEATURES.md) for the current exposed feature inventory. @@ -13,8 +13,10 @@ Start with the root [README](../README.md) for architecture/build instructions a apps can open downloaded videos, audiobooks, and ebooks. - Android TV has a dedicated audiobook detail/player flow; ebooks remain phone-only. -- Client diagnostics are Android-native and self-hosted: adult profiles can - review local reports and choose consent, while child profiles are excluded. +- Client diagnostics are Android-native: the hosted Silo collector is the + default destination, and self-hosted ingest remains an explicit choice. + Adult profiles can review local reports and choose consent, while child + profiles are excluded. No third-party observability SDK is part of the Android implementation. ## Folders @@ -31,4 +33,6 @@ Start with the root [README](../README.md) for architecture/build instructions a - Design: [`superpowers/specs/2026-07-22-android-client-diagnostics-design.md`](superpowers/specs/2026-07-22-android-client-diagnostics-design.md) - Implementation plan and verification commands: [`superpowers/plans/2026-07-22-android-client-diagnostics.md`](superpowers/plans/2026-07-22-android-client-diagnostics.md) -- The compatible server ingest endpoint shipped separately in Prairie Server PR 445. +- Hosted destination design: [`superpowers/specs/2026-08-12-android-hosted-diagnostics-design.md`](superpowers/specs/2026-08-12-android-hosted-diagnostics-design.md) +- Hosted destination implementation plan: [`superpowers/plans/2026-08-12-android-hosted-diagnostics.md`](superpowers/plans/2026-08-12-android-hosted-diagnostics.md) +- The compatible server ingest endpoint shipped separately in Silo Server PR 445. diff --git a/docs/notes/2026-07-27-pr108-slice-b-traceability.md b/docs/notes/2026-07-27-pr108-slice-b-traceability.md index b63fd0675..038f94dc9 100644 --- a/docs/notes/2026-07-27-pr108-slice-b-traceability.md +++ b/docs/notes/2026-07-27-pr108-slice-b-traceability.md @@ -19,7 +19,7 @@ state needed by that slice. | `65c4b316` | Credential-generation scope prerequisite | Relevant token/scope net state is carried by `aeead9d9` | | `1fecf9b1` | Central HTTP origin model | Folded with its follow-up into `38fbf837` | | `cbf398fa` | Reject ambiguous authorities and normalize safe HTTP origins | Folded with `1fecf9b1` into `38fbf837` | -| `5d5f0562` | Never attach or refresh Silo credentials off-origin | Ported as `aeead9d9` | +| `5d5f0562` | Never attach or refresh Prairie credentials off-origin | Ported as `aeead9d9` | | `e18d092e` | Reject stale persistent credential generations | Ported in `aeead9d9`, with startup-snapshot correction in `15278f95` | | `c07d9f9f` | Require explicit cleartext-origin consent | Ported as `dd1e0992`, with client/media/startup/pairing enforcement in `15278f95` | | `85890c6d` | Parsed EPUB allowlist sanitizer | Cherry-picked with `-x` as `60d20931` | diff --git a/docs/plans/uiux-overhaul-remaining-phases.md b/docs/plans/uiux-overhaul-remaining-phases.md index b4e75729c..38144725f 100644 --- a/docs/plans/uiux-overhaul-remaining-phases.md +++ b/docs/plans/uiux-overhaul-remaining-phases.md @@ -32,7 +32,7 @@ Kotlin Multiplatform. Modules: Stack: Kotlin 2.1.20, Compose Multiplatform 1.7.3, Material3, Compose-for-TV 1.0.1, Coil3 3.1.0, Media3/ExoPlayer 1.10.0 + libmpv, Koin DI, navigation-compose 2.9.0, Room 2.8.4, DataStore, WorkManager. minSdk 24, targetSdk 35, compileSdk 36. -Package root `org.prairieserver.prairie` (dirs say "silo"; product is "Prairie"). +Package root `org.prairieserver.prairie` (dirs say "prairie"; product is "Prairie"). iOS-parity is a deliberate design driver — many files say "Mirrors iosApp …". --- @@ -330,7 +330,7 @@ no resource shrinking. No Baseline Profile, no `profileinstaller`, no signingCon ## 6. Quick-start checklist for the new session 1. `git checkout uiux-fluidity-overhaul` and read recent commits (`git log --oneline -10`). -2. Re-read this doc + the memory note `silo-uiux-overhaul-plan` and `silo-build-on-windows`. +2. Re-read this doc + the memory note `prairie-uiux-overhaul-plan` and `prairie-build-on-windows`. 3. Confirm the branch tip still compiles green using the §1 procedure (grep the output!). 4. Pick a phase. 6 is non-gated; 7 and 8 require an owner checkpoint before merge. 5. Work in per-phase commits; keep source-guard tests green; verify by grepping diff --git a/docs/playback/01-media3-only-player-architecture.md b/docs/playback/01-media3-only-player-architecture.md index 340a6089a..7fb47632e 100644 --- a/docs/playback/01-media3-only-player-architecture.md +++ b/docs/playback/01-media3-only-player-architecture.md @@ -1,5 +1,12 @@ # Media3-Only Player Architecture +> Neutral-v3 note (2026-08-06): this document remains authoritative for the +> Android Media3 runtime, but its wire examples predate the platform-neutral +> contract. The server repository's `docs/architecture/playback-protocol-v3.md` +> owns wire semantics. Android now receives neutral delivery capabilities, +> stores server-minted opaque plan-attempt keys, and uses opaque +> `output_context_id` values. + Status: **implemented in Android and validated against the dev-server v3 flow; the published minimum server revision and named hardware validation remain gated**. @@ -27,9 +34,8 @@ The following terms are distinct throughout these specifications: - **Playback attempt**: the complete user start action across any replans, identified before `/start` by `playback_attempt_id`. - **Plan attempt**: one execution of a plan, identified by `plan_attempt_id` and - an idempotent `plan_attempt_key`. Android derives the key deterministically - from `plan_id`, delivery, normalized effective recipe, - `output_route_generation`, and local recovery mutations. + an idempotent, opaque `plan_attempt_key` minted by the server. Android stores + and echoes that key unchanged; it never derives or interprets one locally. - **Replan**: a new server decision after a classified failure, capability change, track change, or quality change. diff --git a/docs/playback/02-migration-compatibility-validation.md b/docs/playback/02-migration-compatibility-validation.md index f6be7b1a7..8bbba0048 100644 --- a/docs/playback/02-migration-compatibility-validation.md +++ b/docs/playback/02-migration-compatibility-validation.md @@ -1,5 +1,10 @@ # Migration, Compatibility, and Validation +> Historical migration note: the A/B sequence below describes the completed +> Media3-only migration and the pre-neutral v3 compatibility window. The +> platform-neutral protocol is a breaking contract and has no fallback to the +> older engine-shaped request/plan model. + Status: **Release B target and dev-server v3 flow implemented; rollout remains blocked on a published minimum server revision and the remaining Phase 0 hardware fixtures**. diff --git a/docs/playback/04-implementation-status-and-dv-handoff.md b/docs/playback/04-implementation-status-and-dv-handoff.md index 5a392ec7b..f10b63815 100644 --- a/docs/playback/04-implementation-status-and-dv-handoff.md +++ b/docs/playback/04-implementation-status-and-dv-handoff.md @@ -1,5 +1,10 @@ # Media3-only implementation status and Dolby Vision handoff +> Superseded wire-status note (2026-08-06): the original validation recorded +> below predates the platform-neutral v3 contract. Its Media3 and hardware +> evidence remains useful; its server compatibility claims do not establish +> compatibility with the neutral server revision. + Status date: 2026-07-12 This file records proof and remaining gates. It does not redefine the diff --git a/docs/playback/README.md b/docs/playback/README.md index 2392ff138..f7630c030 100644 --- a/docs/playback/README.md +++ b/docs/playback/README.md @@ -1,14 +1,18 @@ -# Prairie Android Playback Architecture +# Silo Android Playback Architecture -Status: **Android implementation and dev-server v3 validation complete; 4K DV -and passthrough hardware validation remain gated**. +Status: **the Android client is ported to the platform-neutral playback-v3 +wire contract; live validation still requires a server built from the matching +neutral-v3 revision**. -This directory is the source of truth for the next Prairie Android video player. -This directory supersedes the removed legacy Media3/dual-engine notes. +This directory owns the Android Media3 runtime and its validation history. The +normative wire contract is the server repository's +`docs/architecture/playback-protocol-v3.md`; when these older migration notes +disagree with it, the server contract wins. In particular, Android no longer +advertises engine names or computes plan-attempt keys. ## Product decision -Prairie Android will ship one in-process video engine: **Media3 ExoPlayer**. MPV +Silo Android will ship one in-process video engine: **Media3 ExoPlayer**. MPV will be removed from app artifacts, capability reporting, engine selection, recovery, and UI. @@ -17,7 +21,7 @@ every Android player component. The retained foundation and target runtime are defined in [architecture section 2](01-media3-only-player-architecture.md#2-target-runtime). Media3 is the AndroidX media framework; ExoPlayer is its default `Player` -implementation. Prairie already uses that implementation. [Checkout dependency](../../android-shared/build.gradle.kts) · +implementation. Silo already uses that implementation. [Checkout dependency](../../android-shared/build.gradle.kts) · [player factory](../../android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt) See the [Media3 ExoPlayer overview](https://developer.android.com/media/media3/exoplayer) @@ -27,10 +31,11 @@ and [migration guide](https://developer.android.com/media/media3/exoplayer/migra | Document | Canonical content | | --- | --- | -| [Architecture](01-media3-only-player-architecture.md) | Runtime invariants, server/client contract, capability schema, HDR/DV, audio, subtitles, recovery, and telemetry. | -| [Migration and validation](02-migration-compatibility-validation.md) | Phase ordering, compatibility window, removal inventory, release gates, hardware fixtures, and rollback. | +| [Architecture](01-media3-only-player-architecture.md) | Android runtime invariants plus the pre-neutral contract history. Neutral wire semantics come from the server contract. | +| [Migration and validation](02-migration-compatibility-validation.md) | Historical Media3-only migration plan, hardware fixtures, and rollback evidence. | | [Reference review](03-reference-implementation-review.md) | Source-pinned Wholphin/Plezy observations. It is evidence, not another implementation plan. | | [Implementation status](04-implementation-status-and-dv-handoff.md) | Code, automated proof, dev-server v3 status, and the 4K Dolby Vision handoff checklist. | +| [Intro skip](intro-skip.md) | Where the never/ask/always prompt lives, and the rules the server spec pins. | | [Shield 1080p capability audit](05-shield-1080p-playback-capability-audit.md) | Live protocol-v3 route matrix, catalog coverage, current direct-play gaps, and prioritized causes. | | [Device-correction evidence and design](06-device-quirk-evidence-and-design.md) | Current Jellyfin Android TV, Jellyfin Android, Wholphin, Plezy, Android platform, and issue evidence for the server/client quirk layer. | @@ -43,9 +48,10 @@ not create another set of requirements. ## Release gate -Android work may proceed in parallel, but Release A must not ship until -[migration Phase 0](02-migration-compatibility-validation.md#2-phase-0-server-readiness) -is marked complete against a named minimum server revision. +The neutral Android build must not ship until a named server revision exposing +the matching platform-neutral v3 contract is published and deployed. A +pre-neutral `playback_plan_v3` server is not compatible merely because the +feature token has the same name. ## Evidence boundary diff --git a/docs/playback/intro-skip.md b/docs/playback/intro-skip.md new file mode 100644 index 000000000..37378c42d --- /dev/null +++ b/docs/playback/intro-skip.md @@ -0,0 +1,61 @@ +# Intro skip: never / ask / always + +The behaviour is specified once, in the server repo, and implemented here: +`docs/design/2026-08-16-intro-skip-mode.md` in `silo-server`. Its +"Prompt behaviour" tables are the contract — read them before changing anything +below, and change them there first if the behaviour needs to move. + +## Setting + +`playback.intro_skip_mode` (`never` | `ask` | `always`, default `ask`), contract +revision 7, scopes `profile` and `profile_device`. It supersedes the deprecated +boolean `playback.auto_skip_intro`, which the server mirrors at write time for +one release. + +## Where each part lives + +| Part | Class | +| --- | --- | +| The mode enum and its wire/legacy mapping | `shared/.../domain/player/IntroSkipMode.kt` | +| The state machine (the spec's tables) | `shared/.../domain/player/IntroAutoSkipController.kt` | +| Conformance against the tables | `shared/src/commonTest/.../IntroAutoSkipControllerTest.kt` | +| Rebuffer-vs-pause filtering | `shared/.../domain/player/SettlingFalseEdges.kt` | +| Reading and writing the setting | `android-shared/.../settings/AndroidPlayerSettingsStore.kt` | +| TV pill | `androidTvApp/.../player/TvIntroAutoSkipBanner.kt` | +| TV Select / Back routing | `androidTvApp/.../player/TvPlayerScreen.kt` (root key handler + `BackHandler`) | +| Phone pill | `androidApp/.../player/IntroAutoSkipBanner.kt` | +| Phone Back | `androidApp/.../player/PlayerOverlay.kt` (`BackHandler(enabled = pill visible)`) | +| Settings UI | `androidTvApp/.../settings/TvSettingsScreen.kt`, `androidApp/.../settings/PlaybackSettings.kt` | + +## Rules worth stating twice + +**The controller performs exactly one seek.** The immediate skip that `always` +is, through `observe(onSeek = ...)`. Everything the viewer triggers is +*returned* by `select()` for the caller to perform. + +**Watch Together pins the mode to `ask`.** In a room only the host's transport +may move position, so a guest must never auto-seek — `TvPlayerViewModel` +substitutes `flowOf(IntroSkipMode.ASK)` for the stored mode whenever `roomId` +is non-null. The pill stays live; its Select routes through the screen's +`tvRoomTransportGate` like every other seek. The gate is checked *before* +`select()` is asked for a target, so a refused press leaves the pill and the +intro untouched. + +**The `always` pill is anchored to the intro, not the position.** The skip that +produces it necessarily leaves the range, so the ordinary "outside the range → +hide" rule would take the undo down on the next frame. While `Skipped` is +showing, position changes do nothing; only the timer, Select, Back, a content +change or a mode change end it. + +**A pause freezes the timer, it does not restart it.** The tick job is +cancelled and `secondsRemaining` is kept; resuming continues from that number. +The fill is frame-clock driven (never an `AnimationSpec` — the system animator +duration scale would make the bar lie about when the action fires), so it needs +two signals from the controller: `countdownRun`, bumped every time the tick job +starts *including* a thaw, to re-anchor its clock, and `timerRunning`, false +while frozen, to hold the bar still. Whole seconds only: the partial second in +flight when the pause landed is not carried across. + +**`ask`'s timeout does not resolve the intro; `always`'s does.** A withdrawn +offer re-offers when the viewer scrubs back in. An expired undo does not — the +viewer was told the intro was skipped and let it go. diff --git a/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md b/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md new file mode 100644 index 000000000..21b2f0761 --- /dev/null +++ b/docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md @@ -0,0 +1,91 @@ +# Android TV Navigation Remediation — Executive Summary + +## Decision + +Ship the cache-first Android TV navigation changes for external testing. The +change addresses the cold-start navigation slowdown and the broken `For You` +focus transition reported in issues #121 and #122 without changing server +contracts, playback behavior, or subtitle handling. + +## Customer impact + +On a large production library, the first trip across Home rows caused bursts of +detail requests and garbage collection while focus was moving. Returning over +the same rows was substantially faster because those details were then cached. +The `For You` filter-to-card transition also lacked an explicit focus bridge, +which could leave Down navigation stuck in the filter controls. + +External testing of the first build found two smaller consistency issues: +holding Up while returning through several Home rows could carry focus straight +into the top menu, and Watchlist/Favorites selected from the For You dropdown +opened visually different standalone pages from the equivalent in-page pills. + +## Verified causes + +- Home inline sections could be hydrated more than once. +- Marquee enrichment and Skyline prefetch treated a cold detail cache as an + invitation to fan out requests during focus movement. +- Skyline work could outlive or race the focus state that requested it. +- `For You` did not explicitly transfer focus between its filters and first + result card. +- The Home row fallback accepted overlapping off-screen relocation requests and + treated a repeated Up event at row zero like a deliberate fresh menu-entry + press. +- The For You dropdown routed saved lists to standalone destinations while the + in-page pills used inline grids. + +Archived PR #108 contained earlier versions of the relevant performance ideas +in commits `49a70045` and `65c4b316`, but those commits were not ancestors of +current `main`. This branch forward-ports the behavior against the current +architecture with focused tests and bounded concurrency. + +## Changes + +- Hydrate shared inline Home sections once. +- Make marquee detail enrichment cache-first. +- Start Skyline prefetch only after focus settles and cancel obsolete work. +- Add explicit, tested `For You` filter/card focus transitions. +- Serialize off-screen Up relocation, stop held/repeated Up on Home's first + content row, and require a fresh Up press to enter the top menu. +- Route For You dropdown Watchlist/Favorites choices through the same inline + presentation as their in-page pills; profile-menu entries remain standalone. +- Add regression coverage for cache misses, request bounds, stale completion, + cancellation, repeat-key boundaries, saved-list requests, and focus routing. + +## Evidence + +Production-backed emulator profiling before the change showed: + +| Scenario | Janky frames | p95 | p99 | HTTP requests | +| --- | ---: | ---: | ---: | ---: | +| Horizontal cold | 18.34% | 53 ms | 700 ms | 45 | +| Horizontal warm | 5.09% | — | — | 0 | +| Second cold | 15.13% | 42 ms | 1000 ms | 46 | +| Vertical cold | 27.34% | 250 ms | 1000 ms | 47 | +| Vertical warm | 12.97% | 34 ms | 109 ms | 4 | + +After the change, a combined cold-navigation run recorded 769 frames, 114 +janky frames (14.82%), p50 10 ms, p90 24 ms, p95 46 ms, and p99 300 ms. The +captured run contained only five combined HTTP-completion/GC log matches. + +Supply-chain policy checks, shared and TV-focused unit tests, Android shared +tests, and the minified TV release assembly passed. A final independent review +approved the branch after two correction rounds. + +The device session ended before a complete Down/card/Up `For You` focus smoke +could be recorded. The focus bridge, repeat-key boundary, and saved-list +selection state are covered by behavioral unit tests, so external testing +should explicitly include those remote-control paths. + +## Risk and rollback + +Risk is concentrated in prefetch timing: slower networks may display metadata +slightly later because uncached enrichment no longer competes with active +focus navigation. Content remains available through the normal detail path. +The commits are separated by concern, so the focus bridge, repeat-key boundary, +saved-list routing, or either prefetch policy can be reverted independently if +external testing finds a regression. + +The test APKs are debug-signed release builds and cannot replace an installed +production-signed build without a matching signer. They are intended for a +compatible tester installation only. diff --git a/docs/superpowers/notes/architecture-debate.md b/docs/superpowers/notes/architecture-debate.md index ba64263a9..cefc877cd 100644 --- a/docs/superpowers/notes/architecture-debate.md +++ b/docs/superpowers/notes/architecture-debate.md @@ -707,7 +707,7 @@ Claude proposed a narrower re-scope after the Explore map showed the plan's draf **Projection key (Codex won):** add a **separate `content_item_state` table** PK `(serverId, profileId, contentId)` for watched/rating/favorite — these mutations carry no fileId and can fire before any file row exists. `UserItemStateEntity` stays file-level (position/track/CFI); drop watched/ratingValue/favorite from it. Matches the outbox's `targetFileId: Int?` (null for content ops). **Coalesce key now serverId-scoped**: `serverId|profileId|contentId|kind`. -**Schema:** amend v1 in place (no migration) — the DB is committed but unreleased and not yet wired into DI, so no device has `silo.db`. +**Schema:** amend v1 in place (no migration) — the DB is committed but unreleased and not yet wired into DI, so no device has `prairie.db`. **DI:** commonMain `single { PersonalDataRepository(get(), getOrNull() ?: NoOpUserItemStatePort) }`; `androidModule`+`androidTvModule` bind `PrairieDatabase` and `single { RoomUserItemStateRepository(...) }` after `sharedModules()` (mirrors TokenManager override at `AndroidModule.kt:94`). diff --git a/docs/superpowers/plans/2026-05-23-c-device-login.md b/docs/superpowers/plans/2026-05-23-c-device-login.md index 939553024..0d50a1a8c 100644 --- a/docs/superpowers/plans/2026-05-23-c-device-login.md +++ b/docs/superpowers/plans/2026-05-23-c-device-login.md @@ -441,8 +441,8 @@ class DeviceLoginRepositoryTest { deviceCode = deviceCode, userCode = "ABCD-1234", matchCode = "M1", - verificationUri = "https://silo.example/device", - verificationUriComplete = "https://silo.example/device?token=t1", + verificationUri = "https://prairie.example/device", + verificationUriComplete = "https://prairie.example/device?token=t1", expiresAt = "2099-01-01T00:00:00Z", expiresIn = 600, interval = 1, // small for tests diff --git a/docs/superpowers/plans/2026-06-09-android-media-surfaces.md b/docs/superpowers/plans/2026-06-09-android-media-surfaces.md index 0ab9f2276..b4083b959 100644 --- a/docs/superpowers/plans/2026-06-09-android-media-surfaces.md +++ b/docs/superpowers/plans/2026-06-09-android-media-surfaces.md @@ -1561,9 +1561,9 @@ git status --short --branch Expected: branch `feature/android-parity-and-media-surfaces` with a clean worktree after all task commits. -- [ ] **Step 5: Manual smoke test against `root@silo-new` server** +- [ ] **Step 5: Manual smoke test against `root@prairie-new` server** -Use a debug build pointed at the Prairie server that matches `root@silo-new:/opt/prairie-server`. +Use a debug build pointed at the Prairie server that matches `root@prairie-new:/opt/prairie-server`. Verify: diff --git a/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md b/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md index 9ab038098..b9061bc50 100644 --- a/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md +++ b/docs/superpowers/plans/2026-06-09-mobile-device-login-parity.md @@ -21,8 +21,8 @@ Assert these inputs resolve to `Route.PairDevice(...).route`: - `prairie://device?token=t1` - `continuum://device?code=ABCD-1234` -- `https://silo.example/device?token=t1` -- `https://silo.example/auth/device?code=ABCD` +- `https://prairie.example/device?token=t1` +- `https://prairie.example/auth/device?code=ABCD` Assert unrelated URLs return null. diff --git a/docs/superpowers/plans/2026-06-12-admin-core.md b/docs/superpowers/plans/2026-06-12-admin-core.md index 0f66621e4..23ebe4c0f 100644 --- a/docs/superpowers/plans/2026-06-12-admin-core.md +++ b/docs/superpowers/plans/2026-06-12-admin-core.md @@ -21,9 +21,9 @@ The convention is `./gradlew :shared:testDebugUnitTest --tests "..."` for common ### Task S1: Gating — `Profile.isPrimary` + shared `isActingAdmin` **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/profile/ProfileModels.kt` -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/auth/AdminPermissions.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/auth/AdminPermissionsTest.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/profile/ProfileModels.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/auth/AdminPermissions.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/auth/AdminPermissionsTest.kt` Evidence (server gate, design §"Admin gating"): acting admin = `user.role == "admin"` AND active `profile.is_primary == true`. `Profile` currently has **no** `is_primary` field (verified in ProfileModels.kt) so it must be added. The UI computes this from `AuthRepository`/`AuthApi.getMe()` (returns `User` with `role`) and `ProfileRepository.getActiveProfile(): Profile?`. Placing `isActingAdmin` in `model/auth` keeps it package-adjacent to `User` while accepting a `Profile` param. @@ -91,7 +91,7 @@ class AdminPermissionsTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" ``` Expected: compilation failure — `Profile` has no `isPrimary` member and `isActingAdmin` is unresolved (`unresolved reference: isPrimary`, `unresolved reference: isActingAdmin`). @@ -146,7 +146,7 @@ fun isActingAdmin(user: User?, profile: Profile?): Boolean = - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" --tests "com.continuum.app.model.profile.*" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.auth.AdminPermissionsTest" --tests "com.continuum.app.model.profile.*" ``` - [ ] **Step 5: Commit** @@ -165,8 +165,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: `model/admin/AdminModels.kt` — admin DTOs + serialization tests **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/admin/AdminModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/admin/AdminModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/admin/AdminModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/admin/AdminModelsSerializationTest.kt` Evidence (Go structs, exact `json:"..."` tags): - `AdminStats` + `WatchProviderActivity` from `admin_stats.go` (quoted above): all snake_case; `total_storage_bytes int64`; `WatchProviderActivity` has `trakt_connected_profiles`, `trakt_enabled_profiles`, `trakt_export_enabled`, `trakt_scrobble_enabled`, `last_sync_completed_at *time.Time (omitempty)`, `sync_runs_24h`, `sync_errors_24h`, `imported_watched_24h`, `imported_progress_24h`, `exported_watched_24h`, `pending_exports`, `failed_exports`, `open_scrobbles`, `scrobbles_24h`. @@ -491,7 +491,7 @@ class AdminModelsSerializationTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" ``` Expected: compilation failure — `unresolved reference: AdminStats / AdminUser / CreateUserRequest / AdminSession / AdminLogPage / AdminAuditPage / ScanRequest / ScanResponse / ScanCancelResponse` (the model file does not yet exist). @@ -789,7 +789,7 @@ data class ScanCancelResponse( - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.admin.AdminModelsSerializationTest" ``` - [ ] **Step 5: Commit** @@ -807,9 +807,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: `network/api/AdminApi.kt` (interface + Default) + NetworkModule + MockEngine tests **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/AdminApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/AdminApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/AdminApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/AdminApiTest.kt` Evidence (routes, all under `/api/v1`, design §"Server contracts"; verified handlers): - `GET /admin/stats` with optional `?refresh=true` (admin_stats.go provider; refresh forces a cache bypass server-side). @@ -1124,7 +1124,7 @@ class AdminApiTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" ``` Expected: compilation failure — `unresolved reference: AdminApi / DefaultAdminApi` (the api file does not yet exist). @@ -1377,7 +1377,7 @@ Register in NetworkModule (add the line after the NotificationsApi binding): - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.network.api.AdminApiTest" ``` - [ ] **Step 5: Commit** @@ -1396,9 +1396,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: `repository/AdminRepository.kt` — pass-throughs + RepositoryModule + repository test **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/AdminRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/AdminRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/AdminRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/AdminRepositoryTest.kt` Evidence: existing repos are thin `ApiResult` pass-throughs around an interface-backed API (e.g. `SubtitlesRepository(get())` in RepositoryModule; `SubtitlesApi`/`NotificationsApi` are interfaces faked in tests). `AdminApi` is an interface (Task 3), so the repository is constructed `AdminRepository(get())` and the test fakes `AdminApi`. The scan endpoints live under `/libraries` but are reached via `AdminApi` (documented there); the repository exposes them as `triggerScan`/`cancelScan` so the admin "Scans" sub-screen has a single dependency — KDoc records the `/libraries` placement. @@ -1601,7 +1601,7 @@ class AdminRepositoryTest { - [ ] **Step 2: Run test to verify it fails** (command + expected failure) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" ``` Expected: compilation failure — `unresolved reference: AdminRepository` (the repository does not yet exist). @@ -1728,7 +1728,7 @@ import com.continuum.app.repository.AdminRepository - [ ] **Step 4: Run tests** (command) ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.repository.AdminRepositoryTest" ``` - [ ] **Step 5: Commit** diff --git a/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md b/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md index 3b61a46db..1018a9a2c 100644 --- a/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md +++ b/docs/superpowers/plans/2026-06-12-android-client-review-fixes.md @@ -2234,11 +2234,11 @@ Co-Authored-By: Claude Fable 5 " ### Task D1: Fix WatchNextSyncWorker instantiation (TvWorkerFactory + manifest opt-out + explicit WorkManager init) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt` (doc comment only) +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt` (doc comment only) - Test: none (manual verification — see Step 1) - [ ] **Step 1: Write the failing test** @@ -2273,7 +2273,7 @@ Run the *before* manual check above on the current branch build (`./gradlew :and - [ ] **Step 3: Implementation** -**3a. Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt`:** +**3a. Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt`:** ```kotlin package com.continuum.app.tv.watchnext @@ -2333,7 +2333,7 @@ class TvWorkerFactory : WorkerFactory() { } ``` -**3b. Replace the entire contents of `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt`:** +**3b. Replace the entire contents of `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt`:** ```kotlin package com.continuum.app.tv @@ -2388,7 +2388,7 @@ class ContinuumTvApplication : Application(), Configuration.Provider { } ``` -**3c. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml`** — add the `tools` namespace to the root element: +**3c. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/AndroidManifest.xml`** — add the `tools` namespace to the root element: ```xml ` closing tag of `ContinuumPlaybackSe ``` -**3d. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — delete these two imports: +**3d. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — delete these two imports: ```kotlin import com.continuum.app.tv.watchnext.WatchNextSyncWorker @@ -2445,7 +2445,7 @@ with: single { WatchNextSeeder(androidContext(), get()) } ``` -**3e. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt`** — replace the stale doc-comment lines: +**3e. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt`** — replace the stale doc-comment lines: ```kotlin * Constructed by Koin's [org.koin.androidx.workmanager.factory.KoinWorkerFactory] @@ -2472,7 +2472,7 @@ Then re-run the *after* manual check from Step 1 on a device/emulator. - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt androidTvApp/src/androidMain/AndroidManifest.xml androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/TvWorkerFactory.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ContinuumTvApplication.kt androidTvApp/src/androidMain/AndroidManifest.xml androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/watchnext/WatchNextSyncWorker.kt && git commit -m "$(cat <<'EOF' Fix WatchNextSyncWorker instantiation on TV Mirror the phone app's WorkManager recipe: hand-rolled TvWorkerFactory, @@ -2490,12 +2490,12 @@ EOF ### Task D2: Reinstate one-shot legacy tv_prefs migration (playback settings + library selection) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/build.gradle.kts` (add `kotlinx-coroutines-test` to androidUnitTest deps) -- Test: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/build.gradle.kts` (add `kotlinx-coroutines-test` to androidUnitTest deps) +- Test: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt` Design notes (decided after reading main's `TvPreferences.kt` / `TvSettingsViewModel.kt`): - Exact legacy keys in the `tv_prefs` DataStore: `playback_quality` (string wire value), `subtitle_size` (string label `Small`/`Medium`/`Large`), `auto_play_next` (bool, default true), `auto_skip_intro` (bool, default false), `auto_skip_credits` (bool, default false), `libraries_selected_library_id` (int). @@ -2504,7 +2504,7 @@ Design notes (decided after reading main's `TvPreferences.kt` / `TvSettingsViewM - [ ] **Step 1: Write the failing test** -First add the missing coroutines-test dependency. In `/Users/dev/projects/silo/prairie-android/androidTvApp/build.gradle.kts`, change: +First add the missing coroutines-test dependency. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/build.gradle.kts`, change: ```kotlin androidUnitTest.dependencies { @@ -2523,7 +2523,7 @@ to: } ``` -Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt`: +Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt`: ```kotlin package com.continuum.app.tv.data.preferences @@ -2918,13 +2918,13 @@ private class FakeTokenManager( - [ ] **Step 2: Run test to verify it fails** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" ``` Expected failure: compilation error in `compileDebugUnitTestKotlinAndroid` — `e: ... LegacyTvPrefsMigrationTest.kt: ... Unresolved reference 'LegacyTvPrefsMigration'` (the class does not exist yet). - [ ] **Step 3: Implementation** -**3a. Create `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt`:** +**3a. Create `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt`:** ```kotlin package com.continuum.app.tv.data.preferences @@ -3110,7 +3110,7 @@ class LegacyTvPrefsMigration( } ``` -**3b. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt`** — add the import: +**3b. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt`** — add the import: ```kotlin import com.continuum.app.tv.data.preferences.LegacyTvPrefsMigration @@ -3188,7 +3188,7 @@ to: (rest of the function unchanged). -**3c. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt`** — add the import: +**3c. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt`** — add the import: ```kotlin import com.continuum.app.tv.data.preferences.LegacyTvPrefsMigration @@ -3236,7 +3236,7 @@ to: val storedLibraryId = librarySelectionStore.getSelectedLibraryId() ``` -**3d. In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — add imports: +**3d. In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt`** — add imports: ```kotlin import com.continuum.app.network.ApiResult @@ -3316,14 +3316,14 @@ to: - [ ] **Step 4: Run tests** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest --tests "com.continuum.app.tv.data.preferences.LegacyTvPrefsMigrationTest" && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug ``` Expected: all 8 new tests pass, the rest of the TV suite stays green, and the app module compiles (verifies the DI/ViewModel wiring). - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/build.gradle.kts && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigration.kt androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/data/preferences/LegacyTvPrefsMigrationTest.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/settings/TvSettingsViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/libraries/TvLibrariesViewModel.kt androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt androidTvApp/build.gradle.kts && git commit -m "$(cat <<'EOF' Restore one-shot legacy tv_prefs migration on TV The branch dropped main's first-boot import of the legacy tv_prefs @@ -3342,8 +3342,8 @@ EOF ### Task D3: Constrain Palette extraction decode size in AmbientBackdropTint **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt` -- Test: none (existing `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTintStateTest.kt` continues to cover the state holder) +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt` +- Test: none (existing `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTintStateTest.kt` continues to cover the state holder) - [ ] **Step 1: Write the failing test** @@ -3355,13 +3355,13 @@ Manual check: build, install, open Home on a TV device, and D-pad across hero ca Not applicable (no new unit test). Baseline instead: ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest ``` Expected: current suite green before the change (so any post-change failure is attributable to this edit). - [ ] **Step 3: Implementation** -In `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt`, replace lines 93–98. +In `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt`, replace lines 93–98. Before: @@ -3397,14 +3397,14 @@ No import changes — `size(int)` is a member of `coil3.request.ImageRequest.Bui - [ ] **Step 4: Run tests** ```bash -cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug +cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug ``` Expected: suite green (including `AmbientBackdropTintStateTest`), module compiles. Then run the Step 1 manual check on a device. - [ ] **Step 5: Commit** ```bash -cd /Users/dev/projects/silo/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt && git commit -m "$(cat <<'EOF' +cd /Users/dev/projects/prairie/prairie-android && git add androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/components/AmbientBackdropTint.kt && git commit -m "$(cat <<'EOF' Cap ambient backdrop Palette decode at 128px The accent-extraction ImageRequest used allowHardware(false) with no @@ -4220,13 +4220,13 @@ EOF ### Task F1: Shared request presentation policy (mobile + TV) **Files:** -- Create: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/request/RequestPresentation.kt -- Test: /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/request/RequestPresentationTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestComponents.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestDetailScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/MyRequestsScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvRequestComponents.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvMyRequestsScreen.kt +- Create: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/request/RequestPresentation.kt +- Test: /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/request/RequestPresentationTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestComponents.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/RequestDetailScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/requests/MyRequestsScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvRequestComponents.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/requests/TvMyRequestsScreen.kt Background (verified): `requestImageUrl`/`requestPosterUrl` and the cancel predicate are byte-identical in `RequestComponents.kt` (237–248, 270–271) and `TvRequestComponents.kt` (294–306). Badge precedence is duplicated with drift: mobile `badgeText()` (262–268) returns prettified text directly; TV `cardChipText()` (308–314) returns raw lowercase tokens that `TvRequestStatusChip` later prettifies via `requestLabel()` (321–331). `targetSummary` drift: mobile truncates with `"…"` (275), TV with `"..."` (335). The unification below keeps TV's rendered output identical (its chip still receives raw tokens and prettifies them), and unifies truncation on `"…"`. @@ -4553,10 +4553,10 @@ Co-Authored-By: Claude Fable 5 " ### Task F2: ApiResult.errorMessage — collapse 14 duplicated error branches in RequestsViewModels **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ApiResult.kt -- Modify: /Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/viewmodel/RequestsViewModels.kt -- Test: /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/ApiResultErrorMessageTest.kt -- Test (extend): /Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/viewmodel/RequestsViewModelTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ApiResult.kt +- Modify: /Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/viewmodel/RequestsViewModels.kt +- Test: /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/ApiResultErrorMessageTest.kt +- Test (extend): /Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/viewmodel/RequestsViewModelTest.kt - [ ] **Step 1: Write the failing test** @@ -4758,13 +4758,13 @@ Co-Authored-By: Claude Fable 5 " ### Task F3: One formatBytes for androidApp (5 duplicates) **Files:** -- Create: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt -- Test: /Users/dev/projects/silo/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadsScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadItemRow.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/QualitySelector.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/detail/MediaInfoSheet.kt +- Create: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt +- Test: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadsScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadItemRow.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/QualitySelector.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/detail/MediaInfoSheet.kt Verified inventory (corrects one earlier note): the log10/1024 `"%.1f %s"` variant is byte-identical in `DownloadEntryRows.kt:467`, `DownloadsScreen.kt:387`, `DownloadItemRow.kt:105`. **Both** `QualitySelector.kt:141` and `MediaInfoSheet.kt:182` are when-ladder variants (no TB; `"%.1f GB"/"%.0f MB"/"%.0f KB"` and `"%.2f GB"/"%.1f MB"/"%.0f KB"` respectively) — their output changes slightly to the unified `"%.1f "` format (e.g. "250 MB" → "250.0 MB", "1.25 GB" → "1.2 GB"); acceptable. @@ -4887,12 +4887,12 @@ Co-Authored-By: Claude Fable 5 " ### Task F4: One formatClockTime for androidApp (4 drifted clones) **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt -- Test: /Users/dev/projects/silo/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/PlayerProgressBar.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/ChaptersSheet.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookPlayerScreen.kt -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookBookmarksSheet.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/util/Formatters.kt +- Test: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidUnitTest/kotlin/com/continuum/app/android/ui/util/FormattersTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/PlayerProgressBar.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/player/ChaptersSheet.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookPlayerScreen.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/audiobook/AudiobookBookmarksSheet.kt Drift being unified: `PlayerProgressBar.formatTime` rounds (`roundToInt`), the other three truncate; only `ChaptersSheet.formatChapterTime` guards NaN. Unified semantics: truncation (`toLong`) + NaN/negative guard. The only visible change is the video player's clock, which may read 1 s lower at half-second boundaries; chapter/audiobook output is bit-identical. @@ -5001,7 +5001,7 @@ Co-Authored-By: Claude Fable 5 " ### Task F5: Collapse SeriesRow/SeasonRow into one ExpandableAggregateRow **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/downloads/DownloadEntryRows.kt - Test: none new The sealed `DownloadEntry` base (DownloadsViewModel.kt:65–76) exposes `id/title/subtitle/posterUrl/posterThumbhash/totalBytesUsed/progress/isComplete`, so one composable can serve both; only child selection differs. @@ -5118,13 +5118,13 @@ Co-Authored-By: Claude Fable 5 " ### Task F6: ScopedJsonFileStore — shared skeleton for the three scoped JSON stores **Files:** -- Create: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/store/ScopedJsonFileStore.kt -- Test: /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/store/ScopedJsonFileStoreTest.kt -- Test: /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStoreTest.kt -- Test (extend): /Users/dev/projects/silo/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/ebook/EbookLocalStateStore.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookPositionStore.kt -- Modify: /Users/dev/projects/silo/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStore.kt +- Create: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/store/ScopedJsonFileStore.kt +- Test: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/store/ScopedJsonFileStoreTest.kt +- Test: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStoreTest.kt +- Test (extend): /Users/dev/projects/prairie/prairie-android/android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/ebook/EbookLocalStateStore.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookPositionStore.kt +- Modify: /Users/dev/projects/prairie/prairie-android/android-shared/src/androidMain/kotlin/com/continuum/app/common/audiobook/AudiobookBookmarksStore.kt Notes from reading the code: all three stores re-declare `Json { ignoreUnknownKeys = true }`, the `$serverId/$profileId/$contentId` path builder, runCatching-read-with-`Log.w`, and the tmp+rename write (no fsync). Dedupe drift: ebook `addBookmark` uses `.distinctBy { it.id }`; audiobook `add` appends plain — and additionally returns the *unsorted* appended list while persisting the sorted one. Both get fixed: dedupe-by-id everywhere, and `add` returns exactly what it persists. `android.util.Log` is safe at unit-test scope (`isReturnDefaultValues = true` in android-shared's testOptions). @@ -5575,7 +5575,7 @@ Co-Authored-By: Claude Fable 5 " ### Task F7: ReadingHubUiState.clearingLibraryContent() — centralize the 14-field reset **Files:** -- Modify: /Users/dev/projects/silo/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reading/ReadingHubViewModel.kt +- Modify: /Users/dev/projects/prairie/prairie-android/androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reading/ReadingHubViewModel.kt - Test: none new - [ ] **Step 1: Write the failing test** — not unit-testable at reasonable cost: no `ReadingHubViewModel` test exists (androidApp unit tests cover only downloads/navigation/reader), and one would require fakes for `PersonalDataRepository`, `SectionRepository`, and `CatalogRepository`. This is a behavior-preserving consolidation of `copy(...)` lists; covered by the existing suite plus compilation: `./gradlew :androidApp:testDebugUnitTest :androidApp:compileDebugKotlinAndroid`. diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md b/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md index 4ee18bb68..82b276509 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase1-reader-locator.md @@ -111,7 +111,7 @@ Steps: - [ ] Run the test and confirm it FAILS to compile (the `ReaderLocator` symbol does not yet exist): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" ``` Expected: build failure / unresolved reference `ReaderLocator`. @@ -204,7 +204,7 @@ Steps: - [ ] Run the test and confirm it PASSES: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.reader.ReaderLocatorTest" ``` Expected: `BUILD SUCCESSFUL`, all 9 test methods green. @@ -212,7 +212,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/reader/ReaderLocator.kt shared/src/commonTest/kotlin/com/continuum/app/model/reader/ReaderLocatorTest.kt && git commit -m "feat(reader): add ReaderLocator sealed type with typed JSON + legacy page:N parse" + cd /Users/dev/projects/prairie/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/reader/ReaderLocator.kt shared/src/commonTest/kotlin/com/continuum/app/model/reader/ReaderLocatorTest.kt && git commit -m "feat(reader): add ReaderLocator sealed type with typed JSON + legacy page:N parse" ``` --- @@ -256,7 +256,7 @@ Steps: - [ ] Run the test and confirm it FAILS (unresolved `progressLocationForPage`, and `ebookPageNumberFromProgressLocation` does not yet understand typed JSON): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" ``` Expected: compile failure on `progressLocationForPage` (and, once that is added, a failing assertion on the typed-JSON case until the body is updated). @@ -281,7 +281,7 @@ Steps: - [ ] Run the test and confirm it PASSES: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest --tests "com.continuum.app.model.ebook.EbookVersionSelectionTest" ``` Expected: `BUILD SUCCESSFUL`, including the three new methods and all pre-existing `EbookVersionSelectionTest` cases. @@ -289,7 +289,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/ebook/EbookVersionSelection.kt shared/src/commonTest/kotlin/com/continuum/app/model/ebook/EbookVersionSelectionTest.kt && git commit -m "feat(reader): resolve progress location via ReaderLocator (typed JSON + legacy page:N)" + cd /Users/dev/projects/prairie/prairie-android && git add shared/src/commonMain/kotlin/com/continuum/app/model/ebook/EbookVersionSelection.kt shared/src/commonTest/kotlin/com/continuum/app/model/ebook/EbookVersionSelectionTest.kt && git commit -m "feat(reader): resolve progress location via ReaderLocator (typed JSON + legacy page:N)" ``` --- @@ -346,7 +346,7 @@ Steps: - [ ] Run the test: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :android-shared:testDebugUnitTest --tests "com.continuum.app.common.ebook.EbookLocalStateStoreTest" + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :android-shared:testDebugUnitTest --tests "com.continuum.app.common.ebook.EbookLocalStateStoreTest" ``` Expected: `BUILD SUCCESSFUL`. (`android-shared` already depends on `:shared` via `implementation(project(":shared"))`, so the `ReaderLocator` import resolves. If it does not compile, that signals the store module is missing the shared dependency — it is not — so the expected outcome is green with no production edit.) @@ -356,7 +356,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt && git commit -m "test(reader): assert EbookLocalStateStore reads legacy and typed locator progress rows" + cd /Users/dev/projects/prairie/prairie-android && git add android-shared/src/androidUnitTest/kotlin/com/continuum/app/common/ebook/EbookLocalStateStoreTest.kt && git commit -m "test(reader): assert EbookLocalStateStore reads legacy and typed locator progress rows" ``` --- @@ -408,7 +408,7 @@ Steps: - [ ] Compile `androidApp` to confirm the refactor type-checks: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :androidApp:compileDebugKotlin + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :androidApp:compileDebugKotlin ``` Expected: `BUILD SUCCESSFUL`. @@ -416,7 +416,7 @@ Steps: - [ ] Run the full affected module suites to confirm nothing regressed: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest ``` Expected: `BUILD SUCCESSFUL` across all three. @@ -424,7 +424,7 @@ Steps: - [ ] Commit: ``` - cd /Users/dev/projects/silo/prairie-android && git add androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader/ReaderViewModel.kt && git commit -m "refactor(reader): persist progress + bookmarks as typed ReaderLocator JSON" + cd /Users/dev/projects/prairie/prairie-android && git add androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader/ReaderViewModel.kt && git commit -m "refactor(reader): persist progress + bookmarks as typed ReaderLocator JSON" ``` --- @@ -443,7 +443,7 @@ Steps: - [ ] Verify no inline `"page:` string literals remain in the reader path except the legacy-parse branch inside `ReaderLocator.parse`: ``` - cd /Users/dev/projects/silo/prairie-android && /usr/bin/grep -rn "page:" androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader shared/src/commonMain/kotlin/com/continuum/app/model + cd /Users/dev/projects/prairie/prairie-android && /usr/bin/grep -rn "page:" androidApp/src/androidMain/kotlin/com/continuum/app/android/ui/screens/reader shared/src/commonMain/kotlin/com/continuum/app/model ``` Expected: the only `"page:"` producers are `EpubReader.kt`'s `ReaderSection(location = "page:$index")` (section TOC anchors, untouched in Phase 1 — acceptable, parsed by the locator) and the legacy branch in `ReaderLocator.kt` / `EbookVersionSelection` comments. Note any others and fix if they bypass the helper. @@ -451,7 +451,7 @@ Steps: - [ ] Run lint on changed Kotlin (the repo's standard pre-MR gate): ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:lintKotlinCommonMain :androidApp:lintDebug + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:lintKotlinCommonMain :androidApp:lintDebug ``` If these Gradle lint tasks are not configured in this repo, fall back to the project's documented lint command and run that instead; do not invent a task name. Fix any reported issues inline. @@ -459,7 +459,7 @@ Steps: - [ ] Final full run to confirm everything is green together: ``` - cd /Users/dev/projects/silo/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest + cd /Users/dev/projects/prairie/prairie-android && ./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest :androidApp:testDebugUnitTest ``` Expected: `BUILD SUCCESSFUL`. @@ -467,7 +467,7 @@ Steps: - [ ] If self-review surfaced any fix, commit it: ``` - cd /Users/dev/projects/silo/prairie-android && git add -A && git commit -m "chore(reader): phase 1 self-review fixes for ReaderLocator" + cd /Users/dev/projects/prairie/prairie-android && git add -A && git commit -m "chore(reader): phase 1 self-review fixes for ReaderLocator" ``` --- diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md b/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md index 17ee7dffc..7cf83ccb1 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase2-paginated-epub.md @@ -289,7 +289,7 @@ The web side. Owns rendering, theming, tap/swipe zones, and posting typed events applyTheme: function (themeJson) { if (!rendition) return; var t = JSON.parse(themeJson); // {bg,fg,fontPercent,marginEm,fontFamily} - rendition.themes.register("silo", { + rendition.themes.register("prairie", { "body": { "background": t.bg, "color": t.fg, "margin": t.marginEm + "em !important", @@ -298,7 +298,7 @@ The web side. Owns rendering, theming, tap/swipe zones, and posting typed events }, "img": { "max-width": "100% !important", "height": "auto !important" } }); - rendition.themes.select("silo"); + rendition.themes.select("prairie"); rendition.themes.fontSize(t.fontPercent + "%"); } }; diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md b/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md index 4f60fe3f4..ac93bafc9 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase4-highlights-notes.md @@ -98,7 +98,7 @@ This plan implements **Phase 4 only** of `docs/superpowers/specs/2026-06-12-eboo > **CHECK constraint name note:** The original inline `CHECK (...)` in `20260608000300_ebook_reader_state.sql` is unnamed, so Postgres auto-names it `ebook_reader_annotations_check`. The `DROP CONSTRAINT IF EXISTS ebook_reader_annotations_check` above targets that auto-name. Verify the live name before relying on the Down path: > ``` > make migrate-up - > docker compose exec -T postgres psql -U silo -d silo -c "\d+ ebook_reader_annotations" | grep -i check + > docker compose exec -T postgres psql -U prairie -d prairie -c "\d+ ebook_reader_annotations" | grep -i check > ``` > If the auto-name differs in the target DB, adjust the `DROP CONSTRAINT IF EXISTS` argument in the Up block before applying. The `IF EXISTS` guard keeps the migration safe either way. - [ ] Validate annotations parse without a DB: @@ -113,7 +113,7 @@ This plan implements **Phase 4 only** of `docs/superpowers/specs/2026-06-12-eboo ``` - [ ] Manually verify down/up round-trip in dev (do NOT do this against prod): ``` - go run ./cmd/silo/ --env .env --migrate-down-one # if supported; else use goose directly per Makefile GOOSE var + go run ./cmd/prairie/ --env .env --migrate-down-one # if supported; else use goose directly per Makefile GOOSE var make migrate-up ``` Confirm `\d+ ebook_reader_annotations` shows `locator_range jsonb` and the `_anchor_check` constraint after re-up. diff --git a/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md b/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md index 039b9b045..129abf56c 100644 --- a/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md +++ b/docs/superpowers/plans/2026-06-12-ebook-phase5-search.md @@ -374,7 +374,7 @@ window.siloHighlightCfi = function (cfi) { window.siloLastHl = cfi; window.siloRendition.display(cfi).then(function () { window.siloRendition.annotations.highlight(cfi, {}, function () {}, - "silo-search-hl", { "fill": "yellow", "fill-opacity": "0.4" }); + "prairie-search-hl", { "fill": "yellow", "fill-opacity": "0.4" }); }); }; diff --git a/docs/superpowers/plans/2026-06-12-notifications.md b/docs/superpowers/plans/2026-06-12-notifications.md index a4338caec..682d726b3 100644 --- a/docs/superpowers/plans/2026-06-12-notifications.md +++ b/docs/superpowers/plans/2026-06-12-notifications.md @@ -21,9 +21,9 @@ I have all the conventions and wire shapes pinned. Note one wire detail: `Payloa ### Task S1: Add ktor-client-websockets dependency + install WebSockets plugin **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/gradle/libs.versions.toml` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/build.gradle.kts` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ContinuumHttpClientImpl.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/gradle/libs.versions.toml` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/build.gradle.kts` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/ContinuumHttpClientImpl.kt` This is an enabling task. It has no dedicated unit test; verification is a successful shared compile, which proves the new plugin install does not break the existing client config (ContentNegotiation, Auth, Logging, Timeout, defaultRequest). @@ -86,8 +86,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: NotificationModels.kt (REST + preference + capability + realtime frame models) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/notifications/NotificationModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/notifications/NotificationModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/notifications/NotificationModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/notifications/NotificationModelsSerializationTest.kt` Evidence — pinned Go wire shapes (prairie-server `origin/feat/notifications-v1`): @@ -727,9 +727,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: NotificationsApi.kt (REST + ws-ticket) + NetworkModule registration **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/NotificationsApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/NotificationsApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/NotificationsApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/NotificationsApiTest.kt` Evidence — routes (router.go, all under `/api/v1`, `RequireProfile`): `GET /notifications` (`status=unread`, `limit`, `before`), `GET /notifications/sync` (`since`, `limit`), `GET /notifications/{id}`, `GET /notifications/unread-count`, `GET /notifications/capability`, `GET /notifications/preferences`, `PUT /notifications/preferences`, `POST /notifications/{id}/read` (204), `POST /notifications/read-all` (204), `POST /events/ws-ticket`. @@ -1108,8 +1108,8 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: NotificationsRealtimeClient.kt (ticket handshake + frame decode → Flow) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/NotificationsRealtimeClient.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/NotificationRealtimeDecoderTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/NotificationsRealtimeClient.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/NotificationRealtimeDecoderTest.kt` Design: socket I/O is intentionally thin and untested; the load-bearing logic is the pure `decodeRealtimeFrame(json, raw): NotificationRealtimeEvent?` function, which gets full coverage (snapshot / created / read-one / read-all / unknown→null / non-notifications channel→null / malformed→null). Frame `data` shapes come straight from events_ws.go: snapshot `data` is a `[]DeliveryRowPayload`, `notification.created` `data` is one `DeliveryRowPayload`, `notification.read` `data` is `{profile_id,id}` or `{profile_id,all:true}`. @@ -1415,9 +1415,9 @@ Co-Authored-By: Claude Fable 5 " ### Task S5: NotificationsRepository.kt (singleton StateFlows + pure applyEvent fold) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/NotificationsRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/NotificationsRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/NotificationsRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/NotificationsRepositoryTest.kt` Design: REST is the source of truth; `refresh()`/`loadMore()` drive the lists; `connectRealtime(scope)` collects the realtime client flow with capped-backoff reconnect and folds events into the same StateFlows. The fold is a pure top-level `applyEvent(state, event): NotificationsState` plus a pure `recomputeUnread(rows): Int`, both fully unit-tested. The socket reconnect loop and lifecycle wiring are thin and exercised only via a fake flow under `runTest`. diff --git a/docs/superpowers/plans/2026-06-12-subtitle-suite.md b/docs/superpowers/plans/2026-06-12-subtitle-suite.md index a37d79272..a712f3c93 100644 --- a/docs/superpowers/plans/2026-06-12-subtitle-suite.md +++ b/docs/superpowers/plans/2026-06-12-subtitle-suite.md @@ -1537,7 +1537,7 @@ class SubtitleTrackMergeTest { releaseName = "Dune Part Three", provider = "subdl"), ), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(4, merged.size) @@ -1552,12 +1552,12 @@ class SubtitleTrackMergeTest { assertNull(first.forced) // Web: buildPlayerStreamUrl(apiBaseUrl, `/stream/${sid}/subtitles/${index}`, …) // with apiBaseUrl "/api/v1", absolutized against the server origin on Android. - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/2", first.url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/2", first.url) val second = merged[3] assertEquals(3, second.index) assertEquals("Dune Part Three (subdl)", second.label) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/3", second.url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/3", second.url) } @Test @@ -1568,7 +1568,7 @@ class SubtitleTrackMergeTest { existing = existing, downloaded = emptyList(), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertSame(existing, merged) @@ -1580,7 +1580,7 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(2, firstMerge.size) @@ -1588,7 +1588,7 @@ class SubtitleTrackMergeTest { existing = firstMerge, downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(3, secondMerge.size) // 1 embedded + 2 downloaded, no duplicate of 312 @@ -1605,11 +1605,11 @@ class SubtitleTrackMergeTest { existing = existing, downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(4, merged.last().index) // max(0,3)+1, not size (2) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/4", merged.last().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/4", merged.last().url) } @Test @@ -1618,11 +1618,11 @@ class SubtitleTrackMergeTest { existing = emptyList(), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(0, merged.single().index) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) } @Test @@ -1631,10 +1631,10 @@ class SubtitleTrackMergeTest { existing = emptyList(), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example/", + serverUrl = "https://prairie.example/", ) - assertEquals("https://silo.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) + assertEquals("https://prairie.example/api/v1/stream/sess-1/subtitles/0", merged.single().url) } } ``` @@ -3664,9 +3664,9 @@ TV-side facts these tasks build on (verified against current code): `TvPlayerVie ### Task T1: TvPlayerViewModel — aiStatus probe, refreshSubtitles merge + Media3 rebuild, search/download/translate orchestration **Files:** -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerViewModel.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerViewModel.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/di/AndroidTvModule.kt` - [ ] **Step 1: Write the failing test** @@ -4173,9 +4173,9 @@ Co-Authored-By: Claude Fable 5 " ### Task T2: TvSubtitleSearchDialog + HUD "Search subtitles" action row **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvSubtitleSearchDialog.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerHud.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvSubtitleSearchDialog.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerHud.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` - [ ] **Step 1: Write the failing test** @@ -4915,8 +4915,8 @@ Co-Authored-By: Claude Fable 5 " ### Task T3: TvAiTranslateDialog + HUD "Translate with AI" row (AI-status gated) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvAiTranslateDialog.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvAiTranslateDialog.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/androidTvApp/src/androidMain/kotlin/com/continuum/app/tv/ui/screens/player/TvPlayerScreen.kt` - [ ] **Step 1: Write the failing test** diff --git a/docs/superpowers/plans/2026-06-12-watch-together.md b/docs/superpowers/plans/2026-06-12-watch-together.md index 1a5cb6a38..dff145189 100644 --- a/docs/superpowers/plans/2026-06-12-watch-together.md +++ b/docs/superpowers/plans/2026-06-12-watch-together.md @@ -19,12 +19,12 @@ I have all contracts confirmed. The server uses 201 on create suggestion (not ju ### Task S1: Watch Together shared models + serialization **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModelsSerializationTest.kt` ```kotlin package com.continuum.app.model.watchtogether @@ -408,7 +408,7 @@ Expected: compilation failure — `WatchTogetherModels.kt` and `WatchTogetherRea - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/model/watchtogether/WatchTogetherModels.kt` ```kotlin package com.continuum.app.model.watchtogether @@ -734,7 +734,7 @@ data class WsError( ) ``` -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeEvent.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeEvent.kt` ```kotlin package com.continuum.app.network @@ -796,13 +796,13 @@ Co-Authored-By: Claude Fable 5 " ### Task S2: WatchTogetherApi (REST) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/api/WatchTogetherApiTest.kt` ```kotlin package com.continuum.app.network.api @@ -1030,7 +1030,7 @@ Expected: compilation failure — `WatchTogetherApi` / `DefaultWatchTogetherApi` - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/api/WatchTogetherApi.kt` ```kotlin package com.continuum.app.network.api @@ -1251,7 +1251,7 @@ class DefaultWatchTogetherApi(private val client: HttpClient) : WatchTogetherApi } ``` -Modify `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` — add inside the `module { … }` block after the AdminApi line: +Modify `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/NetworkModule.kt` — add inside the `module { … }` block after the AdminApi line: ```kotlin single { DefaultAdminApi(get()) } single { DefaultWatchTogetherApi(get()) } @@ -1278,12 +1278,12 @@ Co-Authored-By: Claude Fable 5 " ### Task S3: WatchTogetherRealtimeClient + decodeRoomFrame **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/network/RoomFrameDecoderTest.kt` ```kotlin package com.continuum.app.network @@ -1404,7 +1404,7 @@ Expected: compilation failure — `decodeRoomFrame` and `WatchTogetherRealtimeCl - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/network/WatchTogetherRealtimeClient.kt` ```kotlin package com.continuum.app.network @@ -1650,12 +1650,12 @@ Co-Authored-By: Claude Fable 5 " ### Task S4: RoomSyncEngine (pure timing brain) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/RoomSyncEngineTest.kt` ```kotlin package com.continuum.app @@ -1919,7 +1919,7 @@ Expected: compilation failure — `RoomSyncEngine` does not exist (unresolved re - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/RoomSyncEngine.kt` ```kotlin package com.continuum.app @@ -2074,13 +2074,13 @@ Co-Authored-By: Claude Fable 5 " ### Task S5: WatchTogetherRepository (singleton) **Files:** -- Create: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` -- Modify: `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` -- Test: `/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` +- Create: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` +- Modify: `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` +- Test: `/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` - [ ] **Step 1: Write the failing test** (full code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonTest/kotlin/com/continuum/app/repository/WatchTogetherRepositoryTest.kt` ```kotlin package com.continuum.app.repository @@ -2319,7 +2319,7 @@ Expected: compilation failure — `WatchTogetherRepository` does not exist (unre - [ ] **Step 3: Implementation** (complete code) -`/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` +`/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/repository/WatchTogetherRepository.kt` ```kotlin package com.continuum.app.repository @@ -2591,7 +2591,7 @@ class WatchTogetherRepository( } ``` -Modify `/Users/dev/projects/silo/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` — add the import and the singleton. Add to the imports block: +Modify `/Users/dev/projects/prairie/prairie-android/shared/src/commonMain/kotlin/com/continuum/app/di/RepositoryModule.kt` — add the import and the singleton. Add to the imports block: ```kotlin import com.continuum.app.repository.WatchTogetherRepository ``` diff --git a/docs/superpowers/plans/2026-06-13-shared-video-player-core.md b/docs/superpowers/plans/2026-06-13-shared-video-player-core.md index 1d287f0a0..258fab272 100644 --- a/docs/superpowers/plans/2026-06-13-shared-video-player-core.md +++ b/docs/superpowers/plans/2026-06-13-shared-video-player-core.md @@ -82,7 +82,7 @@ androidTvApp/src/androidUnitTest/kotlin/com/continuum/app/tv/ui/screens/player/ - [ ] Run repository status and note unrelated local changes without reverting them. ```bash -cd /Users/jimcole/projects/silo/prairie-android +cd /Users/jimcole/projects/prairie/prairie-android git status --short git branch --show-current ``` @@ -1669,7 +1669,7 @@ Manual flow: SHIELD_ID=$(adb devices -l | awk 'tolower($0) ~ /device / && tolower($0) ~ /(shield|nvidia|android_tv)/ { print $1; exit }') test -n "$SHIELD_ID" adb -s "$SHIELD_ID" logcat -c -adb -s "$SHIELD_ID" logcat | rg -i "continuum|silo|player|subtitle|media3|exoplayer|resume|error|exception" +adb -s "$SHIELD_ID" logcat | rg -i "continuum|prairie|player|subtitle|media3|exoplayer|resume|error|exception" ``` Expected observation: @@ -1686,7 +1686,7 @@ Subtitle selection produces Media3 or app logs showing selected text track. PIXEL_ID=$(adb devices -l | awk 'tolower($0) ~ /device / && tolower($0) ~ /pixel/ { print $1; exit }') test -n "$PIXEL_ID" adb -s "$PIXEL_ID" logcat -c -adb -s "$PIXEL_ID" logcat | rg -i "continuum|silo|player|subtitle|media3|exoplayer|resume|error|exception" +adb -s "$PIXEL_ID" logcat | rg -i "continuum|prairie|player|subtitle|media3|exoplayer|resume|error|exception" ``` Expected observation: diff --git a/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md b/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md index 44f0cd58e..10bfdb2a7 100644 --- a/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md +++ b/docs/superpowers/plans/2026-06-17-android-tv-detail-parity.md @@ -11,7 +11,7 @@ **Per-task loop (every task):** implement → `./gradlew :androidTvApp:compileDebugKotlinAndroid` clean → run any unit tests → **Codex review of the diff** → commit (author `rxwatcher`, `Co-Authored-By: Claude Opus 4.8 (1M context) `, no push). Visual parity is verified on the **Android-TV emulator** against the reference frames (the physical Shield `screencap` returns black). **Conventions:** -- Apple repo lives at `/Users/jimcole/projects/silo/prairie-apple` (branch `feature/playback-ux-redesign`; `cd iosApp && xcodegen generate` before building `PrairieTV`). Read the cited Swift file for exact pt/opacity/scale values; translate per SPEC "Architecture" (unitless 1:1; radii face value; sizes → existing Android TV token scale, tuned on emulator). +- Apple repo lives at `/Users/jimcole/projects/prairie/prairie-apple` (branch `feature/playback-ux-redesign`; `cd iosApp && xcodegen generate` before building `PrairieTV`). Read the cited Swift file for exact pt/opacity/scale values; translate per SPEC "Architecture" (unitless 1:1; radii face value; sizes → existing Android TV token scale, tuned on emulator). - Add a dedicated control radius token `TvControlCorner = 8.dp` (do NOT reuse `small = 12.dp`). - Commit author: every commit uses `git -c user.name=rxwatcher commit …`. diff --git a/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md b/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md index 52d9d7009..a021ca677 100644 --- a/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md +++ b/docs/superpowers/plans/2026-06-17-playback-behavior-migration.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add three playback-behavior features to prairie-android — skip-back-on-resume, pass-out protection, and a remote session-control WebSocket — reusing silo's existing service-owned player. +**Goal:** Add three playback-behavior features to prairie-android — skip-back-on-resume, pass-out protection, and a remote session-control WebSocket — reusing prairie's existing service-owned player. **Architecture:** Pure logic lands in `shared`/`android-shared` (unit-testable, reusable by `androidTvApp`); UI stays in `androidApp`. The realtime socket mirrors the existing `WatchTogetherRealtimeClient` (shared, thin I/O + pure decode) bound to `PlayerViewModel` by a `PlaybackRealtimeController` that mirrors `RoomSyncController`. Server protocol already exists (`/sessions/{session_id}/control/ws`); the wire format matches `prairie-server/web/src/player/realtime-protocol.ts`. @@ -973,7 +973,7 @@ fun decidePlaybackAction(command: PlaybackRealtimeEvent.Command): PlaybackAction } ``` -> `JsonPrimitive.isString` distinguishes a JSON string from a bare number; `doubleOrNull` parses numeric primitives. Confirm `position_seconds` is the field the issuer sends (silo uses `position_seconds` consistently in watch-together); the decode test pins it. +> `JsonPrimitive.isString` distinguishes a JSON string from a bare number; `doubleOrNull` parses numeric primitives. Confirm `position_seconds` is the field the issuer sends (prairie uses `position_seconds` consistently in watch-together); the decode test pins it. - [ ] **Step 4: Run, verify it passes** @@ -1228,7 +1228,7 @@ git add -A && git commit -m "test(playback): subsystem A verification fixes" ## Notes for the implementer -- **No lift-and-shift from continuum.** Continuum used Decompose + an app-level ExoPlayer; silo uses Compose Navigation + a service-owned player. Mirror silo's own patterns (`WatchTogetherRealtimeClient`, `RoomSyncController`, `PlayerViewModel`). +- **No lift-and-shift from continuum.** Continuum used Decompose + an app-level ExoPlayer; prairie uses Compose Navigation + a service-owned player. Mirror prairie's own patterns (`WatchTogetherRealtimeClient`, `RoomSyncController`, `PlayerViewModel`). - **Reuse, don't duplicate.** Every remote-control method and event handler calls a method the VM already exposes. - **Out of scope (this plan):** `play_media`, `set_audio_track`, `set_subtitle_track`, `set_volume` actioning — these map to `Ignore`/`Reject` for now (the client does not advertise the track/media commands in `Supported`). - **TV adoption** of `AutoPlayGuard` and the rewound start position is specified in **Part B** below (the product owner asked Subsystem A to cover TV too). diff --git a/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md b/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md index d497cac8a..49f4cbf03 100644 --- a/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md +++ b/docs/superpowers/plans/2026-06-17-tv-mobile-parity.md @@ -47,7 +47,7 @@ Legend: `[ ]` todo · `[x]` done (commit sha) · `[~]` partial · `[N/A]` won't- - [x] **P4.3 Admin create/edit user (TV)** — quick role/enable actions (setRole/setEnabled in shared AdminUsersViewModel) PLUS the full create/edit FORM: TvAdminUserEditScreen over shared AdminUserEditViewModel (username/email/password, role chips, enabled + download toggles, library-access ids, stream/transcode/profile quotas). Reachable from "Add user" row + "Edit user" dialog option. Nested AdminUserEdit(userId?) route (non-restoring nav); edit-mode derived from route userId; Save gated until edit-load; focus targets first editable field. Codex-reviewed. Commit a85e5a5. - [x] **P4.4 Request Detail (TV)** — parameterized route RequestDetail(mediaType,tmdbId) + DI(params) + TvRequestDetailScreen (reuses shared RequestDetailViewModel; title/meta/genres/overview + Request button[disabled while submitting]/status); reachable from TvRequestsScreen non-actionable taps. (Recommendations rail + poster art = follow-up.) - [x] **P4.5 My Requests open non-library rows (TV)** — both TvRequestsScreen + TvMyRequestsScreen non-library taps open Request Detail (rows always actionable, phone parity). -- [x] **P4.6 Pair Device (TV)** — TvPairDeviceScreen over shared DevicePairingViewModel (approve/deny by token deep link or manual code via TvTextInputDialog); top-level TvRoute.PairDevice(token,code) + DI factory + TvAppNavigation composable + pendingDeepLink "device" branch (prefers token, launchSingleTop); MainTvActivity + manifest now accept `silo` scheme; Settings "Pair a device" row. Codex-reviewed (button enabled-gating + launchSingleTop). Commit d16bde3. (HTTPS `/device` App Links = best-effort follow-up, same caveat as phone parser.) +- [x] **P4.6 Pair Device (TV)** — TvPairDeviceScreen over shared DevicePairingViewModel (approve/deny by token deep link or manual code via TvTextInputDialog); top-level TvRoute.PairDevice(token,code) + DI factory + TvAppNavigation composable + pendingDeepLink "device" branch (prefers token, launchSingleTop); MainTvActivity + manifest now accept `prairie` scheme; Settings "Pair a device" row. Codex-reviewed (button enabled-gating + launchSingleTop). Commit d16bde3. (HTTPS `/device` App Links = best-effort follow-up, same caveat as phone parser.) - [x] **P4.7 Manage Sessions (TV)** — TvManageSessionsViewModel/Screen (own sessions via AuthRepository getSessions/deleteSession) + route/DI; "Manage sessions" row in Settings Account. ## Phase 5 — Collections ✅ commit f88b63b (Codex-reviewed) diff --git a/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md b/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md index 2ae7a1b80..a178e1c23 100644 --- a/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md +++ b/docs/superpowers/plans/2026-07-16-fire-tv-sideload-launcher-icon.md @@ -161,25 +161,25 @@ Expected: `legacyTvLauncherIconsAreOpaqueSquaresAtRequiredDensities` fails becau Preserve the current highest-resolution blank gradient and colorful adaptive foreground before replacing targets: ```bash -cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png /tmp/silo-tv-icon-gradient.png -cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png /tmp/silo-tv-icon-mark.png +cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png /tmp/prairie-tv-icon-gradient.png +cp androidTvApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_foreground.png /tmp/prairie-tv-icon-mark.png for density_and_size in mdpi:80 hdpi:120 xhdpi:160 xxhdpi:240 xxxhdpi:320; do density=${density_and_size%%:*} size=${density_and_size##*:} mark_height=$((size * 68 / 100)) - magick /tmp/silo-tv-icon-gradient.png \ + magick /tmp/prairie-tv-icon-gradient.png \ -gravity center -crop 360x360+0+0 +repage \ -resize "${size}x${size}!" \ - /tmp/silo-tv-icon-background.png + /tmp/prairie-tv-icon-background.png - magick /tmp/silo-tv-icon-mark.png \ + magick /tmp/prairie-tv-icon-mark.png \ -trim +repage -resize "x${mark_height}" \ - /tmp/silo-tv-icon-foreground.png + /tmp/prairie-tv-icon-foreground.png - magick /tmp/silo-tv-icon-background.png \ - /tmp/silo-tv-icon-foreground.png \ + magick /tmp/prairie-tv-icon-background.png \ + /tmp/prairie-tv-icon-foreground.png \ -gravity center -composite \ "PNG24:androidTvApp/src/androidMain/res/mipmap-${density}/ic_launcher.png" done @@ -190,10 +190,10 @@ Expected: five opaque square files with a centered colorful Prairie mark on the Generate the banner separately so Fire OS's centered square crop contains the complete canonical logo: ```bash -magick -size 320x180 canvas:'#1718c9' /tmp/silo-tv-banner-background.png +magick -size 320x180 canvas:'#1718c9' /tmp/prairie-tv-banner-background.png magick androidTvApp/src/androidMain/res/drawable/prairie_wordmark.png \ - -trim +repage -resize '180x' /tmp/silo-tv-banner-wordmark.png -magick /tmp/silo-tv-banner-background.png /tmp/silo-tv-banner-wordmark.png \ + -trim +repage -resize '180x' /tmp/prairie-tv-banner-wordmark.png +magick /tmp/prairie-tv-banner-background.png /tmp/prairie-tv-banner-wordmark.png \ -gravity center -composite \ 'PNG24:androidTvApp/src/androidMain/res/drawable/tv_banner.png' ``` @@ -277,7 +277,7 @@ Expected: installation succeeds and the Fire TV app grid opens. - [ ] **Step 3: Capture and inspect the Fire TV launcher** ```bash -adb -s "$FIRE_TV_SERIAL" exec-out screencap -p > /tmp/fire-tv-silo-square-launcher.png +adb -s "$FIRE_TV_SERIAL" exec-out screencap -p > /tmp/fire-tv-prairie-square-launcher.png ``` Expected: Fire OS retains its outer gray sideload frame, but the inner Prairie icon is square, the colorful mark is not compressed, and the mark is materially larger and sharper than before. @@ -296,7 +296,7 @@ Expected: installation succeeds and the Google TV launcher opens. - [ ] **Step 5: Capture and inspect the Google TV launcher** ```bash -adb -s "$GOOGLE_TV_SERIAL" exec-out screencap -p > /tmp/google-tv-silo-adaptive-launcher.png +adb -s "$GOOGLE_TV_SERIAL" exec-out screencap -p > /tmp/google-tv-prairie-adaptive-launcher.png ``` Expected: Google TV uses the existing adaptive icon with its normal launcher mask; there is no baked square border, stretched banner, or cropped mark. diff --git a/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md b/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md new file mode 100644 index 000000000..3c49c0a02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-android-media-buffer-sizing.md @@ -0,0 +1,400 @@ +# Android Media-Aware Buffer Sizing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Size Android phone and TV byte buffers from encoded media bitrate metadata instead of fast-network delivery capacity whenever media metadata is available. + +**Architecture:** Add one pure internal bitrate-selection function beside `PrairieLoadControl`, represented by a small immutable per-track input. Production maps each selected Media3 track into that input; the function chooses average bitrate, then peak, sums known media rates, and only falls back to the largest network estimate when every media rate is unknown. The existing target-byte calculation, time thresholds, and retry data source remain unchanged. + +**Tech Stack:** Kotlin 2.1, AndroidX Media3 1.10.1, JUnit 4, Gradle, Android phone and TV application modules. + +## Global Constraints + +- Limit production behavior changes to shared `android-shared/PrairieLoadControl`. +- Do not change Prairie Server, Apple clients, or production proxy configuration. +- Preserve issue #80 HTTP Range resume/retry behavior. +- Preserve existing startup, rebuffer, and back-buffer time thresholds. +- Preserve the 16 MiB byte floor, device-specific byte caps, and 15 percent byte overhead. +- Use positive `Format.averageBitrate` first and positive `Format.peakBitrate` only when average is absent or invalid. +- Do not use `Format.bitrate` as an independent input. +- Use raw network throughput only when no selected track has valid media metadata. +- Do not claim observed-consumption adaptation because Media3 exposes no reliable encoded-consumption signal at this boundary. + +--- + +### Task 1: Characterize the approved bitrate-selection contract + +**Files:** +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` +- Modify later in Task 2: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` + +**Interfaces:** +- Consumes: proposed `BufferSizingTrackBitrates(averageBitrateBps: Int, peakBitrateBps: Int, latestNetworkEstimateBps: Long)` +- Produces: regression expectations for `selectBufferSizingBitrateBps(tracks: List): Long?` + +- [ ] **Step 1: Add failing average/peak precedence tests** + +Create `PrairieLoadControlTest.kt` with literal expectations: + +```kotlin +package org.prairieserver.prairie.common.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PrairieLoadControlTest { + @Test + fun `average bitrate takes precedence over peak bitrate`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = 4_000_000, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(4_000_000L, selected) + } + + @Test + fun `peak bitrate is used when average bitrate is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates( + averageBitrateBps = -1, + peakBitrateBps = 9_000_000, + latestNetworkEstimateBps = 100_000_000L, + ), + ), + ) + + assertEquals(9_000_000L, selected) + } +} +``` + +- [ ] **Step 2: Add failing media aggregation and no-inflation tests** + +Extend the same test class: + +```kotlin + @Test + fun `known selected media bitrates are summed and network capacity is ignored`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, 192_000, 100_000_000L), + ), + ) + + assertEquals(4_192_000L, selected) + } + + @Test + fun `one known media rate suppresses network fallback from metadata-poor tracks`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(4_000_000, 8_000_000, 100_000_000L), + BufferSizingTrackBitrates(-1, -1, 100_000_000L), + ), + ) + + assertEquals(4_000_000L, selected) + } +``` + +- [ ] **Step 3: Add failing last-resort and unknown tests** + +Extend the same test class: + +```kotlin + @Test + fun `largest network estimate is the last resort when all media metadata is invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(0, -1, 18_000_000L), + BufferSizingTrackBitrates(-1, 0, 25_000_000L), + ), + ) + + assertEquals(25_000_000L, selected) + } + + @Test + fun `unknown bitrate remains unknown when metadata and network estimates are invalid`() { + val selected = + selectBufferSizingBitrateBps( + listOf( + BufferSizingTrackBitrates(-1, 0, -1L), + ), + ) + + assertNull(selected) + } + + @Test + fun `empty track selection remains unknown`() { + assertNull(selectBufferSizingBitrateBps(emptyList())) + } +``` + +- [ ] **Step 4: Run the focused test to prove RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PrairieLoadControlTest' \ + --max-workers=2 +``` + +Expected: compilation fails because `BufferSizingTrackBitrates` and `selectBufferSizingBitrateBps` do not exist. + +- [ ] **Step 5: Commit the RED tests** + +```bash +git add android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "test(android): specify media-aware buffer bitrate selection" +``` + +### Task 2: Implement media-metadata-first selection + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: Media3 `ExoTrackSelection.selectedFormat` and `latestBitrateEstimate` +- Produces: `internal data class BufferSizingTrackBitrates` and `internal fun selectBufferSizingBitrateBps(List): Long?` + +- [ ] **Step 1: Add the minimal pure selector** + +Add beside the existing target-byte helper: + +```kotlin +internal data class BufferSizingTrackBitrates( + val averageBitrateBps: Int, + val peakBitrateBps: Int, + val latestNetworkEstimateBps: Long, +) + +internal fun selectBufferSizingBitrateBps( + tracks: List, +): Long? { + val mediaBitrateBps = + tracks + .mapNotNull { track -> + track.averageBitrateBps.takeIf { it > 0 }?.toLong() + ?: track.peakBitrateBps.takeIf { it > 0 }?.toLong() + } + + if (mediaBitrateBps.isNotEmpty()) { + return mediaBitrateBps.sum() + } + + return tracks + .maxOfOrNull { it.latestNetworkEstimateBps } + ?.takeIf { it > 0L } +} +``` + +- [ ] **Step 2: Route selected Media3 tracks through the selector** + +Replace the per-selection maximum with one selection-wide call: + +```kotlin +val selectedBitrateBps = + selectBufferSizingBitrateBps( + trackSelections.mapNotNull { selection -> + selection?.let { + BufferSizingTrackBitrates( + averageBitrateBps = it.selectedFormat.averageBitrate, + peakBitrateBps = it.selectedFormat.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + ) + } + }, + ) +``` + +Delete the old private `ExoTrackSelection.selectedBitrateBps()` helper. Do not read `Format.bitrate`. + +- [ ] **Step 3: Run the focused selector and policy tests** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.PrairieLoadControlTest' \ + --tests 'org.prairieserver.prairie.common.player.PlaybackBufferPolicyTest' \ + --max-workers=2 +``` + +Expected: all tests pass. Existing floor/cap tests demonstrate the byte calculation is unchanged. + +- [ ] **Step 4: Perform the mutation check** + +Temporarily reason through these mutations without retaining source changes: + +- choosing peak before average fails `average bitrate takes precedence over peak bitrate`; +- adding network estimates to known media rates fails both no-inflation tests; +- summing shared network estimates fails `largest network estimate is the last resort`; +- accepting zero as known metadata fails the last-resort test; +- returning zero instead of `null` fails both unknown tests. + +- [ ] **Step 5: Commit the implementation** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt +git commit -m "fix(android): size playback buffer from media bitrate" +``` + +### Task 3: Verify issue #80 compatibility and Android builds + +**Files:** +- No production files expected +- Inspect: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/ProgressiveDirectPlayResumeIntegrationTest.kt` + +**Interfaces:** +- Consumes: completed load-control change +- Produces: test and build evidence; no new API + +- [ ] **Step 1: Run the progressive Range-resume integration test** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.ProgressiveDirectPlayResumeIntegrationTest' \ + --max-workers=2 +``` + +Expected: all resume/retry cases pass without changes. + +- [ ] **Step 2: Run the complete shared Android unit-test suite** + +```bash +./gradlew :android-shared:testDebugUnitTest --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run phone and TV debug compilation** + +```bash +./gradlew \ + :androidApp:compileDebugKotlin \ + :androidTvApp:compileDebugKotlin \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Run phone and TV release assembly** + +```bash +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL` with dependency verification intact. + +- [ ] **Step 5: Run repository formatting/static checks applicable to the changed Kotlin** + +Inspect available Gradle verification tasks and run the repository's configured Kotlin lint/format checks. At minimum run: + +```bash +./gradlew check --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +### Task 4: Perform focused canary and independent review + +**Files:** +- Modify only if a deterministic defect is found: the two files from Tasks 1–2 +- Record review or canary notes in the draft PR description rather than production code + +**Interfaces:** +- Consumes: green implementation branch +- Produces: review verdict and bounded runtime evidence + +- [ ] **Step 1: Exercise a short-timeout local canary if the existing playback harness can do so safely** + +Use an ephemeral local endpoint or the existing playback harness with deliberately short idle timeouts. Do not change production proxy settings. Confirm that a Range-capable direct-play request resumes or retries using the existing issue #80 path. + +If the fixture cannot create genuine socket backpressure, record exactly that limitation and rely on the integration test plus target-selection regression tests; do not substitute bandwidth or allocator growth as a proxy. + +- [ ] **Step 2: Request an independent code review** + +Ask a fresh reviewer to inspect: + +- compliance with the approved average-then-peak order; +- absence of `Format.bitrate` as an independent input; +- network fallback only when all media metadata is absent; +- integer overflow or malformed-metadata handling; +- preservation of load-control floors, caps, thresholds, and issue #80 retry code; +- whether tests would fail under each realistic wrong branch. + +- [ ] **Step 3: Apply only verified corrections test-first** + +For each actionable defect, first add or adjust a test that fails for that defect, run it to prove RED, make the smallest production correction, then rerun the focused and complete gates from Task 3. + +- [ ] **Step 4: Run final clean verification** + +From a clean worktree, rerun: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:compileDebugKotlin \ + :androidTvApp:compileDebugKotlin \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + --max-workers=2 +git diff --check +git status --short +``` + +Expected: Gradle and diff checks succeed; status contains no uncommitted implementation changes. + +### Task 5: Publish a separate draft pull request + +**Files:** +- No code changes expected + +**Interfaces:** +- Consumes: reviewed, green `fix/android-buffer-sizing` branch +- Produces: a draft GitHub pull request targeting current `main` + +- [ ] **Step 1: Review the branch diff and commits** + +```bash +git log --oneline origin/main..HEAD +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Expected: only the spec, plan, focused tests, and shared load-control implementation are present. + +- [ ] **Step 2: Push the branch** + +```bash +git push -u origin fix/android-buffer-sizing +``` + +- [ ] **Step 3: Open a draft pull request** + +Create a draft PR targeting `main`. Summarize the media-metadata-first rule, why observed adaptation is deferred, preserved issue #80 behavior, exact verification commands, independent review verdict, and short-timeout canary evidence or limitation. Do not merge. + +- [ ] **Step 4: Confirm hosted checks** + +Watch the PR checks to terminal state. If any hosted job fails, inspect the exact logs, reproduce systematically, correct at the lowest responsible boundary test-first, repush, and wait for green before reporting completion. diff --git a/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md b/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md new file mode 100644 index 000000000..f4d60c967 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-android-tv-navigation-remediation.md @@ -0,0 +1,976 @@ +# Android TV Navigation Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore predictable Android TV navigation, unify For You saved-list presentation, and remove the duplicate, eager cold-start work that makes first Home traversal sluggish on production-sized servers. + +**Architecture:** A shared pure Home hydrator will consume inline aggregate sections and make at most four fallback requests for genuinely unresolved sections. Android TV will use explicit focus policies for recommendation entry and the Home-to-menu boundary, plus a small pure prefetch policy that permits neighbor work only after focus has settled. The For You selector will issue an explicit, repeatable inline-selection request to the existing For You screen; profile-menu saved-list routes remain standalone. + +**Tech Stack:** Kotlin Multiplatform, Kotlin coroutines and `kotlinx-coroutines-test`, Compose for TV focus APIs, Ktor `MockEngine`, Room-backed cache ports, Gradle Android unit/release tasks, ADB/gfxinfo for the final Shield smoke. + +## Global Constraints + +- Do not change server APIs, server configuration, recommendation ranking, Home row composition, authentication, playback, or database schema. +- Preserve Watchlist and Favorites behavior and the existing initial Watchlist focus. +- A repeated/held Up sequence stops on Home's first content row; only a fresh Up press enters the top menu. +- Watchlist and Favorites selected from the For You top-menu selector use the existing inline For You presentation. +- Watchlist and Favorites selected from the profile menu remain standalone utility pages. +- Preserve partial-refresh cache safety: an incomplete network result must not overwrite a complete cached Home. +- Keep item-detail screens network-first; cache-first semantics apply only to speculative marquee enrichment. +- Limit fallback Home hydration and speculative detail fan-out to four concurrent requests. +- Do not hide latency with a longer splash, input suppression, animation tuning, or server-side content caps. +- No production data mutation is permitted during verification. + +--- + +### Task 1: Share bounded, inline-first Home hydration + +**Files:** +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt` +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt:15-175` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt:1-163` +- Test: `shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelTest.kt` + +**Interfaces:** +- Consumes: `ResolvedSection`, `HomeSectionItemsResponse`, `ApiResult`, and the existing `SectionRepository.getHomeSectionItems(String)`. +- Produces: + +```kotlin +data class HomeSectionHydration( + val sections: List, + val fullyResolved: Boolean, +) + +suspend fun hydrateHomeSections( + sections: List, + maxConcurrency: Int = 4, + fetchItems: suspend (String) -> ApiResult, +): HomeSectionHydration +``` + +- [ ] **Step 1: Write the failing inline-section, response-shape, and concurrency tests** + +Create `HomeSectionHydratorTest` with literal fixtures and tests proving: + +```kotlin +@Test +fun inlineSectionsRequireNoFallbackRequests() = runTest { + var calls = 0 + val result = hydrateHomeSections(listOf(section("inline", total = 1, items = listOf(item("a"))))) { + calls += 1 + error("fallback must not run") + } + assertEquals(0, calls) + assertEquals(listOf("a"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) +} + +@Test +fun topLevelFallbackItemsHydrateTheOriginalSection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("b")))) + } + assertEquals("missing", result.sections.single().id) + assertEquals(listOf("b"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) +} + +@Test +fun failedFallbackMarksSnapshotPartialAndOmitsEmptySection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.NetworkError(IllegalStateException("offline")) + } + assertTrue(result.sections.isEmpty()) + assertFalse(result.fullyResolved) +} +``` + +Add a fourth test with twelve unresolved sections, two +`CompletableDeferred` gates, and atomic active/maximum counters. Hold the +first four requests at the gate, assert no fifth request starts, release them in +batches, and finally assert `maximum == 4`, all twelve IDs were fetched exactly +once, and output order matches input order. + +- [ ] **Step 2: Run the hydrator tests and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --no-daemon +``` + +Expected: compilation failure because `hydrateHomeSections` and `HomeSectionHydration` do not exist. + +- [ ] **Step 3: Implement the pure hydrator with a four-request semaphore** + +Use `kotlinx.coroutines.sync.Semaphore` and `withPermit` inside `coroutineScope`. Preserve inline sections without invoking `fetchItems`; fetch only `items.isEmpty() && totalCount > 0`; resolve nested `section.items`, nested zero-total responses, and top-level `items` with the same precedence currently used by `HomeViewModel`. + +- [ ] **Step 4: Run the hydrator tests and verify GREEN** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --no-daemon +``` + +Expected: all `HomeSectionHydratorTest` cases pass. + +- [ ] **Step 5: Write caller regression tests before changing either caller** + +Extend `HomeViewModelTest` so a repository returning fully inline aggregate +sections records zero `getHomeSectionItems` calls while still updating UI and +cache. Create +`android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeHydrationTest.kt` +with Android startup-shaped fixtures that assert the same zero-call contract. + +- [ ] **Step 6: Run the caller tests and verify RED** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest --tests '*HomeViewModelTest*inline*' \ + :android-shared:testDebugUnitTest --tests '*StartupHomeHydrationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: the startup test reports one fallback request per inline section; +the shared ViewModel assertion protects the already-correct inline behavior. + +- [ ] **Step 7: Replace duplicated resolution code in both callers** + +In `HomeViewModel.fetchSections`, call: + +```kotlin +val hydration = hydrateHomeSections(sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) +} +val resolved = hydration.sections +val fullyResolved = hydration.fullyResolved +``` + +In `StartupWarmup.warmHome`, call the same function and cache/warm artwork only when `fullyResolved` is true and `sections` is nonempty. This removes the unconditional per-section N+1. + +- [ ] **Step 8: Run focused caller tests** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest --tests '*HomeSectionHydratorTest' --tests '*HomeViewModelTest' \ + :android-shared:testDebugUnitTest --tests '*StartupHomeHydrationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all focused tests pass with no fallback request for inline sections and a maximum of four for unresolved sections. + +- [ ] **Step 9: Commit Task 1** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelTest.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeHydrationTest.kt +git commit -m "perf(android): hydrate inline home sections once" +``` + +### Task 2: Add cache-first speculative detail reads + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt:110-125` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` + +**Interfaces:** +- Consumes: existing `CatalogCachePort.getCachedItemDetail` and network-first `getItemDetail`. +- Produces: + +```kotlin +suspend fun getItemDetailForPrefetch(contentId: String): ApiResult +``` + +- [ ] **Step 1: Write failing cache-hit and cache-miss tests** + +Extend `CatalogRepositoryDetailCacheTest`: + +```kotlin +@Test +fun prefetchUsesCachedDetailWithoutNetwork() = runTest { + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) + val result = repoThatFailsOnNetwork(cache).getItemDetailForPrefetch("c1") + assertEquals("Cached", (result as ApiResult.Success).data.title) +} + +@Test +fun prefetchFetchesAndCachesWhenDetailIsAbsent() = runTest { + val cache = FakeCache() + val result = repo(HttpStatusCode.OK, """{"content_id":"c2","type":"movie","title":"Fresh"}""", cache) + .getItemDetailForPrefetch("c2") + assertEquals("Fresh", (result as ApiResult.Success).data.title) + assertEquals("c2", cache.cachedId) +} +``` + +- [ ] **Step 2: Run the repository tests and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*CatalogRepositoryDetailCacheTest' --no-daemon +``` + +Expected: compilation failure because `getItemDetailForPrefetch` does not exist. + +- [ ] **Step 3: Implement the minimal cache-first method** + +```kotlin +suspend fun getItemDetailForPrefetch(contentId: String): ApiResult { + catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + return getItemDetail(contentId) +} +``` + +Do not change `getItemDetail`. + +- [ ] **Step 4: Run the repository tests and verify GREEN** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest --tests '*CatalogRepositoryDetailCacheTest' --no-daemon +``` + +Expected: every cache test passes; the existing network-first tests remain unchanged. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt +git commit -m "perf(tv): make marquee prefetch cache first" +``` + +### Task 3: Make Skyline prefetch settled, bounded, and demand-driven + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:95-341` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt` + +**Interfaces:** +- Consumes: the raw focused content ID, the committed/rested marquee content ID, the focused row, and radius two. +- Produces: + +```kotlin +internal fun settledPrefetchItems( + items: List, + rawFocusedContentId: String?, + settledContentId: String?, + radius: Int = 2, +): List +``` + +The function returns an empty list unless raw and settled identities match. When they match, it returns at most two neighbors on each side, excluding the focused item. + +- [ ] **Step 1: Write the failing policy tests** + +Create literal five-card fixtures and assert: + +```kotlin +@Test +fun rapidFocusBeforeMarqueeSettlementStartsNoNeighborWork() { + assertEquals( + emptyList(), + settledPrefetchItems(items, rawFocusedContentId = "d", settledContentId = "b"), + ) +} + +@Test +fun settledFocusReturnsOnlyTwoNeighborsPerSide() { + assertEquals( + listOf("a", "b", "d", "e"), + settledPrefetchItems(items, rawFocusedContentId = "c", settledContentId = "c") + .map { it.contentId }, + ) +} +``` + +Also cover first-card and missing-ID boundaries. + +- [ ] **Step 2: Run the policy tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvSkylinePrefetchPolicyTest' --no-daemon +``` + +Expected: compilation failure because `settledPrefetchItems` does not exist. + +- [ ] **Step 3: Implement the pure settled-focus policy** + +Use `indexOfFirst`, a clamped inclusive range, and `takeIf` identity equality. Return values in row order and exclude the focused index. + +- [ ] **Step 4: Run the policy tests and verify GREEN** + +Run the same command and confirm all boundary cases pass. + +- [ ] **Step 5: Remove unconditional page-entry preload effects** + +Delete only the two `LaunchedEffect(rows)`/`LaunchedEffect(rows, fetchDetail)` blocks that preload hero artwork and full details for two rows × eight items. Remove `HeroPreloadRowCount` and `HeroPreloadItemsPerRow`. Retain the initial aggregate-data marquee seed and startup artwork plan. + +- [ ] **Step 6: Wire cache-first settled neighbor work** + +Change the injected detail lambda to call `catalogRepository.getItemDetailForPrefetch`. Replace the raw-index neighbor calculation with `settledPrefetchItems`. Key the effect on `rows`, `focusedContentId`, `marquee.content?.contentId`, and the fetcher. A raw focus move cancels the old job immediately; the identity mismatch starts no new work until the marquee commits after its existing 150 ms rest. + +Keep the existing maximum four neighbors, request claim, stale-result guard, artwork sizing, and composition-cancellation ownership. + +- [ ] **Step 7: Add an integration-level request-budget test** + +Extend `TvFocusMarqueeEnrichmentTest` with a coroutine test that sends focus identities `a`, `b`, `c` within less than 150 ms and proves only the settled `c` policy window is returned. Mutate the equality guard locally to verify the test fails by returning the `b` window, then restore the implementation. + +- [ ] **Step 8: Run the Skyline and marquee suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylinePrefetchPolicyTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all focused tests pass. + +- [ ] **Step 9: Commit Task 3** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicy.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylinePrefetchPolicyTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt +git commit -m "perf(tv): defer skyline work until focus settles" +``` + +### Task 4: Bridge For You filters into recommendation rows + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt:64-289` + +**Interfaces:** +- Consumes: row-container/card focus request lambdas and a one-frame suspension. +- Produces: + +```kotlin +internal suspend fun requestRecommendationRowFocus( + requestRowContainer: () -> Boolean, + awaitFrame: suspend () -> Unit, + requestFirstCard: () -> Boolean, +): Boolean + +internal fun shouldBridgeRecommendationsDown( + showingRecommendations: Boolean, + hasVisibleRecommendations: Boolean, +): Boolean +``` + +The function returns false only when the row container cannot accept focus. After a successful row hop it waits one frame and targets the first card. + +- [ ] **Step 1: Write the failing focus-bridge tests** + +Create tests with a literal event list: + +```kotlin +@Test +fun handoffCrossesRowRestorerBeforeTargetingFirstCard() = runTest { + val events = mutableListOf() + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; true }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + assertTrue(handled) + assertEquals(listOf("row", "frame", "card"), events) +} + +@Test +fun rejectedRowHopDoesNotTargetCard() = runTest { + val events = mutableListOf() + val handled = requestRecommendationRowFocus( + requestRowContainer = { events += "row"; false }, + awaitFrame = { events += "frame" }, + requestFirstCard = { events += "card"; true }, + ) + assertFalse(handled) + assertEquals(listOf("row"), events) +} +``` + +- [ ] **Step 2: Add the failing visibility-policy tests** + +Assert that `shouldBridgeRecommendationsDown` returns true only when For You is +selected and at least one recommendation row is visible. Assert false for +Watchlist, Favorites, loading, error, and empty For You states. + +- [ ] **Step 3: Run the bridge tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' --no-daemon +``` + +Expected: compilation failure because the bridge does not exist. + +- [ ] **Step 4: Implement the minimal bridge** + +Implement exactly the ordered row/frame/card sequence and the two-boolean +visibility policy. Do not retry or add delays; `TvMediaRow` owns the row +`focusRestorer` contract. + +- [ ] **Step 5: Run the bridge tests and verify GREEN** + +Run the same command and confirm both ordering and rejected-hop behavior pass. + +- [ ] **Step 6: Wire stable requesters into the screen** + +Add stable requesters for For You, Watchlist, Favorites, the first recommendation row container, and its first card. Render recommendation rows with indexed items so only index zero receives: + +```kotlin +firstItemFocusRequester = recommendationFirstCardFocusRequester +rowContainerFocusRequester = recommendationFirstRowContainerFocusRequester +onDirectionUp = { + selectedFilterRequester.requestFocus() +} +``` + +Each filter pill's `onDirectionDown` consults +`shouldBridgeRecommendationsDown`; Watchlist and Favorites continue to use +their existing geometric grid navigation when selected. Keep initial entry on +Watchlist. The first For You row's `onDirectionUp` requests the For You pill. + +- [ ] **Step 7: Run the complete focused TV suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvRecommendationsFocusBridgeTest' \ + --tests '*TvSkylinePrefetchPolicyTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 --no-daemon +``` + +Expected: every focused test passes. + +- [ ] **Step 8: Commit Task 4** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt +git commit -m "fix(tv): enter for-you rows from filter controls" +``` + +### Task 5: Make the Home-to-menu boundary deliberate + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:277-318` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt:38-216` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:369-401,789-810` + +**Interfaces:** +- Consumes: `focusedRowIndex`, `rows.indices`, Android `KeyEvent.nativeKeyEvent.repeatCount`, and the existing shell-owned content-to-menu handoff. +- Produces: + +```kotlin +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, +): TvSkylineUpAction +``` + +The content fallback callback becomes `(isRepeat: Boolean) -> Boolean`. `true` +means the feed consumed the event; `false` means the shell may focus the top +menu. + +- [ ] **Step 1: Write the failing pure focus-boundary tests** + +Create `TvSkylineUpNavigationTest`: + +```kotlin +@Test +fun heldUpStopsOnFirstContentRow() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction(currentRow = 0, rowCount = 6, isRepeat = true, relocationInFlight = false), + ) +} + +@Test +fun freshUpFromFirstContentRowMayEnterMenu() { + assertEquals( + TvSkylineUpAction.EnterMenu, + tvSkylineUpAction(currentRow = 0, rowCount = 6, isRepeat = false, relocationInFlight = false), + ) +} + +@Test +fun repeatedInputDuringOffscreenRelocationIsConsumed() { + assertEquals( + TvSkylineUpAction.StayInContent, + tvSkylineUpAction(currentRow = 4, rowCount = 6, isRepeat = true, relocationInFlight = true), + ) +} + +@Test +fun ordinaryUpWithinRowsTriesExactlyOnePreviousRow() { + assertEquals( + TvSkylineUpAction.TryPreviousRow, + tvSkylineUpAction(currentRow = 4, rowCount = 6, isRepeat = false, relocationInFlight = false), + ) +} +``` + +- [ ] **Step 2: Run the boundary tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylineUpNavigationTest' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `TvSkylineUpAction` and +`tvSkylineUpAction` do not exist. + +- [ ] **Step 3: Implement the pure policy** + +Create `TvSkylineUpNavigation.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +internal enum class TvSkylineUpAction { + EnterMenu, + StayInContent, + TryPreviousRow, +} + +internal fun tvSkylineUpAction( + currentRow: Int, + rowCount: Int, + isRepeat: Boolean, + relocationInFlight: Boolean, +): TvSkylineUpAction = when { + relocationInFlight -> TvSkylineUpAction.StayInContent + currentRow !in 0 until rowCount -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + currentRow == 0 -> + if (isRepeat) TvSkylineUpAction.StayInContent else TvSkylineUpAction.EnterMenu + else -> TvSkylineUpAction.TryPreviousRow +} +``` + +- [ ] **Step 4: Run the pure tests and verify GREEN** + +Run the Step 2 command. Expected: all four tests pass. + +- [ ] **Step 5: Wire repeat identity and serialized relocation** + +Change `onContentUpFallbackChanged` through `TvHomeScreen`, +`TvHomeContent`, and `TvSkylineSectionFeed` to carry +`((isRepeat: Boolean) -> Boolean)`. + +In `TvSkylineSectionFeed`, remember `rowRelocationInFlight`. For +`TryPreviousRow`, first call `focusManager.moveFocus(FocusDirection.Up)`. If +that fails, set `rowRelocationInFlight = true`, launch exactly one +`animateScrollToItem(currentRow - 1)` job, await one frame, attempt the focus +move, and clear the flag in `finally`. `StayInContent` returns `true`; +`EnterMenu` returns `false`. + +In `TvMainShell`, pass: + +```kotlin +val isRepeat = ev.nativeKeyEvent.repeatCount > 0 +val contentHandledUp = contentUpFallback?.invoke(isRepeat) +``` + +Keep the shell's existing `focusState.requestMenuFocus()` behavior only when +the active feed returns `false`. + +- [ ] **Step 6: Run the boundary test and TV compilation** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSkylineUpNavigationTest' \ + :androidTvApp:compileDebugKotlin \ + --max-workers=2 --no-daemon +``` + +Expected: tests and compilation pass. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigation.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/home/TvHomeScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineUpNavigationTest.kt +git commit -m "fix(tv): stop held up at the first home row" +``` + +### Task 6: Route For You saved lists through one presentation + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt:67-117` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:971-976,1224-1242,1290-1307` + +**Interfaces:** +- Consumes: the existing `TvMainRoute.ForYou`, `TvRecommendationsScreen`, + `TvWatchlistInline`, `TvFavoritesInline`, and standalone profile-menu routes. +- Produces: + +```kotlin +internal enum class SavedListSelection { + Watchlist, + Favorites, +} + +internal data class TvForYouEntryRequest( + val sequence: Int = 0, + val selection: SavedListSelection? = null, +) { + fun next(selection: SavedListSelection?): TvForYouEntryRequest = + TvForYouEntryRequest(sequence = sequence + 1, selection = selection) +} + +internal data class AppliedForYouSelection( + val selection: SavedListSelection?, + val lastAppliedSequence: Int, +) + +internal fun applyForYouEntryRequest( + currentSelection: SavedListSelection?, + lastAppliedSequence: Int, + request: TvForYouEntryRequest, +): AppliedForYouSelection +``` + +`TvRecommendationsScreen` accepts +`entryRequest: TvForYouEntryRequest = TvForYouEntryRequest()` and applies its +selection only when `entryRequest.sequence` changes. + +- [ ] **Step 1: Write the failing request-state tests** + +Create `TvForYouEntryRequestTest`: + +```kotlin +@Test +fun repeatedSelectionStillCreatesANewRequest() { + val first = TvForYouEntryRequest().next(SavedListSelection.Watchlist) + val second = first.next(SavedListSelection.Watchlist) + + assertEquals(1, first.sequence) + assertEquals(2, second.sequence) + assertEquals(SavedListSelection.Watchlist, second.selection) +} + +@Test +fun recommendationsRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Favorites, + ).next(null) + + assertEquals(5, request.sequence) + assertNull(request.selection) +} + +@Test +fun unrelatedRecompositionDoesNotOverrideInPageSelection() { + val applied = applyForYouEntryRequest( + currentSelection = SavedListSelection.Favorites, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 3, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Favorites, applied.selection) + assertEquals(3, applied.lastAppliedSequence) +} + +@Test +fun newerRequestAppliesRequestedInlineSelection() { + val applied = applyForYouEntryRequest( + currentSelection = null, + lastAppliedSequence = 3, + request = TvForYouEntryRequest( + sequence = 4, + selection = SavedListSelection.Watchlist, + ), + ) + + assertEquals(SavedListSelection.Watchlist, applied.selection) + assertEquals(4, applied.lastAppliedSequence) +} +``` + +- [ ] **Step 2: Run the request-state tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because the request and applied-selection types do +not exist. + +- [ ] **Step 3: Implement the repeatable entry request** + +Create `TvForYouEntryRequest.kt` with the exact interfaces above. The apply +function returns the current selection unchanged when +`request.sequence <= lastAppliedSequence`; otherwise it returns the request's +selection and sequence. Move +`SavedListSelection` out of `TvRecommendationsScreen.kt` into that file. + +Add the screen parameter: + +```kotlin +entryRequest: TvForYouEntryRequest = TvForYouEntryRequest(), +``` + +Initialize selection and the last applied sequence with `remember`, then add: + +```kotlin +LaunchedEffect(entryRequest.sequence) { + val applied = applyForYouEntryRequest( + currentSelection = savedListSelection, + lastAppliedSequence = lastAppliedEntrySequence, + request = entryRequest, + ) + savedListSelection = applied.selection + lastAppliedEntrySequence = applied.lastAppliedSequence +} +``` + +This permits the same top-menu choice to be selected repeatedly and does not +overwrite in-page pill changes during unrelated recompositions. + +- [ ] **Step 4: Wire only the For You selector to inline requests** + +In `TvMainShell`, remember: + +```kotlin +var forYouEntryRequest by remember { mutableStateOf(TvForYouEntryRequest()) } +val openForYou: (SavedListSelection?) -> Unit = { selection -> + forYouEntryRequest = forYouEntryRequest.next(selection) + focusState.closePanel(false) + navigateToSecondary(TvMainRoute.ForYou.route) + moveFocusToContent(TvMainRoute.ForYou.route) +} +``` + +Pass `entryRequest = forYouEntryRequest` to `TvRecommendationsScreen`. Wire +the three `TvForYouSelector` callbacks to: + +```kotlin +onWatchlist = { openForYou(SavedListSelection.Watchlist) } +onFavorites = { openForYou(SavedListSelection.Favorites) } +onRecommendations = { openForYou(null) } +``` + +Do not change `TvProfileDropdown` callbacks: they must continue navigating to +`TvMainRoute.Watchlist` and `TvMainRoute.Favorites`. + +- [ ] **Step 5: Run focused routing and existing focus tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvRecommendationsFocusBridgeTest' \ + --max-workers=2 --no-daemon +``` + +Expected: all tests pass. + +- [ ] **Step 6: Manually inspect the two call-site groups** + +Run: + +```bash +git diff -- \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt +``` + +Confirm the `TvForYouSelector` callbacks all invoke `openForYou`, while the +`TvProfileDropdown` Watchlist/Favorites callbacks still navigate directly to +their standalone routes. + +- [ ] **Step 7: Commit Task 6** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt +git commit -m "fix(tv): unify for-you saved-list routes" +``` + +### Task 7: Full verification, production-shaped smoke, and executive summary + +**Files:** +- Create: `docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md` +- Modify only if verification exposes a feature regression: files already listed in Tasks 1-4. + +**Interfaces:** +- Consumes: all prior task commits and an explicitly approved authenticated TV + test target. +- Produces: a concise executive summary containing impact, causes, remedy, quantified before/after evidence, rollout risk, and rollback boundary. + +- [ ] **Step 1: Run supply-chain and complete relevant unit gates** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --max-workers=2 --no-daemon +``` + +Expected: exit zero with no failed test task. + +- [ ] **Step 2: Compile debug and minified TV release** + +Run: + +```bash +./gradlew \ + :androidTvApp:assembleDebug \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: both tasks exit zero. + +- [ ] **Step 3: Perform the For You device smoke** + +Install only on the explicitly selected Shield if the candidate signer is compatible with the installed package; otherwise use the dedicated TV emulator. Preserve app data with: + +```bash +TV_TEST_SERIAL="" +adb -s "$TV_TEST_SERIAL" install -r \ + androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk +``` + +Verify: + +1. Enter For You from the top menu. +2. Initial focus remains Watchlist. +3. Select For You and press Down; focus reaches the first visible recommendation card. +4. Press Down/Up repeatedly; focus is not trapped and Up returns to For You. +5. Select Watchlist and Favorites from the in-page pills; Down enters their inline grids. +6. Select Watchlist and Favorites from the top For You selector; each opens the + same inline For You presentation with the matching pill selected. +7. Select Watchlist and Favorites from the profile menu; each retains its + standalone utility-page presentation. +8. From several Home rows down, hold Up until the first row is reached; the + repeated sequence stays in content, and a released-then-fresh Up enters the + selected top-menu item. + +- [ ] **Step 4: Repeat the cold/warm performance protocol** + +Against `lib.strm.cafe`, clear only process state with `am force-stop`, reset `dumpsys gfxinfo`, clear logcat, launch, and run the same fixed sequences used during diagnosis: + +- horizontal: five Right then five Left at 250 ms; +- vertical: three Down then three Up at 500 ms; +- repeat each immediately without process restart. + +Record total/janky frames, p50/p90/p95/p99, sanitized HTTP completion count, GC count, crash, and ANR status. Do not capture credentials or request headers. + +- [ ] **Step 5: Write the executive summary** + +Create the summary with these exact sections: + +- `Decision`: what was fixed and why it is safe to ship. +- `Customer impact`: For You accessibility and first-traversal responsiveness. +- `Verified causes`: missing focus bridge, duplicate Home hydration, eager detail + fan-out, an unguarded repeated-Up boundary, and two For You saved-list routes. +- `Change`: inline-first bounded hydration, cache-first rested enrichment, + deliberate focus handoff, serialized row relocation, and unified For You + selector presentation. +- `Evidence`: before/after cold and warm measurements and test/build counts. +- `Risk and rollback`: Android TV focus/prefetch scope, no schema/server change, revert commits independently. + +- [ ] **Step 6: Review diff and obtain independent review** + +Run: + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Request focused review of correctness, coroutine cancellation, focus-restorer ordering, cache semantics, and whether tests detect realistic regressions. Fix every Critical or Important finding test-first and rerun the smallest affected gate. + +- [ ] **Step 7: Commit the verified executive summary** + +```bash +git add docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md +git commit -m "docs(tv): summarize navigation remediation" +``` + +- [ ] **Step 8: Run the final fresh gate** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew :shared:testDebugUnitTest \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: exit zero. Confirm `git status --short` is empty before invoking `superpowers:finishing-a-development-branch`. diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md index 66b497978..a8b8bb02f 100644 --- a/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-b-auth-epub.md @@ -18,7 +18,7 @@ the reviewed slice-A head as one security-focused, compiling vertical slice. - `eda4a4a2` — keep hardened EPUB paths compatible with Android API 24 - `1fecf9b1` — centralize authenticated HTTP origin comparison - `cbf398fa` — reject ambiguous HTTP authorities -- `5d5f0562` — keep credentials on the configured Silo origin +- `5d5f0562` — keep credentials on the configured Prairie origin - `e18d092e` — reject authentication scopes whose credentials were replaced - `c07d9f9f` — require explicit consent before cleartext login diff --git a/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md b/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md index 3d50c4b82..7ef631913 100644 --- a/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md +++ b/docs/superpowers/plans/2026-07-27-pr108-slice-f-watch-together.md @@ -196,7 +196,7 @@ OkHttp/MockWebServer, Koin, Android Compose, Gradle, ADB/emulator tooling. **Environment:** - Use two concurrent dedicated AVDs, preferring `Silo_Phone` and `Silo_TV`. - Use distinct app data and user/profile identities. -- Start an appropriate local/configured Silo test backend without modifying +- Start an appropriate local/configured Prairie test backend without modifying production data. **Flow:** diff --git a/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md b/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md new file mode 100644 index 000000000..5f35b4f6f --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-watch-together-user-menu-entry.md @@ -0,0 +1,2174 @@ +# Watch Together User-Menu Entry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a top-level Watch Together entry to the authenticated profile menu on Android phone and Android TV, supporting empty vote-room hosting, join by code, and same-process room resume while preserving the existing title-detail and room-authority behavior. + +**Architecture:** Add one narrow shared entry gateway and pure destination/resume policy over the existing `WatchTogetherRepository`; the current phone and TV entry ViewModels remain the platform orchestration points. Phone renders a Material modal sheet and TV renders a focusable popup, both routing into the existing lobby/player and `RoomSession`; no repository, socket, protocol, server endpoint, or persistent room store is added. + +**Tech Stack:** Kotlin 2.1, Kotlin Multiplatform shared module, coroutines and `StateFlow`, Koin, Jetpack Compose Material 3, Compose for TV Material 3, Navigation Compose, JUnit/kotlin-test, Robolectric for phone route tests, Gradle, ADB. + +## Global Constraints + +- Android phone and Android TV only; do not change Prairie Server, Apple clients, or production proxy configuration. +- Add **Watch Together** to the authenticated user/profile menu immediately after **Requests** when Requests is present; otherwise keep it in the same content/action group immediately before the settings/account divider. +- The menu entry is a transient phone sheet or TV popup, not a persistent Watch Together home. +- **Host a room** creates exactly one empty vote room with `selection_mode = "vote"` and must not call `setSelection`. +- **Join by code** must continue to use the existing repository, validation, error mapping, and lobby/player destination rules. +- **Resume current room** is visible only for a valid, non-terminal room in the same running process, server, and authenticated profile; do not add persistence or room discovery. +- The room owner remains a full participant who may suggest, vote, apply the existing host override to any room-owned suggestion, and close the room for everyone. +- Keep current server host semantics: no automatic host transfer, ownership election, original-host reclaim, or timeout changes. +- After host logout, profile/server switch, process death, or unrecovered disconnect, the server may close the room after its existing host-disconnect timeout; the clients do not extend or replace that policy. +- Navigation and backgrounding preserve the live process-scoped room; logout, profile/server switch, explicit Leave, terminal room closure, and process death clear local state through existing ownership boundaries. +- Reuse `WatchTogetherRepository`, `RoomSession`, existing lobby/player routes, websocket/reconnect behavior, voting, auth scope, cleartext consent, and error handling. +- Preserve the existing title-detail Watch Together entry and its preselected-title Host behavior. +- Room credentials remain repository-private and must not enter UI state, route parameters, logs, or tests. +- No changes to `WatchTogetherApi`, Watch Together wire models, websocket frames, or server/proxy configuration. + +--- + +## Current `origin/main` map + +The plan is based on `origin/main` `e0917cbe8cc1b021f184954e1e7cc977c06f628e`. + +| Responsibility | Current file and seam | +|---|---| +| Shared room owner | `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt:71-138,194-378,725-741` | +| Process/session owner | `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomSession.kt:20-104` | +| Identity teardown | `shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt` and `RoomSession` transition gate | +| Shared DI | `shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt:96-123` | +| Phone entry controller | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt:21-125` | +| Phone title-detail sheet | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt:37-138` | +| Phone profile menus | `MainAppTopBar.kt:52-181`, `HomeScreen.kt:84-100,291-305,444-525`, `LibrariesScreen.kt:617-630,1168-1183,1328-1408` | +| Phone shell | `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt:120-451` | +| Phone lobby | `WatchTogetherLobbyScreen.kt:45-203`, `WatchTogetherLobbyViewModel.kt:35-88` | +| TV entry controller | `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt:18-113` | +| TV title-detail dialog | `TvWatchTogetherEntryDialog.kt:31-113` and `TvItemDetailScreen.kt:1048-1104` | +| TV profile menu/shell | `TvMainShell.kt:166-180,607-610,1270-1313,1478-1563` | +| TV root navigation | `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:480-545,630-712,875-900` | +| TV lobby | `TvWatchTogetherLobbyScreen.kt:76-175,250-330,523-558`, `TvWatchTogetherLobbyViewModel.kt:17-83` | +| Existing behavior tests | `WatchTogetherRepositoryTest.kt`, `RoomSessionTest.kt`, `WatchTogetherEntryDestinationTest.kt`, `TvWatchTogetherSurfaceSourceTest.kt`, `TvShellFocusStateTest.kt` | + +## Task 1: Shared entry policy and narrow repository gateway + +**Files:** +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt` +- Create: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt` +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt:71-100,181-284` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt:96-123` + +**Interfaces:** +- Produces: `enum class WatchTogetherEntryTarget { Lobby, Player }` +- Produces: `fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget` +- Produces: `fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot?` +- Produces: `interface WatchTogetherEntryGateway` with `roomSnapshot`, `createRoom`, `joinRoom`, and `setSelection` +- Preserves: the concrete singleton `WatchTogetherRepository`; the gateway is only a testable view of its existing methods. + +- [ ] **Step 1: Write the failing shared policy tests** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchTogetherEntryPolicyTest { + @Test + fun selectedGuestRoutesToPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Guest, + memberCount = 2, + ) + assertEquals(WatchTogetherEntryTarget.Player, watchTogetherEntryTarget(room)) + } + + @Test + fun emptyRoomAndSoloHostRouteToLobby() { + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun onlyNonTerminalNonBlankRoomIsResumable() { + assertNull(resumableWatchTogetherRoom(null)) + assertNull(resumableWatchTogetherRoom(RoomSnapshot(roomId = ""))) + assertNull( + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Ended), + ), + ) + assertEquals( + "room-1", + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby), + )?.roomId, + ) + } +} +``` + +- [ ] **Step 2: Run the policy test and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.watchtogether.WatchTogetherEntryPolicyTest' +``` + +Expected: FAIL to compile because `WatchTogetherEntryTarget`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` do not exist. + +- [ ] **Step 3: Implement the pure policy** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot + +enum class WatchTogetherEntryTarget { + Lobby, + Player, +} + +fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget = + if ( + !room.selectedContentId.isNullOrBlank() && + !(room.selfRole == MemberRole.Host && room.memberCount <= 1) + ) { + WatchTogetherEntryTarget.Player + } else { + WatchTogetherEntryTarget.Lobby + } + +fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot? = + room?.takeIf { it.roomId.isNotBlank() && it.phase != RoomPhase.Ended } +``` + +- [ ] **Step 4: Add the narrow gateway and bind the existing singleton** + +```kotlin +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.flow.StateFlow + +interface WatchTogetherEntryGateway { + val roomSnapshot: StateFlow + suspend fun createRoom(request: CreateRoomRequest): ApiResult + suspend fun joinRoom(request: JoinRoomRequest): ApiResult + suspend fun setSelection(request: SetSelectionRequest): ApiResult +} +``` + +Change the repository declaration and existing members to implement the interface: + +```kotlin +) : RoomSessionRepository, WatchTogetherEntryGateway { + override val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() +} +``` + +Add the `override` modifier to the existing `createRoom`, `joinRoom`, and +`setSelection` declarations. Do not alter any statement inside those three +existing method bodies; verify their body diff is empty. + +Bind the same singleton in `RepositoryModule.kt` immediately after its concrete +registration: + +```kotlin +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway + +single { get() } +``` + +- [ ] **Step 5: Run shared tests and compile both clients** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest \ + :androidApp:compileDebugKotlinAndroid \ + :androidTvApp:compileDebugKotlinAndroid \ + --max-workers=2 +``` + +Expected: PASS; no new network or model source is compiled. + +- [ ] **Step 6: Commit the shared boundary** + +```bash +git add \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +git commit -m "refactor: define Watch Together entry boundary" +``` + +## Task 2: Phone entry controller behavior + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt:21-125` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt:92-112` + +**Interfaces:** +- Consumes: `WatchTogetherEntryGateway`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` +- Produces: `val currentRoom: StateFlow` +- Produces: `fun hostEmptyVoteRoom()` +- Produces: `fun resumeCurrentRoom()` +- Preserves: `fun host(contentId: String, fileId: Int?, selectionMode: RoomSelectionMode)` and `fun joinByCode(code: String)` + +- [ ] **Step 1: Write the phone ViewModel fake and RED tests** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchTogetherEntryViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.hostEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + } + + @Test + fun resumeUsesCurrentRoomWithoutCreateOrJoin() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals("watch_together/room-1", viewModel.uiState.value.destination) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) + } + + @Test + fun identityClearRemovesResumeState() = runTest(dispatcher) { + val gateway = FakeGateway(RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby)) + val viewModel = WatchTogetherEntryViewModel(gateway) + + gateway.roomSnapshot.value = null + + assertNull(viewModel.currentRoom.value) + } + + @Test + fun titleHostStillSetsTheSelectedTitle() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.host(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) + } + + @Test + fun joinByCodeTrimsAndUsesExistingErrorMapping() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = WatchTogetherEntryViewModel(gateway) + + viewModel.joinByCode(" ABCD1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeDestination() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinByCode("EFGH5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) + } + + private class FakeGateway( + room: RoomSnapshot? = null, + ) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + } +} +``` + +- [ ] **Step 2: Run the phone ViewModel test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModelTest' +``` + +Expected: FAIL because the constructor still takes `WatchTogetherRepository`, +and `hostEmptyVoteRoom`, `resumeCurrentRoom`, and `currentRoom` do not exist. + +- [ ] **Step 3: Implement the phone controller additions** + +Change the constructor to `WatchTogetherEntryGateway`, map current-room state +through the shared policy, and add explicit menu actions: + +```kotlin +class WatchTogetherEntryViewModel( + private val gateway: WatchTogetherEntryGateway, +) : ViewModel() { + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) + + fun hostEmptyVoteRoom() { + if (_uiState.value.busy) return + _uiState.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + fun resumeCurrentRoom() { + if (_uiState.value.busy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } +} +``` + +Within existing `host`, delegate vote mode before starting HostPick work: + +```kotlin +if (selectionMode == RoomSelectionMode.Vote) { + hostEmptyVoteRoom() + return +} +``` + +Replace repository calls in this ViewModel with `gateway` calls. Keep +`errorMessage`, the busy guard, one-shot destination consumption, and +title-selection ordering unchanged. Change the title-detail sheet's +`onHostVote` path to `viewModel.hostEmptyVoteRoom()` so it uses the explicit +empty-vote action without dummy content. + +- [ ] **Step 4: Map phone routes through the shared target policy** + +Keep the public phone helper name stable: + +```kotlin +fun watchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Player -> Route.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + ).route + WatchTogetherEntryTarget.Lobby -> + Route.WatchTogetherLobby(roomId = room.roomId).route + } +``` + +- [ ] **Step 5: Run the focused phone entry tests** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryViewModelTest' \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherEntryDestinationTest' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the phone controller** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModel.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntrySheet.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherEntryViewModelTest.kt +git commit -m "feat: add phone Watch Together menu actions" +``` + +## Task 3: Phone transient sheet and all profile-menu rows + +**Files:** +- Create: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt:52-181` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt:84-100,291-305,444-525` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:617-630,1168-1183,1328-1408` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt:120-451` + +**Interfaces:** +- Consumes: `WatchTogetherEntryViewModel.currentRoom`, `hostEmptyVoteRoom`, `resumeCurrentRoom`, and `joinByCode` +- Produces: `@Composable fun WatchTogetherMenuEntrySheet(onNavigate: (String) -> Unit, onDismiss: () -> Unit, viewModel: WatchTogetherEntryViewModel = koinViewModel())` +- Produces: `onWatchTogetherClick: (() -> Unit)?` through each phone chrome/profile-menu signature, supplied only when `CLIENT_WATCH_TOGETHER_SURFACE_ENABLED` is true. + +- [ ] **Step 1: Write the phone source-level RED tests** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherMenuEntrySourceTest { + private fun source(path: String) = File("src/androidMain/kotlin/$path").readText() + + private val topBar = source("org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt") + private val home = source("org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt") + private val libraries = source("org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt") + private val main = source("org/prairieserver/prairie/android/ui/screens/MainScreen.kt") + private val menuSheet = source( + "org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt", + ) + + @Test + fun everyPhoneProfileMenuPlacesWatchTogetherAfterRequestsAndBeforeSettings() { + listOf(topBar, home, libraries).forEach { text -> + val watch = text.indexOf("Text(\"Watch Together\")") + val requests = text.indexOf("Text(\"Requests\")") + val settings = text.indexOf("Text(\"Settings\")") + assertTrue(watch >= 0) + assertTrue(requests < 0 || requests < watch) + assertTrue(watch < settings) + if (requests >= 0) { + assertFalse( + text.substring( + startIndex = requests + "Text(\"Requests\")".length, + endIndex = watch, + ).contains("DropdownMenuItem("), + ) + } + } + } + + @Test + fun mainShellOwnsOneTransientEntrySheet() { + assertTrue(main.contains("var showWatchTogetherEntry by rememberSaveable")) + assertTrue(main.contains("WatchTogetherMenuEntrySheet(")) + assertTrue(main.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + assertTrue(main.contains("onWatchTogetherClick = watchTogetherMenuAction")) + } + + @Test + fun sheetUsesOnlyTheExistingControllerAndNeverHandlesCredentials() { + assertTrue(menuSheet.contains("viewModel.hostEmptyVoteRoom()")) + assertTrue(menuSheet.contains("viewModel.resumeCurrentRoom()")) + assertTrue(menuSheet.contains("viewModel.joinByCode(code)")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(menuSheet.contains(label)) + } + assertFalse(menuSheet.contains("WatchTogetherApi")) + assertFalse(menuSheet.contains("roomAccessToken")) + assertFalse(menuSheet.contains("HttpClient")) + } +} +``` + +- [ ] **Step 2: Run the phone source test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest' +``` + +Expected: FAIL because the menu sheet/file, callbacks, and menu rows do not +exist. + +- [ ] **Step 3: Implement the transient phone sheet** + +The new sheet must collect `uiState` and `currentRoom`, show Resume only when +non-null, and retain the existing join-code rules: + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchTogetherMenuEntrySheet( + onNavigate: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: WatchTogetherEntryViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsState() + val currentRoom by viewModel.currentRoom.collectAsState() + var code by rememberSaveable { mutableStateOf("") } + var showJoin by rememberSaveable { mutableStateOf(false) } + val latestBusy by rememberUpdatedState(state.busy) + + LaunchedEffect(state.destination) { + val destination = state.destination ?: return@LaunchedEffect + viewModel.consumeDestination() + onDismiss() + onNavigate(destination) + } + + ModalBottomSheet( + onDismissRequest = { + if (canDismissRoomEntry(state.busy)) { + viewModel.clearError() + onDismiss() + } + }, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { target -> + target != SheetValue.Hidden || canDismissRoomEntry(latestBusy) + }, + ), + ) { + if (!showJoin) { + if (currentRoom != null) { + Button( + onClick = viewModel::resumeCurrentRoom, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resume current room") + } + } + Button( + onClick = viewModel::hostEmptyVoteRoom, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.busy) "Creating…" else "Host a room") + } + OutlinedButton( + onClick = { + viewModel.clearError() + showJoin = true + }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Join by code") + } + } else { + OutlinedTextField( + value = code, + onValueChange = { code = it.uppercase().filter(Char::isLetterOrDigit).take(8) }, + label = { Text("Invite code") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.joinByCode(code) }, + enabled = !state.busy && code.length >= 4, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (state.busy) "Joining…" else "Join") + } + OutlinedButton( + onClick = { + viewModel.clearError() + showJoin = false + }, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Back") + } + } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } +} +``` + +Render `Text("Watch Together")` with `titleMedium`, `FontWeight.Bold`, and +`Modifier.padding(horizontal = 20.dp, vertical = 12.dp)`, followed by an +`HorizontalDivider`. Wrap the action branch in a full-width `Column` with +`Modifier.padding(20.dp)` and `Arrangement.spacedBy(12.dp)`, and end the sheet +with `Spacer(Modifier.height(24.dp))`. Use an 18.dp +`CircularProgressIndicator` for the busy Join button. Do not import +network/API/token types. + +- [ ] **Step 4: Thread and render the phone menu action** + +Add this exact parameter to `MainAppTopBar`, `HomeScreen`, +`HomeFloatingChrome`, `HomeProfileMenu`, `LibrariesScreen`, +`LibrariesFloatingChrome`, and `ChromeProfileMenu`: + +```kotlin +onWatchTogetherClick: (() -> Unit)?, +``` + +In all three menu implementations, keep the conditional Requests item first, +insert Watch Together immediately after it, and keep one divider after the +content/action group. When Requests is absent, Watch Together is therefore the +last content action before the divider: + +```kotlin +if (onRequestsClick != null) { + DropdownMenuItem( + text = { Text("Requests") }, + onClick = { + menuExpanded = false + onRequestsClick() + }, + ) +} +if (onWatchTogetherClick != null) { + DropdownMenuItem( + text = { Text("Watch Together") }, + onClick = { + menuExpanded = false + onWatchTogetherClick() + }, + ) +} +HorizontalDivider() +``` + +In `MainScreen`, add one saved surface flag and use one callback at all phone +menu call sites: + +```kotlin +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED + +var showWatchTogetherEntry by rememberSaveable { mutableStateOf(false) } +val watchTogetherMenuAction: (() -> Unit)? = + if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { + { showWatchTogetherEntry = true } + } else { + null + } +``` + +Pass: + +```kotlin +onWatchTogetherClick = watchTogetherMenuAction, +``` + +Render beside the existing library and PrairieCast sheets: + +```kotlin +if (showWatchTogetherEntry) { + WatchTogetherMenuEntrySheet( + onNavigate = { route -> + navController.navigate(route) { + launchSingleTop = true + } + }, + onDismiss = { showWatchTogetherEntry = false }, + ) +} +``` + +- [ ] **Step 5: Run phone menu tests and compile** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the phone surface** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySheet.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MainAppTopBar.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt +git commit -m "feat: add Watch Together to phone profile menus" +``` + +## Task 4: Phone browse-versus-leave continuity and owner authority + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt:75-167` + +**Interfaces:** +- Consumes: existing `WatchTogetherLobbyViewModel.leave`, `vote`, `unvote`, `promote`, and `closeRoom` +- Produces: ordinary Back/browse invokes `onBack()` without `leave()` +- Produces: an explicit **Leave room** action invokes `viewModel.leave()` then `onBack()` +- Preserves: host Close, suggestion vote/promote, and title-detail **Suggest to Watch Together**. + +- [ ] **Step 1: Write the phone lobby continuity RED test** + +```kotlin +package org.prairieserver.prairie.android.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt", + ).readText() + + @Test + fun ordinaryBackBrowsesWithoutLeavingAndLeaveIsExplicit() { + val navigationIcon = lobby.substringAfter("navigationIcon = {").substringBefore("actions = {") + assertTrue(navigationIcon.contains("onClick = onBack")) + assertFalse(navigationIcon.contains("viewModel.leave()")) + assertTrue(lobby.contains("Text(\"Leave room\")")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun ownerControlsAndTitleSuggestionRemainReachable() { + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(lobby.contains("viewModel.closeRoom()")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} +``` + +- [ ] **Step 2: Run the continuity test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherLobbyContinuitySourceTest' +``` + +Expected: FAIL because the visible navigation icon currently calls `leave()` +and no explicit Leave row exists. + +- [ ] **Step 3: Separate browse/back from explicit Leave** + +Replace the top app bar navigation behavior with: + +```kotlin +navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to browse", + ) + } +}, +actions = { + TextButton( + onClick = { + viewModel.leave() + onBack() + }, + ) { + Text("Leave room") + } + if (canManage) { + TextButton(onClick = { viewModel.closeRoom() }) { + Text("Close") + } + } +}, +``` + +Do not add any reset to `onCleared` or ordinary `onBack`; `RoomSession` remains +the process owner while the user browses to a detail screen and submits the +existing suggestion action. + +- [ ] **Step 4: Run phone Watch Together regression tests** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit phone continuity** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherLobbyContinuitySourceTest.kt +git commit -m "fix: preserve phone room while browsing" +``` + +## Task 5: TV entry controller and shared destination routing + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt:18-113` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt:1048-1104` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:630-712` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt:31-43` + +**Interfaces:** +- Consumes: `WatchTogetherEntryGateway`, `watchTogetherEntryTarget`, and `resumableWatchTogetherRoom` +- Produces: `val currentRoom: StateFlow` +- Produces: `fun createEmptyVoteRoom()` +- Produces: `fun resumeCurrentRoom()` +- Produces: `fun tvWatchTogetherDestination(room: RoomSnapshot): String` +- Preserves: title-bound `createRoom(contentId, fileId, selectionMode)` and `joinRoom(code)`. + +- [ ] **Step 1: Write the TV RED tests** + +Create `TvWatchTogetherViewModelTest` with an +`UnconfinedTestDispatcher`, `Dispatchers.setMain`/`resetMain`, these tests, and +the complete local fake below: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TvWatchTogetherViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + +@Test +fun emptyHostCreatesVoteRoomWithoutSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createEmptyVoteRoom() + + assertEquals(listOf(CreateRoomRequest(RoomSelectionMode.Vote.wire)), gateway.createRequests) + assertEquals(emptyList(), gateway.selectionRequests) + assertEquals("room-1", viewModel.uiState.value.result?.roomId) +} + +@Test +fun resumeUsesCurrentRoomWithoutNetworkCalls() = runTest(dispatcher) { + val room = RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby) + val gateway = FakeGateway(room) + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.resumeCurrentRoom() + + assertEquals(room, viewModel.uiState.value.result) + assertEquals(emptyList(), gateway.createRequests) + assertEquals(emptyList(), gateway.joinRequests) +} + +@Test +fun joinCodeNormalizesAndKeepsExistingErrorCopy() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.joinRoom(" abcd1234 ") + assertEquals(listOf(JoinRoomRequest(code = "ABCD1234")), gateway.joinRequests) + + viewModel.consumeResult() + gateway.joinResult = ApiResult.NetworkError(IllegalStateException("offline")) + viewModel.joinRoom("efgh5678") + assertEquals("Network error. Check your connection.", viewModel.uiState.value.error) +} + +@Test +fun titleDetailHostStillSetsSelection() = runTest(dispatcher) { + val gateway = FakeGateway() + val viewModel = TvWatchTogetherViewModel(gateway) + + viewModel.createRoom(contentId = "movie-1", fileId = 7) + + assertEquals( + listOf(SetSelectionRequest(contentId = "movie-1", fileId = 7)), + gateway.selectionRequests, + ) +} + +private class FakeGateway( + room: RoomSnapshot? = null, +) : WatchTogetherEntryGateway { + override val roomSnapshot = MutableStateFlow(room) + val createRequests = mutableListOf() + val joinRequests = mutableListOf() + val selectionRequests = mutableListOf() + var joinResult: ApiResult? = null + var nextRoom = RoomSnapshot( + roomId = "room-1", + phase = RoomPhase.Lobby, + selectionMode = RoomSelectionMode.Vote, + ) + + override suspend fun createRoom( + request: CreateRoomRequest, + ): ApiResult { + createRequests += request + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun joinRoom( + request: JoinRoomRequest, + ): ApiResult { + joinRequests += request + joinResult?.let { return it } + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } + + override suspend fun setSelection( + request: SetSelectionRequest, + ): ApiResult { + selectionRequests += request + nextRoom = nextRoom.copy( + selectedContentId = request.contentId, + selectedFileId = request.fileId, + ) + roomSnapshot.value = nextRoom + return ApiResult.Success(RoomResponse(nextRoom, "test-room-token")) + } +} +} +``` + +Add route-policy coverage: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvWatchTogetherDestinationTest { + @Test + fun emptyAndSoloHostRoomsUseLobby() { + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + TvRoute.WatchTogetherLobby("room-1").route, + tvWatchTogetherDestination( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun selectedJoinedRoomUsesSyncedPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selectedFileId = 7, + selfRole = MemberRole.Guest, + memberCount = 2, + anchorPositionSeconds = 12.5, + ) + assertEquals( + TvRoute.Player( + contentId = "movie-1", + fileId = 7, + roomId = "room-1", + resumePositionSeconds = 12.5, + ).route, + tvWatchTogetherDestination(room), + ) + } +} +``` + +- [ ] **Step 2: Run TV controller tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModelTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherDestinationTest' +``` + +Expected: FAIL because the gateway constructor, menu methods, and destination +helper do not exist. + +- [ ] **Step 3: Implement the TV controller additions** + +Mirror the phone controller names through TV's existing naming: + +```kotlin +class TvWatchTogetherViewModel( + private val gateway: WatchTogetherEntryGateway, +) : ViewModel() { + val currentRoom: StateFlow = gateway.roomSnapshot + .map(::resumableWatchTogetherRoom) + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + resumableWatchTogetherRoom(gateway.roomSnapshot.value), + ) + + fun createEmptyVoteRoom() { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(isBusy = true, error = null) } + viewModelScope.launch { + when ( + val created = gateway.createRoom( + CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire), + ) + ) { + is ApiResult.Success -> finish(created.data.room) + is ApiResult.Error, is ApiResult.NetworkError -> + fail(created.errorMessage("Failed to create room")) + } + } + } + + fun resumeCurrentRoom() { + if (_uiState.value.isBusy) return + val room = resumableWatchTogetherRoom(gateway.roomSnapshot.value) + if (room == null) { + fail("Current room is no longer available") + } else { + finish(room) + } + } +} +``` + +Delegate `RoomSelectionMode.Vote` in existing `createRoom` to +`createEmptyVoteRoom`, replace repository calls with gateway calls, and keep +the title HostPick sequence unchanged. + +- [ ] **Step 4: Implement one TV destination helper and use it from detail** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.tv.ui.navigation.TvRoute +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryTarget +import org.prairieserver.prairie.watchtogether.watchTogetherEntryTarget + +fun tvWatchTogetherDestination(room: RoomSnapshot): String = + when (watchTogetherEntryTarget(room)) { + WatchTogetherEntryTarget.Lobby -> + TvRoute.WatchTogetherLobby(room.roomId).route + WatchTogetherEntryTarget.Player -> + TvRoute.Player( + contentId = requireNotNull(room.selectedContentId), + fileId = room.selectedFileId, + roomId = room.roomId, + resumePositionSeconds = room.anchorPositionSeconds + .takeIf { it.isFinite() && it > 0.0 }, + ).route + } +``` + +Replace the inline target calculation in `TvAppNavigation`'s existing detail +callback with: + +```kotlin +onWatchTogether = { snapshot -> + navController.navigate(tvWatchTogetherDestination(snapshot)) +}, +``` + +Change the title-detail vote callback to `createEmptyVoteRoom()`; keep its +normal Host callback title-bound. + +Update `TvWatchTogetherSurfaceSourceTest.aResolvedRoomReachesTheNavigationCallback` +to assert: + +```kotlin +assertTrue(appNavigation.contains("tvWatchTogetherDestination(snapshot)")) +``` + +- [ ] **Step 5: Run TV controller, route, and existing surface tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit TV controller/routing** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestination.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModel.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherViewModelTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherDestinationTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt +git commit -m "feat: add TV Watch Together menu actions" +``` + +## Task 6: TV profile row, focusable popup, and Back restoration + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:166-180,607-610,1270-1313,1478-1563` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:480-545` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt` + +**Interfaces:** +- Produces: `enum class TvWatchTogetherMenuInitialAction { Resume, Host }` +- Produces: `fun tvWatchTogetherMenuInitialAction(canResume: Boolean): TvWatchTogetherMenuInitialAction` +- Produces: `@Composable fun TvWatchTogetherMenuEntryDialog(...)` +- Adds to `TvMainShell`: `onOpenWatchTogether: (RoomSnapshot) -> Unit` +- Consumes: existing `TvJoinCodeDialog`, `TvWatchTogetherViewModel`, `TvProfileDropdown`, and `TvShellFocusState`. + +- [ ] **Step 1: Write TV popup/menu/focus RED tests** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherMenuEntrySourceTest { + private val shell = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt", + ).readText() + private val dialog = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt", + ).readText() + + @Test + fun profileRowIsImmediatelyAfterRequestsAndBeforeSettings() { + val profile = shell.substringAfter("private fun TvProfileDropdown(") + val watch = profile.indexOf("label = \"Watch Together\"") + val requests = profile.indexOf("label = \"Requests\"") + val settings = profile.indexOf("label = \"Settings\"") + assertTrue(watch >= 0) + assertTrue(requests < watch) + assertTrue(watch < settings) + assertFalse( + profile.substring( + startIndex = requests + "label = \"Requests\"".length, + endIndex = watch, + ).contains("ProfileDropdownRow("), + ) + assertTrue(shell.contains("CLIENT_WATCH_TOGETHER_SURFACE_ENABLED")) + } + + @Test + fun popupOwnsFocusAndBackRestoresProfileFocus() { + assertTrue(dialog.contains("PopupProperties(")) + assertTrue(dialog.contains("focusable = true")) + assertTrue(dialog.contains("rememberTvDialogInitialFocus(initialFocus)")) + assertTrue(shell.contains("focusState.closeProfileMenuForContent()")) + assertTrue(shell.contains("focusState.dismissProfileMenu()")) + } + + @Test + fun menuSurfaceUsesExistingControllerAndNoCredentials() { + assertTrue(shell.contains("watchTogetherViewModel.createEmptyVoteRoom()")) + assertTrue(shell.contains("watchTogetherViewModel.resumeCurrentRoom()")) + assertTrue(shell.contains("TvJoinCodeDialog(")) + listOf("Resume current room", "Host a room", "Join by code").forEach { label -> + assertTrue(dialog.contains(label)) + } + assertFalse(dialog.contains("WatchTogetherApi")) + assertFalse(dialog.contains("roomAccessToken")) + assertFalse(dialog.contains("HttpClient")) + } + + @Test + fun initialActionPrefersResumeOnlyWhenAvailable() { + assertEquals( + TvWatchTogetherMenuInitialAction.Resume, + tvWatchTogetherMenuInitialAction(canResume = true), + ) + assertEquals( + TvWatchTogetherMenuInitialAction.Host, + tvWatchTogetherMenuInitialAction(canResume = false), + ) + } +} +``` + +Add a focus-state regression to `TvShellFocusStateTest`: + +```kotlin +@Test +fun closingMenuForPopupThenDismissingPopupRefocusesAvatar() { + val state = TvShellFocusState() + state.previewProfileMenu() + state.enterProfileMenu() + val before = state.profileFocusRequest + + state.closeProfileMenuForContent() + assertEquals(before, state.profileFocusRequest) + + state.dismissProfileMenu() + assertEquals(before + 1, state.profileFocusRequest) +} +``` + +- [ ] **Step 2: Run TV popup tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntrySourceTest' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' +``` + +Expected: FAIL because the dialog, row, callback, and initial-focus policy do +not exist. + +- [ ] **Step 3: Implement the focused TV popup** + +```kotlin +enum class TvWatchTogetherMenuInitialAction { + Resume, + Host, +} + +fun tvWatchTogetherMenuInitialAction( + canResume: Boolean, +): TvWatchTogetherMenuInitialAction = + if (canResume) { + TvWatchTogetherMenuInitialAction.Resume + } else { + TvWatchTogetherMenuInitialAction.Host + } +``` + +The popup uses stable requesters and selects the initial requester from the +pure policy: + +```kotlin +@Composable +fun TvWatchTogetherMenuEntryDialog( + canResume: Boolean, + isBusy: Boolean, + error: String?, + onResume: () -> Unit, + onHost: () -> Unit, + onJoin: () -> Unit, + onDismiss: () -> Unit, +) { + val resumeFocus = remember { FocusRequester() } + val hostFocus = remember { FocusRequester() } + val initialAction = tvWatchTogetherMenuInitialAction(canResume) + val initialFocus = when (initialAction) { + TvWatchTogetherMenuInitialAction.Resume -> resumeFocus + TvWatchTogetherMenuInitialAction.Host -> hostFocus + } + + Popup( + alignment = Alignment.Center, + onDismissRequest = { + if (canDismissRoomEntry(isBusy)) onDismiss() + }, + properties = PopupProperties( + focusable = true, + dismissOnBackPress = canDismissRoomEntry(isBusy), + dismissOnClickOutside = canDismissRoomEntry(isBusy), + clippingEnabled = false, + ), + ) { + Column( + modifier = Modifier + .width(340.dp) + .then(rememberTvDialogInitialFocus(initialFocus)), + ) { + if (canResume) { + TvDialogActionRow( + title = "Resume current room", + enabled = !isBusy, + onClick = onResume, + modifier = Modifier.focusRequester(resumeFocus), + ) + } + TvDialogActionRow( + title = if (isBusy) "Working…" else "Host a room", + enabled = !isBusy, + onClick = onHost, + modifier = Modifier.focusRequester(hostFocus), + ) + TvDialogActionRow( + title = "Join by code", + enabled = !isBusy, + onClick = onJoin, + ) + error?.let { Text(it, color = Color(0xFFEF4444)) } + } + } +} +``` + +Wrap the column in a full-screen centered `Box` padded +`start = 36.dp, top = 50.dp, end = 36.dp, bottom = 42.dp`. Give the 340.dp +column a 14.dp rounded shape, `DarkBackground.copy(alpha = 0.68f)`, a 0.6.dp +`Color.White.copy(alpha = 0.20f)` border, 14.dp horizontal/vertical padding, +and 10.dp item spacing. Render the heading as `"WATCH TOGETHER"` using +`labelMedium`, 16.sp, 1.1.sp letter spacing, bold weight, and white at 0.58 +alpha. + +- [ ] **Step 4: Wire popup state and the profile row into `TvMainShell`** + +Add the callback: + +```kotlin +import org.koin.compose.viewmodel.koinViewModel +import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherMenuEntryDialog +import org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherViewModel + +onOpenWatchTogether: (RoomSnapshot) -> Unit, +``` + +Collect the existing controller and own transient flags in the shell: + +```kotlin +val watchTogetherViewModel = koinViewModel() +val watchTogetherState by watchTogetherViewModel.uiState.collectAsState() +val currentWatchTogetherRoom by watchTogetherViewModel.currentRoom.collectAsState() +var watchTogetherEntryOpen by rememberSaveable { mutableStateOf(false) } +var watchTogetherJoinOpen by rememberSaveable { mutableStateOf(false) } +``` + +Consume one-shot results: + +```kotlin +LaunchedEffect(watchTogetherState.result) { + val room = watchTogetherState.result ?: return@LaunchedEffect + watchTogetherViewModel.consumeResult() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + onOpenWatchTogether(room) +} +``` + +Add `showWatchTogether: Boolean` and `onWatchTogether: () -> Unit` to +`TvProfileDropdown`; pass +`showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED`, and invoke +`focusState.closeProfileMenuForContent()` before opening the popup. Insert: + +```kotlin +onWatchTogether = { + focusState.closeProfileMenuForContent() + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = true +}, + +if (showWatchTogether) { + ProfileDropdownRow( + label = "Watch Together", + icon = Icons.Filled.People, + onClick = onWatchTogether, + ) +} +``` + +Place it immediately after the conditional Requests row. When Requests is +hidden, Watch Together remains after History and before the content/settings +divider. + +Render `TvJoinCodeDialog` or `TvWatchTogetherMenuEntryDialog` after the profile +dropdown: + +```kotlin +if (watchTogetherEntryOpen) { + if (watchTogetherJoinOpen) { + TvJoinCodeDialog( + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onJoin = watchTogetherViewModel::joinRoom, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = false + }, + ) + } else { + TvWatchTogetherMenuEntryDialog( + canResume = currentWatchTogetherRoom != null, + isBusy = watchTogetherState.isBusy, + error = watchTogetherState.error, + onResume = watchTogetherViewModel::resumeCurrentRoom, + onHost = watchTogetherViewModel::createEmptyVoteRoom, + onJoin = { + watchTogetherViewModel.clearError() + watchTogetherJoinOpen = true + }, + onDismiss = { + watchTogetherViewModel.clearError() + watchTogetherEntryOpen = false + watchTogetherJoinOpen = false + focusState.dismissProfileMenu() + }, + ) + } +} +``` + +Switching from entry to join does not restore avatar focus; only dismissal of +the outer entry popup does. + +- [ ] **Step 5: Route shell results through the existing root destinations** + +At the `TvMainShell` call in `TvAppNavigation`, pass: + +```kotlin +onOpenWatchTogether = { room -> + navController.navigate(tvWatchTogetherDestination(room)) { + launchSingleTop = true + } +}, +``` + +Do not add a new `TvRoute`. + +- [ ] **Step 6: Run TV popup, shell focus, and compile gates** + +Run: + +```bash +./gradlew \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleDebug \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the TV surface** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntryDialog.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt +git commit -m "feat: add Watch Together to TV profile menu" +``` + +## Task 7: TV browse-versus-leave continuity and owner authority + +**Files:** +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt:120-175,250-330` + +**Interfaces:** +- Consumes: existing `onBack`, `TvWatchTogetherLobbyViewModel.leave`, `vote`, `unvote`, `promote`, and `closeRoom` +- Produces: D-pad Back and **Browse titles** return without leaving. +- Produces: explicit **Leave room** clears through `RoomSession.depart`. +- Preserves: host Close, host override, suggestion voting, and title-detail suggestion entry. + +- [ ] **Step 1: Write the TV lobby continuity RED test** + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.watchtogether + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvWatchTogetherLobbyContinuitySourceTest { + private val lobby = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt", + ).readText() + private val detail = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun backAndBrowsePreserveRoomWhileLeaveIsExplicit() { + val backHandler = lobby.substringAfter("BackHandler(enabled = true)") + .substringBefore("Box(") + assertTrue(backHandler.contains("onBack()")) + assertFalse(backHandler.contains("viewModel.leave()")) + assertTrue(lobby.contains("title = \"Browse titles\"")) + assertTrue(lobby.contains("title = \"Leave room\"")) + assertTrue(lobby.contains("viewModel.leave()")) + } + + @Test + fun existingOwnerAuthorityAndSuggestionPathRemain() { + assertTrue(lobby.contains("CloseRoomButton(onClick = viewModel::closeRoom)")) + assertTrue(lobby.contains("viewModel.vote(s.id)")) + assertTrue(lobby.contains("viewModel.promote(s.id)")) + assertTrue(detail.contains("Suggest to Watch Together")) + assertTrue(detail.contains("suggestViewModel.suggest(")) + } +} +``` + +- [ ] **Step 2: Run the TV continuity test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.TvWatchTogetherLobbyContinuitySourceTest' +``` + +Expected: FAIL because TV Back currently calls `leave()` and the two explicit +rows do not exist. + +- [ ] **Step 3: Make TV Back browse and add explicit Browse/Leave rows** + +Change ordinary Back: + +```kotlin +BackHandler(enabled = true) { + onBack() +} +``` + +Before host-only policy/close controls, add: + +```kotlin +TvDialogActionRow( + title = "Browse titles", + onClick = onBack, +) +TvDialogActionRow( + title = "Leave room", + onClick = { + viewModel.leave() + onBack() + }, +) +``` + +Keep the terminal `closedReason` effect calling `leave()` and keep the explicit +host `CloseRoomButton`; those are teardown actions, unlike ordinary browse. + +- [ ] **Step 4: Run all TV Watch Together and focus tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' +``` + +Expected: PASS. + +- [ ] **Step 5: Commit TV continuity** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherLobbyContinuitySourceTest.kt +git commit -m "fix: preserve TV room while browsing" +``` + +## Task 8: Preserve owner operations, identity teardown, auth, cleartext, and error boundaries + +**Files:** +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt:40-143,776-866` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt:122-184` +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt` + +**Interfaces:** +- Consumes only existing repository methods: `addSuggestion`, `vote`, `promoteSuggestion`, `closeRoom`, and identity-transition reset. +- Produces test evidence that the top-level entry changes presentation only. +- Must not modify `WatchTogetherApi.kt`, `WatchTogetherModels.kt`, `WatchTogetherRealtimeClient.kt`, `CleartextOriginConsent.kt`, or server/proxy files. + +- [ ] **Step 1: Add a repository authority characterization** + +Extend the existing `FakeApi` with counters for create, suggestion, vote, +promote, and close, then add: + +```kotlin +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode + +var createCalls = 0 +var addSuggestionCalls = 0 +var voteCalls = 0 +var promoteCalls = 0 +var closeCalls = 0 + +override suspend fun createRoom( + request: CreateRoomRequest, + scope: AuthScopeSnapshot, +): ApiResult { + createCalls++ + lastAuthScope = scope + return createResult?.await() ?: createResponse +} + +override suspend fun addSuggestion( + roomId: String, + roomToken: String, + request: AddSuggestionRequest, + scope: AuthScopeSnapshot, +): ApiResult { + addSuggestionCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) +} + +override suspend fun vote( + roomId: String, + roomToken: String, + suggestionId: String, + scope: AuthScopeSnapshot, +): ApiResult { + voteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) +} + +override suspend fun promoteSuggestion( + roomId: String, + roomToken: String, + request: PromoteSuggestionRequest, + scope: AuthScopeSnapshot, +): ApiResult { + promoteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return createResponse +} + +override suspend fun closeRoom( + roomId: String, + roomToken: String, + scope: AuthScopeSnapshot, +): ApiResult { + closeCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(Unit) +} + +@Test +fun `empty vote room owner keeps existing suggestion vote override and close authority`() = runTest { + val api = FakeApi( + createResponse = ApiResult.Success( + RoomResponse( + room = RoomSnapshot( + roomId = "room-1", + selectionMode = RoomSelectionMode.Vote, + selfRole = MemberRole.Host, + selfCanManageRoom = true, + ), + roomAccessToken = "room-token", + ), + ), + ) + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scopeA }, + ) + + repository.createRoom(CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)) + repository.addSuggestion( + AddSuggestionRequest( + contentId = "movie-1", + contentType = "movie", + title = "Movie One", + ), + ) + repository.vote("suggestion-1") + repository.promoteSuggestion(PromoteSuggestionRequest("suggestion-1")) + repository.closeRoom() + + assertEquals(1, api.createCalls) + assertEquals(1, api.addSuggestionCalls) + assertEquals(1, api.voteCalls) + assertEquals(1, api.promoteCalls) + assertEquals(1, api.closeCalls) + assertEquals("room-token", api.lastRoomToken) +} +``` + +This is characterization of current authority; it should pass without +production changes. + +- [ ] **Step 2: Add an explicit same-profile/session lifecycle assertion** + +Add to `RoomSessionTest`: + +```kotlin +@Test +fun `room remains adopted until explicit leave or identity transition`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + + session.adopt("room-a").join() + runCurrent() + assertTrue(session.isActive()) + assertEquals(0, repository.resetCount) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } +} +``` + +Add a dedicated logout assertion so the profile-switch example is not treated +as sufficient coverage for sign-out: + +```kotlin +@Test +fun `sign out clears the adopted room before identity mutation`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.adopt("room-a").join() + runCurrent() + + barrier.changing(IdentityTransitionKind.SIGN_OUT) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } +} +``` + +Retain the existing all-transition-kinds regression so `SERVER_SWITCH`, +`PROFILE_SWITCH`, and future identity transition kinds remain covered as well. + +- [ ] **Step 3: Strengthen source boundary assertions** + +In both menu source tests, assert that the new UI files do not contain: + +```kotlin +assertFalse(source.contains("room_token")) +assertFalse(source.contains("roomAccessToken")) +assertFalse(source.contains("Authorization")) +assertFalse(source.contains("CleartextOriginConsent")) +``` + +Also assert error presentation still comes from each existing ViewModel state: + +```kotlin +assertTrue(source.contains("state.error")) +``` + +- [ ] **Step 4: Run authority, lifecycle, cleartext, and entry error suites** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.WatchTogetherRepositoryTest' \ + --tests 'org.prairieserver.prairie.watchtogether.RoomSessionTest' +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.auth.ServerSetupCleartextWarningTest' \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.auth.TvServerSetupCleartextWarningTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --max-workers=2 +``` + +Expected: PASS. + +- [ ] **Step 5: Verify no protocol, cleartext-policy, or server files changed** + +Run: + +```bash +test -z "$(git diff --name-only origin/main...HEAD -- \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/model/watchtogether/WatchTogetherModels.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/network/CleartextOriginConsent.kt)" +``` + +Expected: exit 0 and no output. + +- [ ] **Step 6: Commit the preservation tests** + +```bash +git add \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/watchtogether/WatchTogetherMenuEntrySourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvWatchTogetherMenuEntrySourceTest.kt +git commit -m "test: lock Watch Together menu boundaries" +``` + +## Task 9: Full verification, two-client device smoke, and review gate + +**Files:** +- Verify only; do not create a tracked evidence file containing device IDs, + invite codes, tokens, URLs, or logs. + +**Interfaces:** +- Consumes the complete feature from Tasks 1-8. +- Produces fresh build/test/device evidence for review. + +- [ ] **Step 1: Run formatting/diff and supply-chain policy checks** + +Run: + +```bash +git diff --check origin/main...HEAD +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Run focused tests once more** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.watchtogether.WatchTogetherEntryPolicyTest' \ + --tests 'org.prairieserver.prairie.repository.WatchTogetherRepositoryTest' \ + --tests 'org.prairieserver.prairie.watchtogether.RoomSessionTest' +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.watchtogether.*' \ + --max-workers=2 +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.watchtogether.*' \ + --tests 'org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest' \ + --max-workers=2 +``` + +Expected: PASS with zero failed tests. + +- [ ] **Step 3: Run the full unit and debug build gate** + +Run: + +```bash +./gradlew \ + test \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Run minified release assembly for both clients** + +Use the repository-supported local signing path only: + +```bash +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`. Do not expose keystore paths, passwords, or +certificate material. + +- [ ] **Step 5: Start or select only dedicated test emulators** + +Prefer the existing `Silo_Phone` and `Silo_TV` AVDs and fixed serials: + +```bash +"${ANDROID_HOME}/emulator/emulator" -avd Silo_TV -port 5554 -no-snapshot-save & +"${ANDROID_HOME}/emulator/emulator" -avd Silo_Phone -port 5556 -no-snapshot-save & +adb -s emulator-5554 wait-for-device +adb -s emulator-5556 wait-for-device +adb -s emulator-5554 shell getprop ro.build.characteristics +adb -s emulator-5556 shell getprop ro.build.characteristics +``` + +Expected: `emulator-5554` reports TV characteristics and `emulator-5556` +reports a phone profile. If either serial belongs to another running device, +stop and choose unused even-numbered emulator ports; never issue an unscoped +`adb install`, `adb shell`, clear-data, or uninstall command. + +- [ ] **Step 6: Install debug APKs only on the matching dedicated emulators** + +Run: + +```bash +adb -s emulator-5556 install -r \ + androidApp/build/outputs/apk/debug/androidApp-arm64-v8a-debug.apk +adb -s emulator-5554 install -r \ + androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk +adb -s emulator-5556 shell am start \ + -n org.prairieserver.prairie/.android.MainActivity +adb -s emulator-5554 shell am start \ + -n org.prairieserver.prairie/.tv.MainTvActivity +``` + +If the output APK names differ, resolve only within each module's +`build/outputs/apk/debug/` directory with `find`, verify ABI using `apkanalyzer`, +then repeat the serial-scoped install. + +- [ ] **Step 7: Execute the phone touch smoke matrix** + +On the authenticated phone test profile: + +1. Open profile menus from Home, Libraries, and For You; verify Requests appears + immediately before Watch Together when enabled, and Watch Together remains + immediately before the settings/account divider when Requests is disabled. +2. Open the sheet; with no room verify Host receives the primary position, + Join opens code input, Back returns to entry, and dismissal returns to the + same tab. +3. Host a room; verify the lobby shows vote mode and no selected title. +4. Use Back to browse without leaving, open a movie/episode detail, and verify + **Suggest to Watch Together** is present and can submit a suggestion. +5. Reopen the profile menu; verify **Resume current room** appears and returns + to the same room. +6. Verify the owner can vote, use the existing host override, and close the + room for everyone. +7. Verify explicit **Leave room** removes Resume. +8. Recreate a room, background/foreground the app, and verify Resume remains. +9. Switch profile or log out on the dedicated test account and verify Resume is + absent after returning to an authenticated shell. +10. Recreate a room, run + `adb -s emulator-5556 shell am force-stop org.prairieserver.prairie`, relaunch the + phone activity, and verify Resume is absent while persisted login/profile + data remains intact. +11. Enter an invalid join code and verify the existing inline error appears + without navigation or crash. + +- [ ] **Step 8: Execute the TV D-pad and cross-client smoke matrix** + +On the authenticated TV test profile: + +1. Open the profile dropdown; verify Watch Together follows History and + precedes Requests/Settings. +2. With no room, select the row and verify Host receives initial focus. Press + Back and verify focus returns to the profile avatar without entering content + behind the popup. +3. Host an empty room and join it from the phone by code. +4. Use TV Back or **Browse titles**; verify the room remains active. Open a + title detail and submit the existing **Suggest to Watch Together** action. +5. Reopen the TV profile popup; verify Resume is first and initially focused. +6. Resume the lobby; vote from both participants, verify the TV owner can + override any room-owned suggestion, then close the room and verify both + clients receive closure. +7. Recreate a room, background and foreground TV with Home/Recent, and verify + Resume persists. +8. Use explicit Leave and verify Resume disappears. +9. Verify Join by code handles D-pad entry, Back-to-entry focus, invalid-code + errors, and selected-room routing to the synchronized player. +10. Confirm the title-detail Watch Together action still hosts with that title + preselected rather than creating an empty room. +11. Recreate a room, run + `adb -s emulator-5554 shell am force-stop org.prairieserver.prairie`, relaunch the + TV activity, and verify the process-scoped Resume action is absent. + +Do not alter production proxy settings or server host-timeout configuration. + +- [ ] **Step 9: Capture non-secret crash/ANR evidence** + +Run immediately after the smoke matrix: + +```bash +adb -s emulator-5556 shell pidof org.prairieserver.prairie +adb -s emulator-5554 shell pidof org.prairieserver.prairie +adb -s emulator-5556 logcat -d -t 1500 | + rg -i 'FATAL EXCEPTION|ANR in org\.siloserver\.prairie|am_crash.*org\.siloserver\.prairie' || true +adb -s emulator-5554 logcat -d -t 1500 | + rg -i 'FATAL EXCEPTION|ANR in org\.siloserver\.prairie|am_crash.*org\.siloserver\.prairie' || true +``` + +Expected: both PIDs exist and neither filtered log contains an app crash or +ANR. Do not save raw logcat if it includes URLs, invite codes, or tokens. + +- [ ] **Step 10: Request independent code and security review** + +Ask the reviewer to inspect: + +- shared gateway/policy as a view over the existing singleton, not a second + owner; +- empty vote-room creation for exactly one create and zero `setSelection`; +- identity and explicit-leave teardown; +- phone/TV route parity; +- TV initial focus and Back restoration; +- title-detail preselection regression; +- absence of credentials, direct API calls, server/protocol changes, and + cleartext bypasses. + +Address findings test-first, rerun the smallest affected focused test, then +rerun Steps 1-4. + +- [ ] **Step 11: Verify final branch state** + +Run: + +```bash +git status --short +git log --oneline --decorate origin/main..HEAD +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Expected: clean worktree, the spec/plan plus small feature commits, and no +uncommitted or generated files. diff --git a/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md b/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md new file mode 100644 index 000000000..da13f3eb0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-final-ledger-fixes.md @@ -0,0 +1,221 @@ +# Android Final Ledger Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix every current defect validated from the independent 2026-07-28 Android end-to-end ledger before publishing release candidates. + +**Architecture:** Watch Together reconciliation remains centralized in its existing repository and lobby surfaces. Audiobook teardown reuses the application-owned playback lifecycle for asynchronous externally-owned session finalization. The three remaining isolated fixes stay at their current subtitle, authentication, and download ownership boundaries. + +**Tech Stack:** Kotlin, coroutines/Flow, Ktor, Jetpack Compose, Media3, Room, WorkManager, Kotlin test/coroutines-test, Gradle. + +## Global Constraints + +- Android phone and TV only; no server/API/schema/protocol changes. +- Preserve all already-reviewed Watch Together generation, lease, reconnect, and leave semantics. +- No `runBlocking` on UI/ViewModel teardown, arbitrary sleeps, timeout widening, or fire-and-forget global scopes. +- Every behavioral fix starts with a deterministic RED regression. +- Physical devices remain excluded. +- Delete only the verified-unused no-op auth stub; do not alter the installed real auth plugin. + +--- + +### Task 1: Reconcile Watch Together Suggestions and Surface Lobby Errors + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherRepository.kt` +- Modify: phone and TV Watch Together lobby ViewModels/screens as required by their existing error-effect architecture. +- Test: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherRepositoryTest.kt` +- Test: phone and TV lobby unit/source tests. + +**Interfaces:** +- Consumes: existing `Opened`, REST `listSuggestions`, repository `errors`, lobby message/snackbar effects. +- Produces: reconnect hydration, authoritative `votedIds`, and visible transient lobby failures on both clients. + +- [ ] **Step 1: Write reconnect hydration RED** + +Start a socket, publish suggestions, terminate transport, mutate the fake REST +suggestion list, connect the successor socket, emit `Opened`, and assert REST +is called again and the missed mutation is published. Verify a protocol +`Closed` remains terminal and does not refresh/reconnect. + +- [ ] **Step 2: Write authoritative unvote RED** + +Apply a REST list with `votedByMe=true`, then refresh with the same suggestion +set to false. Assert the repository's local vote set removes that ID while +preserving votes still reported true. + +- [ ] **Step 3: Implement repository reconciliation** + +On each healthy `Opened`, refresh suggestions through the existing REST API +without creating a second repository/socket architecture. Replace the +authoritative vote set from REST responses rather than only OR-ing true IDs; +retain optimistic mutation behavior between authoritative refreshes. + +- [ ] **Step 4: Write lobby error RED** + +Make vote/promote/suggest fail in each lobby ViewModel and assert one visible +message effect. Also assert repository transient socket errors are visible +while the lobby is active. + +- [ ] **Step 5: Implement phone/TV error parity** + +Collect the existing repository error flow in the existing lobby lifecycle, +and translate rejected operations through the same one-shot UI message path. +Do not add replay that can show stale errors after navigation. + +- [ ] **Step 6: Run focused GREEN and commit** + +Run repository and both lobby test suites, compile phone/TV, then commit: + +```bash +git commit -m "fix(watch-together): reconcile rooms and surface lobby errors" +``` + +### Task 2: Make Audiobook Teardown Non-Blocking + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AudiobookPlayerViewModel.kt` +- Modify: phone and TV DI factories for the shared audiobook ViewModel. +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt` +- Test: a source-contract test for audiobook teardown. + +**Interfaces:** +- Consumes: the lifecycle's existing application scope, `NonCancellable + Dispatchers.IO`, and `PlaybackSessionManager.reportProgress/stopSession`. +- Produces: `reportAndStopExternalSessionAsync(sessionId, positionSeconds, isPaused)` for an externally-owned audiobook session. + +- [ ] **Step 1: Write asynchronous finalization RED** + +Use a fake manager whose progress call suspends. Invoke the new external +finalizer, assert the caller returns before the fake is released, then assert +the exact session/position/pause report precedes stop. + +- [ ] **Step 2: Write audiobook source-contract RED** + +Assert `AudiobookPlayerViewModel.onCleared` contains no `runBlocking` and +submits a synchronously captured active session/position/pause snapshot to the +external finalizer. + +- [ ] **Step 3: Implement the smallest lifecycle reuse** + +Add a coalesced application-scope async operation for the explicit external +session ID. Do not adopt the audiobook session into the video lifecycle and do +not derive the target from lifecycle-owned state. Inject the lifecycle through +both clients' existing factories. + +- [ ] **Step 4: Run focused GREEN and commit** + +Run lifecycle/audiobook tests and phone/TV compilation, then commit: + +```bash +git commit -m "fix(android): finalize audiobook sessions asynchronously" +``` + +### Task 3: Correct TV Top Subtitle Title-Safe Compensation + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt` + +**Interfaces:** +- Consumes: subtitle vertical preset and `titleSafeFraction`. +- Produces: unchanged Top base padding; compensated Bottom/LowerThird padding. + +- [ ] **Step 1: Write RED** + +With `titleSafeFraction = 0.05f`, assert Top remains its base `0.74f` padding +while Bottom and LowerThird retain their existing compensation. + +- [ ] **Step 2: Implement GREEN** + +Skip bottom-padding subtraction only for the Top preset; preserve every other +preset and the outer title-safe surface inset. + +- [ ] **Step 3: Verify and commit** + +Run the full subtitle appearance class and compile both clients: + +```bash +git commit -m "fix(tv): avoid double-shifting top subtitles" +``` + +### Task 4: Remove the Unused No-Op Auth Plugin + +**Files:** +- Delete: `shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt` + +**Interfaces:** +- Consumes: repository-wide proof that only `PrairieAuthPlugin` is installed. +- Produces: no public no-op authentication symbol that can be installed accidentally. + +- [ ] **Step 1: Prove non-use** + +Search production/tests/build publication metadata for `PrairieAuth`; require the +declaration to be the only match and the real `PrairieAuthPlugin` installation to +remain in `PrairieHttpClientImpl`. + +- [ ] **Step 2: Delete and compile** + +Delete only the stub, compile shared/phone/TV, and commit: + +```bash +git commit -m "chore(shared): remove unused no-op auth plugin" +``` + +### Task 5: Clear Permanent Download Failure Progress + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt` +- Test: the existing DownloadWorker/sidecar status test suite. + +**Interfaces:** +- Consumes: existing live-record and sidecar status update helpers. +- Produces: nullable “preserve” byte arguments, with explicit zero clearing both stored representations on permanent failure. + +- [ ] **Step 1: Write RED** + +Seed a download and sidecar with nonzero bytes, invoke the permanent-failure +transition, and assert `Failed`, `bytesSent == 0`, and `fileSize == 0` in both. + +- [ ] **Step 2: Implement GREEN** + +Distinguish omitted/preserve values from explicit zero using nullable +arguments or an equally explicit update type. Preserve existing callers that +intend to retain progress. + +- [ ] **Step 3: Verify and commit** + +Run the focused worker/sidecar tests and compile phone/TV: + +```bash +git commit -m "fix(android): clear progress on permanent download failure" +``` + +### Task 6: Final Integrated Release Qualification + +**Files:** +- Verify only: `origin/main...HEAD`. + +**Interfaces:** +- Consumes: Tasks 1–5 with independent approval. +- Produces: clean reviewed branch, green supply-chain/unit/release gates, final universal APKs, and updated PR #126. + +- [ ] **Step 1: Independently review every task and fix every finding** + +- [ ] **Step 2: Run supply-chain and exact combined phone/TV unit/release gate** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +- [ ] **Step 3: Require a final whole-branch clean review** + +- [ ] **Step 4: Verify and copy final universal APKs, then push/update PR #126 without merging** diff --git a/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md b/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md new file mode 100644 index 000000000..cfbac1e91 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-phone-library-chrome-inset.md @@ -0,0 +1,350 @@ +# Android Phone Library Chrome Inset Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep Android phone library content below the fixed library selector and subtab chrome, matching the iOS `safeAreaInset` behavior. + +**Architecture:** Preserve the full-screen backdrop in the existing root `Box`, but replace the content-first/floating-chrome overlay with a foreground `Column`: measured chrome first, then a clipped `Box` with `weight(1f)` for every library state and subtab. Remove the old per-tab status-bar/chrome runways so system insets are consumed once by the shared chrome. + +**Tech Stack:** Kotlin, Jetpack Compose, Material 3, Kotlin/JVM unit tests, Gradle. + +## Global Constraints + +- Android phone only; do not alter Android TV, server APIs, Apple code, or standalone Browse / Collections routes. +- Keep the full-screen Recommended hero backdrop; only interactive/editorial scroll content is confined below the chrome. +- Preserve library/profile menu actions, tab state, scroll state, filters, pagination, grids, hero selection, and bottom-chrome padding. +- The shared library chrome consumes status-bar/display-cutout top inset exactly once. +- Do not target physical devices; emulator validation may use only the dedicated phone emulator when available. + +--- + +### Task 1: Reserve a measured viewport below the shared library chrome + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:610-1110` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt:83-125` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt` + +**Interfaces:** +- Consumes: existing `LibrariesFloatingChrome`, `RecommendedTabContent`, `BrowseTabContent`, `CollectionsTabContent`, `FeaturedCarousel`, and `LocalBottomChromeInset`. +- Produces: one measured `Column` boundary in `LibrariesScreen`; a `FeaturedCarousel(topInset: Dp = 16.dp)` parameter that adds only content-local breathing room. + +- [ ] **Step 1: Write the failing structural regression tests** + +Create `LibraryChromeInsetSourceTest.kt` with repository-standard source loading: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.libraries + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LibraryChromeInsetSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private val libraries = source( + "org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt", + ) + private val carousel = source( + "org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt", + ) + + @Test + fun sharedChromeOwnsReservedSpaceBeforeEveryLibraryTab() { + val chrome = libraries.indexOf("LibrariesFloatingChrome(") + val viewport = libraries.indexOf("LibraryContentViewport(") + assertTrue(chrome >= 0) + assertTrue(viewport > chrome) + assertTrue(libraries.contains("Modifier.weight(1f).clipToBounds()")) + } + + @Test + fun tabsDoNotCarryOverlayClearanceRunways() { + assertFalse(libraries.contains("LibrariesChromeContentHeight")) + assertFalse(libraries.contains("extraTopInset = 50.dp")) + assertFalse(libraries.contains(".windowInsetsPadding(WindowInsets.statusBars)")) + assertFalse(carousel.contains("WindowInsets.statusBars")) + assertTrue(carousel.contains("topInset: androidx.compose.ui.unit.Dp = 16.dp")) + } +} +``` + +- [ ] **Step 2: Run the source test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: FAIL because `LibraryContentViewport`, the reserved weighted/clipped viewport, and the simplified carousel inset do not exist; overlay-clearance constants remain. + +- [ ] **Step 3: Put the chrome before a shared clipped viewport** + +In `LibrariesScreen`, retain backdrop drawing in the root `Box`, then render: + +```kotlin +Column(modifier = Modifier.fillMaxSize()) { + LibrariesFloatingChrome( + scrimProgress = chromeScrimProgress, + selectedLibrary = selectedLibrary, + canSwitch = state.libraries.size > 1, + activeProfile = activeProfile, + selectedTab = state.selectedTab, + onLibrarySelectorClick = onLibrarySelectorClick, + onTabSelected = viewModel::selectTab, + onSearchClick = onSearchClick, + onRequestsClick = onRequestsClick, + onWatchTogetherClick = onWatchTogetherClick, + onSettingsClick = onSettingsClick, + onSwitchProfileClick = onSwitchProfileClick, + onSwitchServerClick = onSwitchServerClick, + onSignOutClick = onSignOutClick, + ) + + LibraryContentViewport( + modifier = Modifier.weight(1f).clipToBounds(), + ) { + when { + state.isLoadingLibraries && state.libraries.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + state.librariesError != null && state.libraries.isEmpty() -> { + ErrorView( + message = state.librariesError ?: "Failed to load libraries", + onRetry = viewModel::refresh, + modifier = Modifier.fillMaxSize(), + ) + } + selectedLibrary == null -> { + EmptyStateView( + title = "No libraries available", + subtitle = "Libraries visible to this profile will show up here", + icon = Icons.Default.VideoLibrary, + modifier = Modifier.fillMaxSize(), + ) + } + state.selectedTab == LibrariesSubtab.Recommended -> { + RecommendedTabContent( + state = state, + listState = recommendedListState, + onItemClick = onItemClick, + onPlayClick = onPlayClick, + onRetry = viewModel::retryCurrentTab, + onActiveBackdropChange = { url, thumbhash -> + heroBackdropUrl = url + heroBackdropThumbhash = thumbhash + }, + ) + } + state.selectedTab == LibrariesSubtab.Browse -> { + BrowseTabContent( + state = state, + onItemClick = onItemClick, + onRetry = viewModel::retryCurrentTab, + onLoadMore = viewModel::loadMoreCatalog, + onSortChanged = viewModel::selectBrowseSort, + onNamePrefixChanged = viewModel::selectNamePrefix, + onDensityChanged = viewModel::selectViewDensity, + onApplyFilter = viewModel::applyFilterState, + onSetPreserve = viewModel::setPreserveFilters, + ) + } + else -> { + CollectionsTabContent( + state = state, + onCollectionClick = { collectionId -> + state.selectedLibraryId?.let { libraryId -> + onCollectionClick(collectionId, libraryId) + } + }, + onRetry = viewModel::retryCurrentTab, + ) + } + } + } +} +``` + +Add the focused wrapper next to `LibrariesScreen`: + +```kotlin +@Composable +private fun LibraryContentViewport( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth(), + content = content, + ) +} +``` + +Import `androidx.compose.ui.draw.clipToBounds`. + +- [ ] **Step 4: Remove duplicate top-clearance padding** + +Delete `LibrariesChromeContentHeight`. In Recommended loading/error/empty +states, remove `.padding(top = LibrariesChromeContentHeight)` and +`.windowInsetsPadding(WindowInsets.statusBars)`. Remove the `no-featured` +status-bar/chrome spacer and replace it with: + +```kotlin +item(key = "no-featured") { + Spacer(modifier = Modifier.height(16.dp)) +} +``` + +In Browse, change the outer modifier to: + +```kotlin +modifier = Modifier.fillMaxSize() +``` + +In Collections, remove `contentTopPadding` and every +`.padding(top = contentTopPadding)` while retaining existing grid/content +padding and `LocalBottomChromeInset`. + +- [ ] **Step 5: Simplify the carousel's top inset** + +In `FeaturedCarousel`, replace `extraTopInset` and the system-inset calculation: + +```kotlin +topInset: androidx.compose.ui.unit.Dp = 16.dp, +``` + +and: + +```kotlin +Spacer(modifier = Modifier.height(topInset)) +``` + +Remove the unused `WindowInsets`, `asPaddingValues`, and +`calculateTopPadding` imports. The only caller uses the 16dp default. + +- [ ] **Step 6: Run the focused test and existing phone metadata/menu regressions** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --tests org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest \ + --tests org.prairieserver.prairie.android.ui.screens.watchtogether.WatchTogetherMenuEntrySourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: BUILD SUCCESSFUL; all selected tests pass. + +- [ ] **Step 7: Inspect the scoped diff and commit** + +Run: + +```bash +git diff --check +git diff -- \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +``` + +Commit: + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt +git commit -m "fix(android): keep library content below chrome" +``` + +### Task 2: Verify the integrated branch and publish tester artifacts + +**Files:** +- Verify only: all files changed by `origin/main...HEAD` + +**Interfaces:** +- Consumes: Task 1's fixed library viewport plus existing TV focus, editorial hero, and identity-scoped request fixes. +- Produces: independently reviewed branch, green phone/TV gates, signed universal tester APKs, and updated PR #126. + +- [ ] **Step 1: Run supply-chain policy** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: self-tests pass and both commands exit 0. + +- [ ] **Step 2: Run the full phone/TV unit and release gate** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: BUILD SUCCESSFUL; no unit-test failures; both universal release APKs exist. + +- [ ] **Step 3: Perform emulator-only visual validation when available** + +Use only a dedicated phone emulator selected explicitly through the local test +harness. Install the debug build serial-specifically, launch the +phone app, and verify Recommended, Browse, and Collections content stops at the +fixed chrome boundary before and after scrolling. Open/close the profile menu +and verify the list position does not change. If the dedicated emulator is not +online, record the limitation; do not touch a physical device. + +- [ ] **Step 4: Obtain independent whole-branch review** + +Provide the reviewer `origin/main...HEAD`, the approved specs, focused/full test +results, and emulator evidence or limitation. Require explicit approval or fix +each verified finding test-first before publication. + +- [ ] **Step 5: Verify and copy signed universal APKs** + +Verify package/version, universal ABIs, v2 signature, size, and SHA-256 for: + +```text +androidApp/build/outputs/apk/release/androidApp-universal-release.apk +androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk +``` + +Copy them without overwriting existing files into the tester-selected release +artifact destination outside the repository, using filenames containing +`0.3.11`, `FocusHeroLibraryInset`, and the final short commit. + +- [ ] **Step 6: Final hygiene and PR update** + +Run: + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Push `fix/tv-for-you-cold-navigation`, update PR #126 with the final test, +review, emulator, and artifact evidence, and leave it open and unmerged. diff --git a/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md b/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md new file mode 100644 index 000000000..7ac489f38 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-player-system-brightness.md @@ -0,0 +1,188 @@ +# Android Player System Brightness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the Android phone player's window-brightness override while preserving every other player gesture and keep-screen-awake behavior. + +**Architecture:** Extract the vertical-drag start-zone decision into a small pure classifier used by `PlayerGestureHandler`. The classifier maps the left edge to no action, the right edge to volume, and the center to dismissal; the obsolete brightness mode and window mutation are then removed. + +**Tech Stack:** Kotlin, Jetpack Compose pointer input, Android `AudioManager`, Kotlin/JUnit unit tests, Gradle. + +## Global Constraints + +- Android phone only; do not change Android TV. +- Never write `WindowManager.LayoutParams.screenBrightness`. +- Preserve right-edge volume, center swipe-down dismissal, double-tap seeking, pinch aspect changes, control toggling, temporary fast-forward, and `FLAG_KEEP_SCREEN_ON`. +- A left-edge vertical drag is a no-op and must not become a dismiss candidate. +- Do not add permissions or mutate Android system brightness settings. +- Do not install or modify the Shield. + +--- + +### Task 1: Remove mobile window-brightness ownership + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt` + +**Interfaces:** +- Produces: `internal enum class VerticalDragMode { None, Volume, DismissCandidate }`. +- Produces: `internal fun verticalDragMode(startX: Float, width: Float, edgeZonePx: Float): VerticalDragMode`. +- Preserves: `adjustVolume(AudioManager, Float)` and every public `PlayerGestureHandler` parameter. + +- [ ] **Step 1: Write the failing classifier tests** + +```kotlin +class PlayerVerticalDragModeTest { + @Test + fun `left edge leaves system brightness authoritative`() { + assertEquals( + VerticalDragMode.None, + verticalDragMode(startX = 40f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `right edge retains volume routing`() { + assertEquals( + VerticalDragMode.Volume, + verticalDragMode(startX = 950f, width = 1_000f, edgeZonePx = 88f), + ) + } + + @Test + fun `center retains dismiss routing`() { + assertEquals( + VerticalDragMode.DismissCandidate, + verticalDragMode(startX = 500f, width = 1_000f, edgeZonePx = 88f), + ) + } +} +``` + +- [ ] **Step 2: Run RED** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*PlayerVerticalDragModeTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: test compilation fails because the production classifier and visible +mode contract do not exist yet. + +- [ ] **Step 3: Implement the minimal routing change** + +In `PlayerGestureHandler.kt`, add: + +```kotlin +internal enum class VerticalDragMode { None, Volume, DismissCandidate } + +internal fun verticalDragMode( + startX: Float, + width: Float, + edgeZonePx: Float, +): VerticalDragMode = when { + startX < edgeZonePx -> VerticalDragMode.None + startX > width - edgeZonePx -> VerticalDragMode.Volume + else -> VerticalDragMode.DismissCandidate +} +``` + +Use this function from `onDragStart`. Remove `VerticalDragMode.Brightness`, +`adjustBrightness`, and the unused `Window`/`WindowManager` imports. Keep +`LocalContext` because `AudioManager` still needs it. Update the gesture +documentation to say the left edge is reserved and does not alter brightness. + +- [ ] **Step 4: Run GREEN** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*PlayerVerticalDragModeTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all three routing tests pass. + +- [ ] **Step 5: Run focused player regressions** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*Player*Gesture*' \ + --tests '*PlayerPinchGravity*' \ + --tests '*MobilePlayerLifecycle*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Build the phone release** + +```bash +./gradlew :androidApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` and release APK outputs exist. + +- [ ] **Step 7: Verify the final diff and commit** + +```bash +git diff --check +git diff --stat +git status --short +``` + +Confirm that no Android TV or system-settings code changed, then commit: + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerGestureHandler.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerVerticalDragModeTest.kt +git commit -m "fix(player): leave phone brightness system-managed" +``` + +### Task 2: Pixel validation and PR update + +**Files:** +- Modify only if a confirmed regression requires a test-first correction: Task 1 files. +- Update: PR #127 description/check evidence without merging. + +**Interfaces:** +- Consumes: the Task 1 release APK. +- Produces: Pixel evidence that system brightness remains authoritative and preserved gestures still operate. + +- [ ] **Step 1: Verify safe Pixel upgrade compatibility** + +On serial `58211FDCQ000CU`, compare candidate and installed package, version, +and signing certificate. Abort without uninstall, clear-data, or downgrade if +they differ incompatibly. + +- [ ] **Step 2: Install and launch on the Pixel** + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-arm64-v8a-release.apk +adb -s 58211FDCQ000CU shell am start \ + -n org.prairieserver.prairie/.android.MainActivity +``` + +- [ ] **Step 3: Validate behavior** + +During video playback, verify: + +1. Android's brightness slider changes display brightness before and after a + left-edge vertical drag. +2. A left-edge drag does not dismiss playback. +3. A right-edge drag still changes media volume. +4. A center downward drag still dismisses after playback is established. +5. Double-tap seek and pinch aspect-mode changes still work. +6. Playback still prevents the display from sleeping while playing/buffering. +7. Fresh app logs contain no fatal exception, crash, or ANR. + +- [ ] **Step 4: Push and update PR #127** + +Push `fix/subtitle-aspect-recenter`, record focused test/release/Pixel evidence +on PR #127, and wait for hosted Unit tests and CodeRabbit. Do not merge without +fresh user authorization. diff --git a/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md b/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md new file mode 100644 index 000000000..6c869e854 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-release-audit-fixes.md @@ -0,0 +1,174 @@ +# Android Release Audit Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the three Important Android phone release bugs found by the final audit: stale Libraries responses, Browse content behind bottom chrome, and cross-identity cache attribution. + +**Architecture:** Libraries request families will use generation tokens owned by `LibrariesViewModel`, allowing superseded asynchronous completions to be discarded without changing repository APIs. `CatalogGrid` will accept the already-measured bottom chrome inset and reserve it for both grid content and the alphabet rail. Offline cache writes will preserve request-time identity ownership by rejecting writes after the shared identity generation changes. + +**Tech Stack:** Kotlin, coroutines/StateFlow, Jetpack Compose, Room-backed Android catalog cache, Kotlin test/coroutines-test, Gradle. + +## Global Constraints + +- Android phone and shared Android code only; no server/API/schema/protocol changes. +- Preserve existing successful response, offline fallback, paging, and identity-transition behavior. +- Tests must deterministically complete deferred requests out of order; no sleeps or widened timeouts. +- Physical devices remain excluded. +- Every production correction must be preceded by a failing regression. + +--- + +### Task 1: Make Libraries Results Current and Keep Browse Above Bottom Chrome + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/browse/CatalogGrid.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesViewModelTest.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibraryChromeInsetSourceTest.kt` + +**Interfaces:** +- Consumes: `LocalBottomChromeInset.current`, the existing `LibrariesUiState` selected library/query fields, and existing repository suspend functions. +- Produces: monotonically increasing Recommended, Browse, and Collections request generations; `CatalogGrid(..., bottomContentInset: Dp = 0.dp)` whose grid and alphabet rail stay above that inset. + +- [ ] **Step 1: Write deferred-response regressions** + +Add deterministic tests that start request A, change library or Browse query state, start request B, complete B first, then A, and assert the final rows/grid still belong to B. Cover Recommended, Browse sort/filter, and Collections. + +- [ ] **Step 2: Verify the request regressions fail** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibrariesViewModelTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the reverse-completion assertions fail because A overwrites B. + +- [ ] **Step 3: Implement request generations** + +Keep one counter per request family: + +```kotlin +private var recommendedRequestGeneration = 0L +private var catalogRequestGeneration = 0L +private var collectionsRequestGeneration = 0L +``` + +Increment and capture the family generation before launching work. Before every success/error/loading completion write, require both the captured generation and the captured library/query identity to remain current. Superseded requests may finish, but must not mutate `uiState`. + +- [ ] **Step 4: Write the bottom-inset regression** + +Extend `LibraryChromeInsetSourceTest` to require `BrowseTabContent` to pass `LocalBottomChromeInset.current` into `CatalogGrid`, and require `CatalogGrid` to expose and consume `bottomContentInset` in both its scroll padding and alphabet-rail bounds. + +- [ ] **Step 5: Verify the inset regression fails** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.android.ui.screens.libraries.LibraryChromeInsetSourceTest \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: failure because `CatalogGrid` currently hard-codes 8dp bottom padding and the full-height rail. + +- [ ] **Step 6: Implement the measured bottom inset** + +Add: + +```kotlin +bottomContentInset: Dp = 0.dp +``` + +to `CatalogGrid`. Add it to grid/list bottom `contentPadding`, and constrain/pad the alphabet rail so its interactive range ends above the same inset. Pass `LocalBottomChromeInset.current` from Libraries Browse; standalone callers retain the zero default. + +- [ ] **Step 7: Run focused GREEN verification and commit** + +Run both focused test classes and relevant Android compilation. Commit only Task 1 files with: + +```bash +git commit -m "fix(android): keep library results and browse chrome current" +``` + +### Task 2: Preserve Request-Time Identity for Offline Cache Writes + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/cache/RoomCatalogCacheRepository.kt` +- Modify only if required by the narrow ownership boundary: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt` +- Modify only if required by the narrow ownership boundary: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt` +- Test: existing Room/cache repository Android unit tests and `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` + +**Interfaces:** +- Consumes: `IdentityTransitionBarrier.generation` and the existing cache snapshot/provider. +- Produces: cache writes that are accepted only when the request-time identity generation is still current; reads and offline fallback remain unchanged. + +- [ ] **Step 1: Write the delayed identity-switch regression** + +Create a deferred API response under identity A, switch the barrier/snapshot to B, complete A, then assert A's response is not written or readable as B. Cover item detail and one section/catalog path that exercises the shared ownership seam. + +- [ ] **Step 2: Verify the ownership regression fails** + +Run the exact new cache/repository tests with: + +```bash +./gradlew :shared:testDebugUnitTest :android-shared:testDebugUnitTest \ + --tests '*CatalogRepositoryDetailCacheTest*' \ + --tests '*RoomCatalogCacheRepository*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: failure showing the A response attributed to B. + +- [ ] **Step 3: Implement generation-validated writes** + +Capture `IdentityTransitionBarrier.generation` before each network-backed cacheable request. At completion, call the existing cache write only when the captured generation still equals the current generation. Do not synthesize a new identity from completion-time state and do not alter offline-read fallback semantics. + +- [ ] **Step 4: Run focused GREEN verification and commit** + +Run the new regressions plus neighboring cache/repository tests. Commit only Task 2 files with: + +```bash +git commit -m "fix(shared): keep cache writes identity scoped" +``` + +### Task 3: Integrate and Requalify the Release + +**Files:** +- Verify only: all files changed by Tasks 1 and 2. + +**Interfaces:** +- Consumes: both reviewed fix commits. +- Produces: one clean branch with green supply-chain, phone/TV unit, and phone/TV release gates. + +- [ ] **Step 0: Preserve request-time ownership for Home cache writes** + +Add a Home-cache write lease using the shared identity generation, propagate it +from the section request through both `StartupWarmup` and `HomeViewModel`, and +make `RoomHomeCacheRepository` reject stale leases before and after resolving +the identity snapshot. First add a deterministic delayed A→B regression that +proves A's Home sections cannot be stored or read as B. Preserve existing +offline Home reads and successful same-identity warmup behavior. + +- [ ] **Step 1: Review each task diff independently** + +Require explicit spec-compliance and code-quality approval; fix every Critical, Important, or Minor release finding before continuing. + +- [ ] **Step 2: Run the exact release gate** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +- [ ] **Step 3: Perform whole-branch review and artifact verification** + +Require a clean `origin/main...HEAD` review, verify universal APK package/version/ABIs/v2 signature/size/SHA-256, copy final-hash artifacts without overwrite, then push and update PR #126 without merging. diff --git a/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md b/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md new file mode 100644 index 000000000..87cc0c1b8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing.md @@ -0,0 +1,532 @@ +# Android Subtitle Aspect Reconciliation and Phone Sizing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate stale/clipped subtitle geometry after Android aspect changes and make all phone subtitle presets 1.125× larger without changing TV sizing. + +**Architecture:** `SubtitleManager` remains the shared owner of Media3 and libass subtitle presentation. A fixed phone/television presentation class selects the font fraction, while `SubtitleVideoRectSync` uses generation-bound post-layout snapshot verification to request at most one corrective pre-draw pass when an aspect change exposes stale `exo_content_frame` bounds. + +**Tech Stack:** Kotlin Multiplatform, Android Media3 `PlayerView`/`SubtitleView`, Compose `AndroidView`, Koin, Robolectric, Gradle. + +## Global Constraints + +- Preserve subtitle selection, cue styling, authored positioning, libass/ASS, bitmap subtitle, letterbox, and title-safe behavior. +- Do not change subtitle tracks, server subtitle processing, playback protocols, preset names, or persisted subtitle appearance schema. +- Phone fractions are exactly Small `22.5 / 720`, Medium `29.25 / 720`, Large `36 / 720`, XLarge `45 / 720`, and XXLarge `54 / 720`. +- Television fractions remain Small `20 / 720`, Medium `26 / 720`, Large `32 / 720`, XLarge `40 / 720`, and XXLarge `48 / 720`. +- Reconciliation is bounded to two post-layout applications per explicit sync generation, coalesces repeated requests, and cancels all pending work on detach/dispose. +- Use Media3's measured `exo_content_frame`; do not duplicate Media3's aspect-ratio algorithm. +- Do not install or modify the Shield. Physical validation is limited to Pixel serial `58211FDCQ000CU`. + +--- + +### Task 1: Phone-only subtitle preset scaling + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:40-46,334-342` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:35-55` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt:219-222` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt:142-146` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt` + +**Interfaces:** +- Produces: `enum class AndroidSubtitlePresentation { Phone, Television }`. +- Produces: `SubtitleManager(libassBridge: LibassBridge? = null, presentation: AndroidSubtitlePresentation = AndroidSubtitlePresentation.Television)`. +- Preserves: all existing `SubtitleManager()` test and utility construction as television-scale compatibility. + +- [ ] **Step 1: Write failing fraction tests** + +Replace the single reflected web-scale test with explicit phone and television assertions: + +```kotlin +@Test +fun phoneSubtitleTextFractionsUseApprovedPhoneScale() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Phone, + ) + assertEquals(22.5f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(29.25f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(36f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(45f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(54f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) +} + +@Test +fun televisionSubtitleTextFractionsPreserveExistingScale() { + val manager = SubtitleManager( + presentation = AndroidSubtitlePresentation.Television, + ) + assertEquals(20f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Small)) + assertEquals(26f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Medium)) + assertEquals(32f / 720f, fractionalSize(manager, SubtitleFontSizePreset.Large)) + assertEquals(40f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XLarge)) + assertEquals(48f / 720f, fractionalSize(manager, SubtitleFontSizePreset.XXLarge)) +} +``` + +Update the existing phone/TV source-wiring contract tests to require the named +`presentation` argument with `Phone` and `Television`, respectively. The +production change that makes these tests pass is explicit DI selection plus +the presentation-aware conversion. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest*' \ + --tests '*SubtitleAspectSyncWiringTest*' \ + --tests '*TvSubtitleAspectSyncWiringTest*' \ + --max-workers=2 --no-daemon +``` + +Expected: compilation/assertion failures because +`AndroidSubtitlePresentation` and the explicit DI arguments do not exist and +phone fractions still equal television fractions. + +- [ ] **Step 3: Implement the presentation-aware conversion** + +Add the enum next to `SubtitleManager`, retain television as the default for +existing shared call sites, and select the numerator table without changing +the persisted `SubtitleFontSizePreset`: + +```kotlin +enum class AndroidSubtitlePresentation { + Phone, + Television, +} + +class SubtitleManager( + private val libassBridge: LibassBridge? = null, + private val presentation: AndroidSubtitlePresentation = + AndroidSubtitlePresentation.Television, +) { + private fun fractionalSizeFor(preset: SubtitleFontSizePreset): Float { + val numerator = when (presentation) { + AndroidSubtitlePresentation.Phone -> when (preset) { + SubtitleFontSizePreset.Small -> 22.5f + SubtitleFontSizePreset.Medium -> 29.25f + SubtitleFontSizePreset.Large -> 36f + SubtitleFontSizePreset.XLarge -> 45f + SubtitleFontSizePreset.XXLarge -> 54f + } + AndroidSubtitlePresentation.Television -> when (preset) { + SubtitleFontSizePreset.Small -> 20f + SubtitleFontSizePreset.Medium -> 26f + SubtitleFontSizePreset.Large -> 32f + SubtitleFontSizePreset.XLarge -> 40f + SubtitleFontSizePreset.XXLarge -> 48f + } + } + return numerator / 720f + } +} +``` + +Construct the phone singleton with +`presentation = AndroidSubtitlePresentation.Phone` and TV with +`presentation = AndroidSubtitlePresentation.Television`; use named arguments +for both constructor parameters. + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the Step 2 command. Expected: all selected tests pass with no compilation +or assertion failure. + +- [ ] **Step 5: Commit the sizing change** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectSyncWiringTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectSyncWiringTest.kt +git commit -m "fix(subtitles): scale phone caption presets" +``` + +### Task 2: Bounded stale-frame convergence + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:583-768` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:360-560` + +**Interfaces:** +- Consumes: existing `SubtitleVideoRectSync.updateAndReconcileAfterLayout()`. +- Produces: an internal immutable content-frame/resize snapshot used only to decide whether one corrective pre-draw is required. +- Preserves: `syncSubtitleVideoBounds(PlayerView)`, layout-listener behavior, and all public subtitle APIs. + +- [ ] **Step 1: Add a production-shaped RED transition test** + +Extend `MountedSubtitleCanvas` with a method that deliberately dispatches the +first pre-draw while the old content-frame bounds are still mounted, changes +the frame, drains the posted snapshot verification, then dispatches the +corrective pre-draw: + +```kotlin +fun transitionAfterEarlyPreDraw(resizeMode: Int, finalFrame: FrameBounds) { + schedule(resizeMode) + playerView.viewTreeObserver.dispatchOnPreDraw() + contentFrame.layout( + finalFrame.left, + finalFrame.top, + finalFrame.right, + finalFrame.bottom, + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + playerView.viewTreeObserver.dispatchOnPreDraw() +} +``` + +Add: + +```kotlin +@Test +fun mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame() { + val canvas = MountedSubtitleCanvas() + canvas.transition( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + frame = FrameBounds(-120, -64, 2040, 1080), + ) + + canvas.transitionAfterEarlyPreDraw( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + finalFrame = FrameBounds(240, 0, 1680, 1016), + ) + + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) +} +``` + +The production change that makes this test pass is retaining the explicit-sync +generation long enough to notice that the frame snapshot changed after the +first pre-draw and scheduling exactly one corrective pass. + +- [ ] **Step 2: Run the single test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest.mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: FAIL because the existing one-shot pre-draw listener is removed +before the final Fit frame is mounted, leaving the Zoom-derived top/left +offset. + +- [ ] **Step 3: Add latest-generation and detach RED tests** + +Add tests that: + +```kotlin +@Test +fun rapidEarlyTransitionsApplyOnlyLatestMode() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FILL) + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_FIT) + canvas.dispatchEarlyPreDrawThenMount(FrameBounds(240, 0, 1680, 1016)) + assertEquals(SubtitleVideoRect(0, 0, 1440, 1016), canvas.subtitleRect()) + assertEquals(2, canvas.reconciliationCount) +} + +@Test +fun detachCancelsPostedSnapshotVerification() { + val canvas = MountedSubtitleCanvas() + canvas.schedule(AspectRatioFrameLayout.RESIZE_MODE_ZOOM) + canvas.dispatchPreDraw() + canvas.detach() + canvas.mountFrameAndDrain(FrameBounds(-120, -64, 2040, 1080)) + assertEquals(1, canvas.reconciliationCount) +} +``` + +Expose `reconciliationCount` through the existing +`postLayoutReconciliationObserver`; keep all harness helpers test-only. + +- [ ] **Step 4: Run the three tests and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest.mountedCanvasCorrectsFillToFitWhenFirstPreDrawSeesCroppedFrame' \ + --tests '*SubtitleManagerAppearanceTest.rapidEarlyTransitionsApplyOnlyLatestMode' \ + --tests '*SubtitleManagerAppearanceTest.detachCancelsPostedSnapshotVerification' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the Fill-to-Fit test remains red and the new lifecycle/count +assertions fail because no generation-bound snapshot verification exists. + +- [ ] **Step 5: Implement bounded snapshot verification** + +Inside `SubtitleVideoRectSync`, add: + +```kotlin +private data class LayoutSnapshot( + val resizeMode: Int, + val playerWidth: Int, + val playerHeight: Int, + val frameLeft: Int, + val frameTop: Int, + val frameWidth: Int, + val frameHeight: Int, +) + +private var reconciliationGeneration = 0L +private var pendingVerification: Runnable? = null +private var appliedPasses = 0 +``` + +`updateAndReconcileAfterLayout()` increments the generation only when creating +a new explicit reconciliation request, resets `appliedPasses`, coalesces the +single pending pre-draw, and captures no mutable view geometry. + +After the pre-draw calls `update()`, capture the snapshot actually applied, +increment `appliedPasses`, and post one main-thread verification runnable. The +runnable must: + +```kotlin +if ( + !isDisposed && + generation == reconciliationGeneration && + appliedPasses < 2 && + currentSnapshot(playerView) != appliedSnapshot +) { + schedulePreDrawFor(generation) +} +``` + +The second application does not post another correction. `dispose()` removes +the pre-draw listener, removes the posted runnable with +`playerView.removeCallbacks`, increments/invalidates the generation, and keeps +the existing listener cleanup. The permanent content-frame layout listener +continues to handle genuine later layouts. + +- [ ] **Step 6: Run focused reconciliation tests and verify GREEN** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all appearance, geometry, coalescing, rapid-transition, and detach +tests pass. `repeatedExplicitSyncsRunOnePostLayoutReconciliation` must remain +green for stable geometry. + +- [ ] **Step 7: Commit the reconciliation fix** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): converge aspect bounds after layout" +``` + +### Task 3: Stabilize initial phone subtitle restore + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt` +- Test: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt` +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleRemountReselection.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/SubtitleRemountReselectionTest.kt` + +**Interfaces:** +- Consumes: `MobileSubtitleTransactionAdapter.reportMountedSelection(...)` and + the existing five-second mobile mount deadline. +- Produces: stable-snapshot evidence owned by the pending mobile mount + generation; the first non-empty miss remains pending, a changed snapshot + restarts settlement, and a repeated identical miss may fail. + +- [ ] **Step 1: Write the failing mobile transaction tests** + +Change the immediate-miss test so it reports one ready, non-empty catalog and +asserts that the pending local restore remains active with no failure. Add a +second test that reports the same key twice and asserts the existing failure, +plus a changed-key test that requires the changed key to repeat before failure. + +```kotlin +harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "intermediate", + settled = true, +) +assertEquals(local, harness.adapter.snapshot.localMountIdentity) +assertNull(harness.adapter.snapshot.failureMessage) + +harness.adapter.reportMountedSelection( + identity = local, + selected = false, + snapshotKey = "intermediate", + settled = true, +) +assertNull(harness.adapter.snapshot.localMountIdentity) +assertTrue(harness.adapter.snapshot.failureMessage?.contains("mount", true) == true) +``` + +- [ ] **Step 2: Run RED** + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests '*MobileSubtitleTransactionAdapterTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the first-snapshot assertion fails because the adapter currently +calls `failLocalMount` immediately. + +- [ ] **Step 3: Implement generation-owned snapshot stabilization** + +Add one nullable last-miss snapshot key to +`MobileSubtitleTransactionAdapter`. For a non-selected result with +`settled=true`, record the first non-blank key; fail only when the same key is +reported again. A changed key replaces the candidate and remains provisional. +Clear the candidate from `invalidateLocalMount()` so content, identity, and +generation changes cannot inherit old evidence. + +In `PlayerScreen` keep the immediate `LaunchedEffect` mount attempt +provisional (`settled = false`). Track callbacks remain the source of settled +catalog evidence. Do not change the five-second timeout, successful-selection +path, persisted identity, or error copy. + +- [ ] **Step 4: Run GREEN and focused TV parity** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*MobileSubtitleTransactionAdapterTest*' \ + --tests '*SubtitleRemountReselectionTest*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: mobile first/changed/repeated snapshot tests pass, and TV's existing +first-snapshot stabilization tests remain green without TV production edits. + +- [ ] **Step 5: Commit** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapter.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/MobileSubtitleTransactionAdapterTest.kt +git commit -m "fix(subtitles): await stable tracks on playback restore" +``` + +### Task 4: Regression, release, and Pixel validation + +**Files:** +- Modify only if evidence requires a test correction: files from Tasks 1-3. +- Record verification in the PR description; do not add generated artifacts to git. + +**Interfaces:** +- Consumes: phone presentation scaling and bounded reconciliation from Tasks 1-2. +- Produces: verified phone/TV release artifacts and physical Pixel evidence. + +- [ ] **Step 1: Run focused shared/phone/TV tests uncached** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*Subtitle*' \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected subtitle tests pass. + +- [ ] **Step 2: Run supply-chain verification** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit zero without changing tracked files. + +- [ ] **Step 3: Run the full unit and release gates** + +```bash +./gradlew \ + testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`; both release APK outputs exist. Do not install the +TV artifact. + +- [ ] **Step 4: Safely install the phone release on the Pixel** + +Verify serial, package, version, and signer compatibility first. Then use only: + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-universal-release.apk +``` + +Abort without uninstalling, clearing data, downgrading, or changing settings if +the signer or version is incompatible. + +- [ ] **Step 5: Validate the reproduced matrix on Pixel** + +Using the already configured subtitle track: + +1. Play through at least three consecutive text cues in Fit. +2. Change Fit → Fill → Stretch → Fit, closing the sheet after each selection. +3. Repeat Fill → Fit rapidly three times. +4. Confirm each cue is horizontally centered, fully above the display bottom, + and visible on the first cue after each transition. +5. Confirm multi-line cues are not clipped. +6. Confirm the default Large phone size is visibly larger than the pre-fix + `32 / 720` build and that Small through XXLarge remain ordered. +7. Capture serial-scoped screenshots and fresh app-process logs; confirm no + fatal exception, ANR, subtitle parser error, or playback regression. + +- [ ] **Step 6: Request independent review** + +Provide the reviewer with the approved spec, this plan, commits from Tasks 1-2, +the focused/full gate outputs, and Pixel screenshots. Require explicit verdicts +on: + +- generation/coalescing correctness, +- detach and callback ownership, +- parent-local Media3 geometry, +- phone-only sizing and TV preservation, +- absence of server/protocol/persistence changes. + +Fix only evidenced findings test-first and rerun the smallest affected gate. + +- [ ] **Step 7: Final diff and branch verification** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline --decorate origin/main..HEAD +``` + +Expected: no whitespace errors, a clean worktree, and only the approved PR #127 +subtitle work plus its spec/plan/fix commits. + +- [ ] **Step 8: Push and update PR #127 without merging** + +Push `fix/subtitle-aspect-recenter`, update PR #127 with the new Pixel +reproduction and verification evidence, and wait for hosted checks and +CodeRabbit. Do not merge without fresh user authorization. diff --git a/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md b/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md new file mode 100644 index 000000000..7a14598bb --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md @@ -0,0 +1,943 @@ +# Android Active Header Focus and Editorial Hero Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make content-to-header navigation land on the active TV section and present ordered editorial browsing metadata on Android phone and TV. + +**Architecture:** Keep `TvMainShell` as the route-aware focus coordinator, pass its active root explicitly through `TvShellFocusState`, and let `TvTopMenuBar` apply the existing requester after composition. Keep TV hero transformation inside `TvMarqueeContent.from` and phone hero transformation in the small pure `featuredHeroMetadata` mapper, using only existing `SectionItem` data and leaving player/detail surfaces unchanged. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose/Compose for TV, Compose focus and `FlowRow` layout APIs, Kotlin coroutines, Kotlin test/JUnit, Gradle. + +## Global Constraints + +- TV header-focus behavior is Android TV only. +- Editorial browsing-hero metadata applies to Android phone and TV. +- No server, API, database, payload, schema, or production-configuration changes. +- No player-overlay, playback-settings, item-detail, stream-selection, transcoding, or subtitle changes. +- No phone Home hero; phone scope is the existing Library Recommended featured carousel only. +- No Apple-client changes. +- Preserve the held-Up boundary: repeated Up stops on the first content row and a fresh Up enters the menu. +- Search receives content-to-menu focus only while Search is the active route. +- Technical resolution, HDR, and audio data remains available to other consumers but is not rendered in browsing heroes. +- Preserve synopsis, cast/air-date enrichment, artwork, cache-first loading, and crossfade behavior. +- Invalid, non-finite, zero, negative, blank, or unavailable metadata is omitted without placeholders, empty chips, or dangling separators. +- Phone metadata may wrap to at most two lines and must not overlap carousel actions or change the carousel page height. +- Shared hydration/navigation-performance changes already present in PR #126 are its accepted baseline and remain unchanged. +- Do not merge or deploy; update open PR #126 only after all required verification is green. + +--- + +## File Map + +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt` + derives the active root and supplies one explicit focus target to both Up and + Back paths. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt` + carries the requested menu target without performing Compose focus itself. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt` + provides the small frame-ordered focus application seam. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt` + resolves the active target to an existing `FocusRequester` and applies it + after composition. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt` + builds browsing-hero editorial metadata from `SectionItem`. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt` + proves frame ordering and request-result propagation. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt` + proves Up/Back retain the requested active root. +- `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + proves TV movie/episode metadata ordering, technical-badge removal, and + invalid-value omission. +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt` + renders phone Library Recommended hero chips in a bounded two-line + `FlowRow`. +- `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt` + maps existing `SectionItem` fields to ordered, testable phone hero chips. +- `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` + proves phone movie/episode ordering, generic-type removal, and invalid-value + omission. + +--- + +### Task 1: Make active-section header focus deterministic + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:466-470,719-735,802-815` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt:174-184,285-309` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt:225-242` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt:90-125,245-280` + +**Interfaces:** +- Consumes: `TvTopMenuPanel.Root(TvRootDestination)`, `TvShellFocusState.requestMenuFocusIfAvailable(TvTopMenuPanel?, Boolean)`, and each existing top-menu `FocusRequester`. +- Produces: `internal suspend fun requestTopMenuFocusUntilApplied(awaitFrame: suspend () -> Unit, requestFocus: () -> Boolean)`. +- Produces: `TvShellFocusState.onBack(onTabRoot: Boolean, menuFocusTarget: TvTopMenuPanel? = null): TvShellBackAction`. +- Produces: one `selectedMenuFocusTarget: TvTopMenuPanel?` in `TvMainShell`, reused by content Up and Back. + +- [ ] **Step 1: Write the frame-ordering regression test** + +Create `TvTopMenuFocusRequestTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvTopMenuFocusRequestTest { + @Test + fun focusIsRequestedOnlyAfterTheTargetHasHadAFrameToCompose() = runTest { + val events = mutableListOf() + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { events += "focus"; true }, + ) + + assertEquals(listOf("frame", "focus"), events) + } + + @Test + fun aTargetThatIsNotAttachedYetIsRetriedOnTheNextFrame() = runTest { + val events = mutableListOf() + var attempts = 0 + + requestTopMenuFocusUntilApplied( + awaitFrame = { events += "frame" }, + requestFocus = { + events += "focus" + attempts += 1 + attempts == 2 + }, + ) + + assertEquals(listOf("frame", "focus", "frame", "focus"), events) + } +} +``` + +- [ ] **Step 2: Add the active-root Back regression** + +Append to `TvShellFocusStateTest`: + +```kotlin +@Test +fun backFromRootContentRetainsTheActiveRootAsItsMenuTarget() { + val state = TvShellFocusState() + + assertEquals( + TvShellBackAction.MoveFocusToMenu, + state.onBack( + onTabRoot = true, + menuFocusTarget = moviesPanel, + ), + ) + + assertEquals(moviesPanel, state.menuFocusTarget) +} +``` + +- [ ] **Step 3: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `requestTopMenuFocusUntilApplied` and the +`menuFocusTarget` argument do not exist. + +- [ ] **Step 4: Implement the frame-ordered focus seam** + +Create `TvTopMenuFocusRequest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +internal suspend fun requestTopMenuFocusUntilApplied( + awaitFrame: suspend () -> Unit, + requestFocus: () -> Boolean, +) { + do { + awaitFrame() + } while (!requestFocus()) +} +``` + +- [ ] **Step 5: Carry the active target through the state holder** + +Change `TvShellFocusState.onBack` to: + +```kotlin +fun onBack( + onTabRoot: Boolean, + menuFocusTarget: TvTopMenuPanel? = null, +): TvShellBackAction { + val action = tvShellBackAction( + panelOpen = openPanel != null, + profileMenuOpen = profileMenuOpen, + menuFocused = isMenuFocused, + onTabRoot = onTabRoot, + ) + when (action) { + TvShellBackAction.ClosePanel -> closePanel(returnFocusToBar = true) + TvShellBackAction.CloseProfileMenu -> dismissProfileMenu() + TvShellBackAction.MoveFocusToMenu -> requestMenuFocus(menuFocusTarget) + TvShellBackAction.MenuBack, + TvShellBackAction.DelegateToNav -> Unit + } + return action +} +``` + +Do not alter the pure `tvShellBackAction` precedence. + +- [ ] **Step 6: Derive one route-aware target and reuse it** + +Immediately after `selectedRoot` in `TvMainShell`, add: + +```kotlin +val selectedMenuFocusTarget = selectedRoot?.let(TvTopMenuPanel::Root) +``` + +Pass it to Back: + +```kotlin +focusState.onBack( + onTabRoot = selectedRoot != null, + menuFocusTarget = selectedMenuFocusTarget, +) +``` + +Use it at both first-row Up handoffs: + +```kotlin +focusState.requestMenuFocusIfAvailable( + selectedMenuFocusTarget, + allowNullTarget = currentRoute == TvMainRoute.Search.route, +) +``` + +Leave `selectedMenuFocusTarget` null on Search. `TvTopMenuBar` must continue +using `isSearchActive` to select Search for that route; other secondary routes +must not silently select Home. + +- [ ] **Step 7: Apply the requester after composition and record the handled identity** + +In `TvTopMenuBar`, track `focusRequestIdentity` as +`focusRequest to focusRequestTarget`, then replace the immediate focus call +inside the `LaunchedEffect(focusRequest, focusRequestTarget, isFocusSuppressed)` +with: + +```kotlin +requestTopMenuFocusUntilApplied( + awaitFrame = { androidx.compose.runtime.withFrameNanos { } }, + isTargetCurrent = { + currentFocusRequestTarget == focusRequestTarget && + isTopMenuFocusTargetAvailable(focusRequestTarget, currentDestinations) + }, + requestFocus = { + runCatching { requester.requestFocus() }.getOrDefault(false) + }, +) +lastHandledFocusRequest = focusRequestIdentity +``` + +Move the existing `lastHandledFocusRequest = focusRequestIdentity` assignment +out of the pre-request path. The frame loop suspends rather than spins and is +cancelled automatically if the `LaunchedEffect` keys change. Keep the +explicit-target resolution and `dwellSuppressedButton` behavior unchanged. + +- [ ] **Step 8: Run focused tests and verify GREEN** + +Run the command from Step 3. + +Expected: both test classes pass with zero failures. + +- [ ] **Step 9: Run the existing Up-navigation regression** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all four held-Up/row-relocation tests pass unchanged. + +- [ ] **Step 10: Commit the focus correction** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusState.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuBar.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvShellFocusStateTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvTopMenuFocusRequestTest.kt +git commit -m "fix(tv): restore active header focus from content" +``` + +--- + +### Task 2: Replace stream badges with editorial hero metadata + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:75-165` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt:1-32` + +**Interfaces:** +- Consumes: `SectionItem.year`, `durationSeconds`, `ratingImdb`, `genres`, `contentRating`, `seriesTitle`, `seasonNumber`, `episodeNumber`, and existing `TvMarqueeEnrichment.detailLine`. +- Produces: `TvMarqueeContent.badges` containing only an optional uppercase content classification. +- Produces: ordered `TvMarqueeContent.metaParts`: movie `year → runtime → IMDb → genre`; episode `Sx Ey → episode title → runtime → IMDb`. + +- [ ] **Step 1: Replace quality-badge tests with movie editorial-metadata RED** + +Replace `TvFocusMarqueeModelTest` with: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvFocusMarqueeModelTest { + @Test + fun movieHeroPrioritizesEditorialMetadataAndOmitsStreamQuality() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "TrueHD Atmos", + ), + ), + rowTitle = "Popular", + ) + + assertEquals(listOf("PG-13"), content.badges) + assertEquals( + listOf("2016", "1h 56m", "7.9", "Science Fiction"), + content.metaParts, + ) + } + + @Test + fun episodeHeroUsesSeriesTitleAndEditorialEpisodeMetadata() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + overlaySummary = OverlaySummary( + resolution = "1080p", + audio = "EAC3", + ), + ), + rowTitle = "Continue Watching", + ) + + assertEquals("The Last of Us", content.title) + assertEquals(listOf("TV-MA"), content.badges) + assertEquals( + listOf("S1 E3", "Long, Long Time", "1h 16m", "8.6"), + content.metaParts, + ) + } + + @Test + fun missingEditorialMetadataProducesNoEmptyTokensOrBadges() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-2", + type = "movie", + title = "Untitled", + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "HDR10", + audio = "Atmos", + ), + ), + rowTitle = "Recently Added", + ) + + assertEquals(emptyList(), content.badges) + assertEquals(emptyList(), content.metaParts) + } +} +``` + +- [ ] **Step 2: Run the model test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: the movie order differs, the episode lacks its rating, and technical +quality badges are still present. + +- [ ] **Step 3: Implement the approved metadata ordering** + +In `TvMarqueeContent.from`, build metadata exactly as follows: + +```kotlin +val meta = mutableListOf() +if (isEpisode) { + episodeToken(item.seasonNumber, item.episodeNumber)?.let(meta::add) + if (item.title.isNotBlank()) meta.add(item.title) + lengthText(item.durationSeconds)?.let(meta::add) + item.ratingImdb?.let { meta.add(formatRating(it)) } +} else { + if (item.year > 0) meta.add(item.year.toString()) + lengthText(item.durationSeconds)?.let(meta::add) + item.ratingImdb?.let { meta.add(formatRating(it)) } + item.genres.firstOrNull { it.isNotBlank() }?.let(meta::add) +} + +val badges = item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let(::listOf) + .orEmpty() +``` + +Delete `qualityBadges`, `dynamicRangeBadge`, `audioBadge`, and +`prettyResolution`; they have no remaining callers. Do not remove +`SectionItem.overlaySummary` or change shared models. + +- [ ] **Step 4: Preserve enrichment and rendering contracts** + +Verify by inspection that `TvMarqueeEnrichment.from` still emits its existing +air-date/cast `detailLine`, `TvMarqueeContent.withEnrichment` still preserves +the content identity, and `TvFocusMarquee` still omits the badge/meta row when +both lists are empty. Make no changes to those paths. + +- [ ] **Step 5: Run the focused model test and verify GREEN** + +Run the command from Step 2. + +Expected: all three tests pass with zero failures. + +- [ ] **Step 6: Commit the editorial hero correction** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "fix(tv): show editorial metadata in browse heroes" +``` + +--- + +### Task 3: Add phone parity and reject invalid hero metadata + +**Files:** +- Create: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt:1-65,275-375,467-505` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:80-100,185-215` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + +**Interfaces:** +- Consumes: existing `SectionItem` editorial fields only. +- Produces: `internal enum class FeaturedHeroMetadataKind { Plain, Rating, Classification }`. +- Produces: `internal data class FeaturedHeroMetadataChip(val label: String, val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain)`. +- Produces: `internal fun featuredHeroMetadata(item: SectionItem): List`. +- Preserves: TV `TvMarqueeContent` ordering from Task 2 while filtering invalid ratings and durations. + +- [ ] **Step 1: Write phone movie and episode metadata tests** + +Create `FeaturedHeroMetadataTest.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.home + +import org.prairieserver.prairie.model.catalog.OverlaySummary +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class FeaturedHeroMetadataTest { + @Test + fun movieUsesOrderedEditorialMetadataWithoutGenericOrTechnicalChips() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-1", + type = "movie", + title = "Arrival", + year = 2016, + genres = listOf("Science Fiction"), + ratingImdb = 7.9, + contentRating = "PG-13", + durationSeconds = 6_960.0, + overlaySummary = OverlaySummary( + resolution = "2160p", + hdr = "Dolby Vision", + audio = "Atmos", + ), + ), + ) + + assertEquals( + listOf("2016", "1h 56m", "7.9", "Science Fiction", "PG-13"), + chips.map { it.label }, + ) + assertEquals( + listOf( + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Rating, + FeaturedHeroMetadataKind.Plain, + FeaturedHeroMetadataKind.Classification, + ), + chips.map { it.kind }, + ) + } + + @Test + fun episodeReliesOnExistingSeriesEyebrowAndTitleWithoutDuplicatingName() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "episode-1", + type = "episode", + title = "Long, Long Time", + seriesTitle = "The Last of Us", + seasonNumber = 1, + episodeNumber = 3, + ratingImdb = 8.6, + contentRating = "TV-MA", + durationSeconds = 4_560.0, + ), + ) + + assertEquals( + listOf("S1 E3", "1h 16m", "8.6", "TV-MA"), + chips.map { it.label }, + ) + } +} +``` + +- [ ] **Step 2: Write phone invalid-value tests** + +Append: + +```kotlin +@Test +fun invalidRatingsAndDurationsAreOmitted() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + ).forEachIndexed { index, invalid -> + val chips = featuredHeroMetadata( + SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + ) + + assertEquals(emptyList(), chips) + } +} +``` + +- [ ] **Step 3: Strengthen the TV invalid-value regression** + +Append to `TvFocusMarqueeModelTest`: + +```kotlin +@Test +fun invalidRatingsAndDurationsAreOmittedFromTvMetadata() { + listOf( + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 0.0, + -1.0, + ).forEachIndexed { index, invalid -> + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "invalid-$index", + type = "movie", + title = "Invalid", + ratingImdb = invalid, + durationSeconds = invalid, + ), + rowTitle = "Invalid", + ) + + assertEquals(emptyList(), content.metaParts) + } +} +``` + +- [ ] **Step 4: Run both focused model tests and verify RED** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: phone compilation fails because the new metadata types/functions do +not exist; the TV invalid-value case also fails before the production guard is +added. If Gradle applies each `--tests` pattern to both module tasks and reports +that one module has no matching tests, run the two module/test pairs as separate +commands while retaining the same worker/no-daemon/rerun shape. + +- [ ] **Step 5: Implement the pure phone metadata mapper** + +Create `FeaturedHeroMetadata.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.home + +import java.util.Locale +import kotlin.math.roundToInt +import org.prairieserver.prairie.model.section.SectionItem + +internal enum class FeaturedHeroMetadataKind { + Plain, + Rating, + Classification, +} + +internal data class FeaturedHeroMetadataChip( + val label: String, + val kind: FeaturedHeroMetadataKind = FeaturedHeroMetadataKind.Plain, +) + +internal fun featuredHeroMetadata(item: SectionItem): List { + val result = mutableListOf() + val isEpisode = item.type.equals("episode", ignoreCase = true) + + if (isEpisode) { + episodeToken(item.seasonNumber, item.episodeNumber)?.let { + result += FeaturedHeroMetadataChip(it) + } + } else if (item.year > 0) { + result += FeaturedHeroMetadataChip(item.year.toString()) + } + + formatFeaturedRuntime(item.durationSeconds)?.let { + result += FeaturedHeroMetadataChip(it) + } + item.ratingImdb + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { + result += FeaturedHeroMetadataChip( + label = String.format(Locale.US, "%.1f", it), + kind = FeaturedHeroMetadataKind.Rating, + ) + } + if (!isEpisode) { + item.genres.firstOrNull { it.isNotBlank() }?.let { + result += FeaturedHeroMetadataChip(it) + } + } + item.contentRating + ?.takeIf { it.isNotBlank() } + ?.uppercase(Locale.US) + ?.let { + result += FeaturedHeroMetadataChip( + label = it, + kind = FeaturedHeroMetadataKind.Classification, + ) + } + return result +} + +private fun episodeToken(season: Int?, episode: Int?): String? = when { + season != null && episode != null -> "S$season E$episode" + season != null -> "Season $season" + episode != null -> "Episode $episode" + else -> null +} + +private fun formatFeaturedRuntime(durationSeconds: Double?): String? { + val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null + val minutes = (duration / 60.0).roundToInt().takeIf { it > 0 } + ?: return null + if (minutes < 60) return "$minutes min" + val hours = minutes / 60 + val remainder = minutes % 60 + return if (remainder == 0) "${hours}h" else "${hours}h ${remainder}m" +} +``` + +- [ ] **Step 6: Render bounded phone chips without changing carousel actions** + +In `FeaturedCarousel.kt`: + +- import `ExperimentalLayoutApi` and `FlowRow` from + `androidx.compose.foundation.layout`; +- annotate `FeaturedCardContent` with `@OptIn(ExperimentalLayoutApi::class)`; +- use `remember(item) { featuredHeroMetadata(item) }` for the phone hero + metadata; +- replace the single `Row` with: + +```kotlin +FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + maxLines = 2, +) { + chips.forEach { chip -> MetadataChip(chip) } +} +``` + +Change `MetadataChip` to accept `FeaturedHeroMetadataChip` and render the star +only for `FeaturedHeroMetadataKind.Rating`: + +```kotlin +if (chip.kind == FeaturedHeroMetadataKind.Rating) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + tint = Color(0xFFFFCA28), + modifier = Modifier.size(12.dp), + ) +} +Text( + text = chip.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = 0.94f), + maxLines = 1, +) +``` + +Delete `HeroChip`, `metadataChips`, and the duplicate item-based +`episodeToken`; keep `eyebrowFor`, paging, actions, backdrop, and carousel +geometry unchanged. + +- [ ] **Step 7: Reject invalid TV ratings and durations** + +In `TvMarqueeContent.from`, guard the rating at both movie and episode call +sites: + +```kotlin +item.ratingImdb + ?.takeIf { it.isFinite() && it > 0.0 } + ?.let { meta.add(formatRating(it)) } +``` + +Start `lengthText` with: + +```kotlin +val duration = durationSeconds?.takeIf { it.isFinite() && it > 0.0 } + ?: return null +val minutes = (duration / 60.0).roundToInt() +``` + +Use `duration` rather than the nullable input for the calculation. Do not alter +valid formatting or field order. + +- [ ] **Step 8: Run both focused model tests and verify GREEN** + +Run the module-specific commands described in Step 4. + +Expected: all phone metadata tests and all TV marquee-model tests pass. + +- [ ] **Step 9: Commit the phone-parity and invalid-value correction** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedCarousel.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "fix(android): align editorial browse hero metadata" +``` + +--- + +### Task 4: Verify, review, package, and update PR #126 + +**Files:** +- Verify only: all branch changes against `origin/main` +- Output only, not committed: Android phone and TV universal minified release APKs +- Update remotely after green: existing PR #126 description/checklist + +**Interfaces:** +- Consumes: Tasks 1–3 commits. +- Produces: independently reviewed, fully verified PR #126 head and clearly named phone/TV tester APKs. + +- [ ] **Step 1: Run all focused regressions together** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvTopMenuFocusRequestTest" \ + --tests "org.prairieserver.prairie.tv.ui.shell.TvShellFocusStateTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvSkylineUpNavigationTest" \ + --tests "org.prairieserver.prairie.tv.ui.components.TvFocusMarqueeModelTest" \ + --rerun-tasks --max-workers=2 --no-daemon + +./gradlew :androidApp:testDebugUnitTest \ + --tests "org.prairieserver.prairie.android.ui.screens.home.FeaturedHeroMetadataTest" \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: all selected tests pass with zero failures. + +- [ ] **Step 2: Run supply-chain policy checks** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit 0. + +- [ ] **Step 3: Run the complete fresh phone/TV test and release gate** + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --rerun-tasks --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`; all phone and TV unit XML results have zero +failures and both universal minified release APKs are produced. + +- [ ] **Step 4: Perform the required device/emulator smoke** + +On a dedicated Android TV emulator or tester device, without touching an +unapproved physical device: + +1. Open Home, press Down into content, move to the first row, then press a + fresh Up; verify Home receives focus. +2. Repeat for one library section, For You, and Calendar; verify the active + pill receives focus each time. +3. Hold Up from a lower row; verify focus stops on the first content row. +4. Open Search and verify its route still owns Search focus. +5. Focus one movie and one episode; verify no resolution/HDR/audio badges are + shown and the approved editorial fields appear in order. + +Record the emulator/device identity and pass/fail result in the PR. If no +approved target is available, mark this smoke as pending instead of claiming +it passed. + +On a dedicated phone emulator, without touching an unapproved physical device: + +1. Open a Library Recommended featured carousel containing a movie and verify + year, runtime, IMDb, genre, and classification appear without a generic + `Movie` chip. +2. Open an episode carousel page and verify the series eyebrow/title remain, + with season/episode, runtime, IMDb, and classification chips and no generic + `Episode` chip. +3. Use a narrow phone viewport and verify chips wrap to no more than two lines + without covering Play or More Info. +4. Verify carousel paging, artwork, Play, and More Info behavior remain intact. + +Record the phone emulator identity and result, or mark this smoke pending when +no approved target is available. + +- [ ] **Step 5: Request independent focused review** + +Provide the reviewer: + +- the approved spec; +- `git diff origin/main...HEAD`; +- focused and full test results; +- the focus request timing/target contract; +- phone and TV movie/episode metadata ordering and invalid-value omission; +- the explicit scope boundary: Task 1 focus is TV-only, Task 2/3 hero metadata + is phone+TV, and earlier shared hydration/navigation-performance commits in + PR #126 are an accepted unchanged baseline rather than new Task 3 scope. + +Address only verified findings, rerun the affected focused test, and repeat +review until approved. + +- [ ] **Step 6: Verify diff hygiene and branch state** + +```bash +git diff --check origin/main...HEAD +git status --short --branch +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, no uncommitted files, and only the documented +navigation/hero commits plus their specs/plans. + +- [ ] **Step 7: Copy and verify both tester APKs** + +Select the universal APK from each release output, verify each with +`apksigner verify --verbose`, inspect package/version/ABI metadata with +`apkanalyzer`, calculate `shasum -a 256`, and copy it without overwriting prior +artifacts: + +```bash +cp androidApp/build/outputs/apk/release/androidApp-universal-release.apk \ + "Prairie-Phone-Universal-0.3.11-FocusHeroFix-$(git rev-parse --short HEAD).apk" +cp androidTvApp/build/outputs/apk/release/androidTvApp-universal-release.apk \ + "Prairie-TV-Universal-0.3.11-TVFocusHeroFix-$(git rev-parse --short HEAD).apk" +``` + +If a generated filename differs, select its universal artifact explicitly; +never substitute an ABI-specific split. Report that +`-PallowDebugReleaseSigning=true` produces a debug-signed release build that +only upgrades installations signed by the same certificate. + +- [ ] **Step 8: Push and update PR #126** + +```bash +git push origin fix/tv-for-you-cold-navigation +gh pr view 126 --repo Prairie-Server/prairie-android \ + --json state,isDraft,baseRefName,headRefName,mergeable,statusCheckRollup +``` + +Update the PR description to include: + +- active-section focus restoration; +- editorial-only phone and TV movie/episode hero metadata; +- invalid rating/runtime omission; +- focused/full verification evidence; +- independent review verdict; +- phone and TV smoke results or their explicit pending status; +- both tester APK signing caveats. + +Do not merge PR #126. diff --git a/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md b/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md new file mode 100644 index 000000000..8ff9dc40e --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-pr-126-coderabbit-remediation.md @@ -0,0 +1,527 @@ +# PR #126 CodeRabbit Remediation Implementation Plan + +> **Status:** Completed 2026-07-28. All remediation tasks and their recorded verification/review steps were completed on `fix/tv-for-you-cold-navigation`. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkboxes for tracking. + +**Goal:** Resolve every substantiated CodeRabbit finding on PR #126 while preserving intentional shared-request and Watch Together delivery semantics. + +**Architecture:** Keep each correction at its current boundary: TV shell wiring owns destination/focus routing, the marquee state owns page-entry versus real-focus arbitration, phone/TV presentation helpers own metadata validation, and repositories own identity-safe cache writes. Add focused regressions before behavioral changes; use existing characterization tests for behavior-preserving refactors. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose, Kotlin coroutines, Kotlin test/JUnit, Gradle, shell supply-chain policy scripts. + +## Global Constraints + +- Work only on `fix/tv-for-you-cold-navigation`; do not merge PR #126. +- Preserve shared in-flight recommendation requests; do not cancel repository work when one UI caller is superseded. +- Preserve Watch Together attach, cadence, and delivery semantics. +- Settled real TV card focus always wins over a page-entry marquee seed. +- IMDb ratings are valid only when finite and within `(0, 10]` on phone and TV. +- Do not install or deploy APKs. +- Address proven false positives with explicit invariants or documentation, not semantic changes. + +--- + +## File map + +- `androidTvApp/.../TvMainShell.kt`: root-destination selection and For You request reset. +- `androidTvApp/.../TvLibraryDetailScreen.kt`: Alphabet rail fallback forwarding. +- `androidTvApp/.../TvForYouEntryRequest.kt`: testable request transition. +- `androidTvApp/.../TvSkylineSectionFeed.kt`: initial marquee seed effect identity. +- `androidTvApp/.../TvFocusMarqueeModel.kt`: page-entry seed arbitration and TV metadata validation. +- `androidApp/.../FeaturedHeroMetadata.kt`: phone metadata validation. +- `shared/.../RoomDeliveryLatch.kt`: compiler-visible nullable-key invariant. +- Phone/TV room-sync controllers: non-null delivery-key binding at state-report call sites. +- `shared/.../SectionRepository.kt`: injectable home-request dispatcher. +- Phone library state logic and shared repositories: behavior-preserving helper extractions. +- Existing focused unit-test files plus small source-contract tests: regressions and wiring verification. +- Three review/plan/report documents: wording, stale references, and local-path hygiene. + +### Task 1: TV destination and focus wiring + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryDetailScreen.kt:158-167` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:548-601` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequest.kt:8-14` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvForYouEntryRequestTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/library/TvLibraryReviewWiringSourceTest.kt` + +**Interfaces:** +- Consumes: `TvForYouEntryRequest.next(selection: SavedListSelection?)`. +- Produces: `TvForYouEntryRequest.nextForTopLevelForYou(): TvForYouEntryRequest`; Alphabet and Calendar both register their content-Up fallback with the shell. + +- [x] **Step 1: Write the For You RED regression** + +Add to `TvForYouEntryRequestTest`: + +```kotlin +@Test +fun topLevelForYouRequestClearsSavedListSelection() { + val request = TvForYouEntryRequest( + sequence = 9, + selection = SavedListSelection.Watchlist, + ).nextForTopLevelForYou() + + assertEquals(10, request.sequence) + assertNull(request.selection) +} +``` + +- [x] **Step 2: Write the Alphabet wiring RED regression** + +Create a source-contract test that loads +`TvLibraryDetailScreen.kt`, isolates the +`TvLibraryTab.Alphabet -> LibraryTab(...)` block, and asserts it contains: + +```kotlin +onContentUpFallbackChanged = onContentUpFallbackChanged +``` + +Also assert the existing `TvCalendarScreen(...)` call still contains +`onContentUpFallbackChanged = onContentUpFallback`. + +- [x] **Step 3: Run RED** + +Run: + +```bash +./gradlew --no-daemon :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvLibraryReviewWiringSourceTest' \ + --max-workers=2 +``` + +Expected: failure because `nextForTopLevelForYou` is absent and the Alphabet +branch does not forward the fallback. + +- [x] **Step 4: Implement the minimal GREEN changes** + +Add: + +```kotlin +fun nextForTopLevelForYou(): TvForYouEntryRequest = next(null) +``` + +At the start of `onSelectRoot`, update only the For You destination: + +```kotlin +if (dest == TvRootDestination.ForYou) { + forYouEntryRequest = forYouEntryRequest.nextForTopLevelForYou() +} +``` + +Forward `onContentUpFallbackChanged` in the Alphabet `LibraryTab` call exactly +as Browse already does. + +- [x] **Step 5: Run GREEN and focused neighboring tests** + +Run: + +```bash +./gradlew --no-daemon :androidTvApp:testDebugUnitTest \ + --tests '*TvForYouEntryRequestTest' \ + --tests '*TvLibraryReviewWiringSourceTest' \ + --tests '*TvCalendarFocusRoutingTest' \ + --tests '*TvLibraryFocusRestoreTest' \ + --max-workers=2 +``` + +Expected: all selected tests pass. + +- [x] **Step 6: Commit Task 1** + +```bash +git add androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "fix(tv): close reviewed focus routing gaps" +``` + +### Task 2: Marquee identity and metadata bounds + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSkylineSectionFeed.kt:106-123` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt:76-165,229-285` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeEnrichmentTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt:18-55` +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` + +**Interfaces:** +- Consumes: `TvFocusMarqueeState.seedInitialPreview`, `TvMarqueeContent.from`, and `featuredHeroMetadata`. +- Produces: identity-aware page-entry reseeding that stops after real focus; consistent `(0, 10]` rating tokens on both clients. + +- [x] **Step 1: Write page-entry identity RED tests** + +Add tests that: + +```kotlin +state.seedInitialPreview(item, "Row", rowIdentity = "row-old") +state.commit(state.candidate) +state.seedInitialPreview(item, "Row", rowIdentity = "row-new") +assertEquals("row-new#item-1", state.candidate?.id) +``` + +and: + +```kotlin +state.preview(focusedItem, "Focused", rowIdentity = "focused-row") +state.commit(state.candidate) +state.seedInitialPreview(seedItem, "Replacement", rowIdentity = "replacement-row") +assertEquals("focused-row#focused-item", state.content?.id) +assertEquals("focused-row#focused-item", state.candidate?.id) +``` + +- [x] **Step 2: Write phone/TV rating and mixed-field RED tests** + +For both clients assert: + +```kotlin +ratingImdb = 11.0 +``` + +produces no rating token. Add two independent cases: + +```kotlin +ratingImdb = Double.NaN +durationSeconds = 7_200.0 +``` + +retains `"2h"`, while: + +```kotlin +ratingImdb = 8.4 +durationSeconds = Double.NaN +``` + +retains `"8.4"`. + +- [x] **Step 3: Run RED** + +```bash +./gradlew --no-daemon \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*TvFocusMarqueeEnrichmentTest' \ + --max-workers=2 +``` + +Expected: the row-identity and upper-bound assertions fail. + +- [x] **Step 4: Implement minimal marquee arbitration** + +Keep `seedInitialPreview` as the boundary. Build `next` first, ignore seeds once +`focusedMarqueeId != null`, and otherwise replace a different page-entry +candidate/content identity: + +```kotlin +fun seedInitialPreview(item: SectionItem, rowTitle: String, rowIdentity: String = rowTitle) { + if (focusedMarqueeId != null) return + val next = TvMarqueeContent.from(item, rowTitle, rowIdentity) + if (candidate?.id == next.id || content?.id == next.id) return + candidate = next +} +``` + +Include `initialMarqueeSeed?.rowIdentity` in the `LaunchedEffect` key and call +`seedInitialPreview` without an outer `marquee.content == null` gate. + +- [x] **Step 5: Implement bounded shared rating helpers** + +In each client boundary use a small private helper equivalent to: + +```kotlin +private fun validImdbRating(rating: Double?): Double? = + rating?.takeIf { it.isFinite() && it > 0.0 && it <= 10.0 } +``` + +Route both TV episode/non-episode branches through one TV `ratingToken` helper +to remove the duplicated filter. + +- [x] **Step 6: Run GREEN** + +Repeat the Task 2 focused command. Expected: all selected tests pass. + +- [x] **Step 7: Commit Task 2** + +```bash +git add androidApp/src/androidMain androidApp/src/androidUnitTest \ + androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "fix(android): bound hero metadata and marquee identity" +``` + +### Task 3: Compiler-visible Watch Together delivery invariants + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt:58-64` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomSyncController.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomSyncController.kt:204-234` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvRoomDeliveryKeySourceTest.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/RoomDeliveryKeySourceTest.kt` + +**Interfaces:** +- Consumes: `RoomDeliveryLatch.isServerAttached`. +- Produces: identical delivery decisions with no nullable-key dereference or force-unwrapped reporting key. + +- [x] **Step 1: Strengthen nullable-key characterization** + +Add latch cases asserting `false` for: + +```kotlin +isServerAttached(key = null, echo = matchingEcho) +isServerAttached(key = validKey, echo = null) +isServerAttached(key = validKey, echo = wrongEpochEcho) +``` + +- [x] **Step 2: Write source-contract RED tests** + +Assert each room-sync controller reporting block does not contain +`deliveryKey!!` and does contain an explicit `deliveryKey != null` guard before +`stateReport`. + +- [x] **Step 3: Run RED** + +```bash +./gradlew --no-daemon \ + :shared:test \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*RoomDeliveryLatchTest' \ + --tests '*RoomDeliveryKeySourceTest' \ + --tests '*TvRoomDeliveryKeySourceTest' \ + --max-workers=2 +``` + +Historical pre-fix result: latch behavior remained green while both +source-contract tests failed because their reporting blocks used +`deliveryKey!!`. The current tree binds a non-null key before state reporting. + +- [x] **Step 4: Make nullability explicit without semantic changes** + +Use: + +```kotlin +key != null && + isAttached(key) && + echo != null && + echo.connectionGeneration == key.connectionGeneration +``` + +in the latch. In both controllers require a non-null local key before +`isServerAttached` and pass `key.playbackSessionId` to `stateReport`. + +- [x] **Step 5: Run GREEN and neighboring room-sync tests** + +Run the Task 3 command plus `*RoomSyncStateReportGateTest`. Expected: all pass. + +- [x] **Step 6: Commit Task 3** + +```bash +git add shared/src/commonMain shared/src/commonTest \ + androidApp/src/androidMain androidApp/src/androidUnitTest \ + androidTvApp/src/androidMain androidTvApp/src/androidUnitTest +git commit -m "refactor(watch-together): make delivery keys explicit" +``` + +### Task 4: Behavior-preserving review cleanups + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt:15-34` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt:80-221` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/libraries/LibrariesScreen.kt:688-708` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt:47-177` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt:33-45` +- Verify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt` +- Verify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt` + +**Interfaces:** +- Consumes: `CoroutineDispatcher`, identity transition generations, and `CatalogCacheWriteLease`. +- Produces: injectable home-request dispatcher and private helpers that preserve existing generation and cache-write decisions. + +- [x] **Step 1: Establish the characterization-test baseline** + +```bash +./gradlew --no-daemon :shared:test \ + --tests '*SectionRepositoryCacheTest' \ + --tests '*CatalogRepositoryDetailCacheTest' \ + --tests '*PersonalDataRepositoryCacheTest' \ + --max-workers=2 +``` + +Expected: all selected tests pass before refactoring. + +- [x] **Step 2: Inject the home request dispatcher** + +Add a constructor parameter with the existing runtime default: + +```kotlin +private val homeRequestDispatcher: CoroutineDispatcher = Dispatchers.Default +``` + +and use it in `homeRequestScope`. Update gated concurrency tests to pass a +`StandardTestDispatcher(testScheduler)` and extract one local helper that builds +the gated `MockEngine`/repository without changing assertions. + +- [x] **Step 3: Extract the phone library identity comparison** + +Add: + +```kotlin +private fun CatalogRequestIdentity.matches(state: LibrariesUiState): Boolean = + state.selectedLibraryId == libraryId && + state.browseSort == browseSort && + state.selectedNamePrefix == selectedNamePrefix && + state.filterState == filterState +``` + +Keep request/query generation comparisons in their respective methods. +Do not retain or cancel a recommendation `Job`; shared repository work must +remain independent of one superseded UI caller. + +- [x] **Step 4: Extract repository-local guarded-write helpers** + +In both repositories add the private pattern: + +```kotlin +private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, +) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } +} +``` + +Route existing cache write sites through it without moving network calls or +changing success/fallback behavior. + +- [x] **Step 5: Re-run characterization tests** + +Repeat the Task 4 baseline command. Expected: all selected tests pass with the +same assertions. + +- [x] **Step 6: Commit Task 4** + +```bash +git add shared/src/commonMain shared/src/commonTest androidApp/src/androidMain +git commit -m "refactor(android): clarify reviewed request guards" +``` + +### Task 5: Documentation and privacy corrections + +**Files:** +- Modify: `.superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md` +- Modify: `docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md:71` +- Modify: `docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md:151,245` + +**Interfaces:** +- Consumes: current helper and focus-routing names. +- Produces: reproducible repository-relative evidence and implementation-accurate plan text. + +- [x] **Step 1: Replace local paths** + +Replace the worktree with `fix/tv-for-you-cold-navigation worktree` and replace +Desktop artifact paths with the artifact filenames while retaining hashes and +signing notes. + +- [x] **Step 2: Correct stale wording** + +Change `TV focused` to `TV-focused`, replace the stale +`FeaturedCarousel.metadataChips` reference with `featuredHeroMetadata`, and +update focus pseudocode to use `requestMenuFocusIfAvailable` plus the +`(focusRequest, focusRequestTarget)` handled identity. + +- [x] **Step 3: Verify documentation** + +```bash +rg -n --pcre2 '/(Users|home)/[^/[:space:]]+' \ + .superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md +rg -n 'FeaturedCarousel\\.metadataChips|TV focused|requestMenuFocus\\(selectedMenuFocusTarget\\)' \ + docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md \ + docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md +git diff --check +``` + +Expected: both `rg` commands return no matches and `git diff --check` passes. + +- [x] **Step 4: Commit Task 5** + +```bash +git add .superpowers/sdd/2026-07-28-android-tv-active-header-focus-editorial-hero/task-4-report.md \ + docs/reviews/2026-07-27-android-tv-navigation-remediation-executive-summary.md \ + docs/superpowers/plans/2026-07-28-android-tv-active-header-focus-editorial-hero.md +git commit -m "docs: resolve PR 126 review findings" +``` + +### Task 6: Full verification, independent review, and PR update + +**Files:** +- Verify: all Task 1-5 files. +- Update remotely: PR #126 branch and description/check state only; do not merge. + +**Interfaces:** +- Consumes: all correction commits. +- Produces: reviewed branch with fresh focused, full, supply-chain, and release evidence. + +- [x] **Step 1: Run complete unit gates** + +```bash +./gradlew --no-daemon \ + :shared:test \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --max-workers=2 --rerun-tasks +``` + +Expected: exit 0 with no failed tests. + +- [x] **Step 2: Run supply-chain policy** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both exit 0. + +- [x] **Step 3: Run phone and TV release compilation** + +```bash +./gradlew --no-daemon \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 +``` + +Expected: `BUILD SUCCESSFUL`; these artifacts are verification-only and are not +installed or deployed. + +- [x] **Step 4: Perform diff and scope checks** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, no uncommitted changes, and only the documented +PR #126 plus review-remediation commits. + +- [x] **Step 5: Obtain independent focused review** + +Request review of `f822d22c..HEAD` against this plan and the design spec. Require +explicit assessment of the marquee real-focus invariant, For You reset, +Alphabet fallback, request-sharing non-cancellation, Watch Together delivery +semantics, and test adequacy. Fix any critical or important finding with a new +focused RED/GREEN cycle. + +- [x] **Step 6: Push and update PR #126** + +Push `fix/tv-for-you-cold-navigation`, update the PR verification summary with +fresh commands, and leave the PR open and unmerged. Record which CodeRabbit +suggestion was intentionally rejected because cancelling a UI caller must not +cancel shared repository work. diff --git a/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md b/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md new file mode 100644 index 000000000..3c261a1f0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-subtitle-aspect-recenter.md @@ -0,0 +1,550 @@ +# Subtitle Aspect-Mode Recentring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the Android phone and TV subtitle canvas aligned with the final visible video viewport when switching among Fit, Fill/Zoom, and Stretch. + +**Architecture:** `SubtitleManager` remains the only subtitle-geometry owner. A +pure mode-aware selector rejects stale fitted content-frame geometry while +preserving a matching post-layout content-frame rectangle so Zoom retains the +visible viewport's parent-local offset. The existing per-`PlayerView` +synchronizer performs one lifecycle-owned pre-draw reconciliation after each +explicit sync request and removes that observer on completion or disposal. + +**Tech Stack:** Kotlin 2.1, Android Views, Media3 `PlayerView`/`AspectRatioFrameLayout`, Robolectric/JUnit, Gradle 8.12. + +## Global Constraints + +- Apply the shared correction to Android phone and Android TV; phone is the confirmed reproduction. +- Fit aligns the subtitle canvas with the fitted video rectangle. +- Phone Fill/Media3 Zoom and phone Stretch/Media3 Fill align the canvas with the full visible player viewport. +- Preserve authored ASS/SSA and PGS positions relative to the canvas; do not rewrite individual cue coordinates. +- Preserve existing letterbox detection, title-safe insets, subtitle appearance, timing, track selection, playback state, networking, and persisted settings. +- Do not add polling, arbitrary delays, a second renderer, server changes, protocol changes, or transcoding changes. +- A delayed reconciliation must not mutate a detached or replaced `PlayerView`. +- Do not install on the Shield without a separate explicit request. + +--- + +### Task 1: Make subtitle canvas selection resize-mode aware + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:445-492,662-673` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt:143-251` + +**Interfaces:** +- Consumes: `SubtitleVideoRect`, Media3 resize-mode constants, `displayedSubtitleVideoRect(...)`, and the current content-frame rectangle. +- Produces: `internal fun selectSubtitleCanvasRect(resizeMode: Int, contentFrameRect: SubtitleVideoRect?, displayedVideoRect: SubtitleVideoRect): SubtitleVideoRect`. + +- [x] **Step 1: Add failing stale-frame regression tests** + +Add these tests to `SubtitleManagerAppearanceTest`: + +```kotlin +@Test +fun zoomIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 0, top = 236, width = 2404, height = 1352) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2404, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) +} + +@Test +fun stretchIgnoresStaleFittedContentFrameAndUsesFullViewport() { + val staleFit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val fullViewport = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + assertEquals( + fullViewport, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL, + contentFrameRect = staleFit, + displayedVideoRect = fullViewport, + ), + ) +} + +@Test +fun fitContinuesToUsePostLayoutContentFrame() { + val fittedFrame = SubtitleVideoRect(left = 0, top = 0, width = 1920, height = 1080) + val computedFallback = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + + assertEquals( + fittedFrame, + selectSubtitleCanvasRect( + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT, + contentFrameRect = fittedFrame, + displayedVideoRect = computedFallback, + ), + ) +} + +@Test +fun repeatedModeSelectionDoesNotRetainPreviousCanvas() { + val fit = SubtitleVideoRect(left = 240, top = 0, width = 1920, height = 1080) + val full = SubtitleVideoRect(left = 0, top = 0, width = 2400, height = 1080) + + val fill = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + fit, + full, + ) + val stretch = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FILL, + fit, + full, + ) + val restoredFit = selectSubtitleCanvasRect( + AspectRatioFrameLayout.RESIZE_MODE_FIT, + fit, + fit, + ) + + assertEquals(full, fill) + assertEquals(full, stretch) + assertEquals(fit, restoredFit) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --max-workers=2 --no-daemon +``` + +Expected: compilation fails because `selectSubtitleCanvasRect` does not exist. + +- [x] **Step 3: Implement the minimal mode-aware selector** + +Add beside `displayedSubtitleVideoRect`: + +```kotlin +internal fun selectSubtitleCanvasRect( + resizeMode: Int, + contentFrameRect: SubtitleVideoRect?, + displayedVideoRect: SubtitleVideoRect, +): SubtitleVideoRect = when (resizeMode) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM, + AspectRatioFrameLayout.RESIZE_MODE_FILL, + -> contentFrameRect?.takeIf { + it.width == displayedVideoRect.width && + it.height == displayedVideoRect.height + } ?: displayedVideoRect + else -> contentFrameRect ?: displayedVideoRect +} +``` + +Change `SubtitleVideoRectSync.applyRect` to compute both inputs before applying +letterbox and title-safe insets: + +```kotlin +val resizeMode = playerView.resizeMode +val displayedVideoRect = displayedSubtitleVideoRect( + viewWidth = playerView.width, + viewHeight = playerView.height, + videoWidth = videoSize.width, + videoHeight = videoSize.height, + videoPixelWidthHeightRatio = videoSize.pixelWidthHeightRatio, + resizeMode = resizeMode, +) +val rect = selectSubtitleCanvasRect( + resizeMode = resizeMode, + contentFrameRect = playerView.contentFrameSubtitleRect(), + displayedVideoRect = displayedVideoRect, +).insetByLetterbox(letterbox).insetByTitleSafe(titleSafeFraction) +``` + +For Zoom and Fill, a content-frame rectangle is used only when its dimensions +match the visible viewport. This preserves the post-layout parent-local offset +of an oversized, negatively positioned Zoom frame. A stale fitted rectangle +does not match, so selection falls back to `displayedVideoRect`. + +- [x] **Step 4: Run the focused class and verify GREEN** + +Run the Step 2 command. + +Expected: `SubtitleManagerAppearanceTest` passes with zero failures. + +- [x] **Step 5: Commit the independently testable geometry correction** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): recenter canvas for fill modes" +``` + +--- + +### Task 2: Reconcile once after layout and cancel stale callbacks + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt:270-282,563-704` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt` + +**Interfaces:** +- Consumes: Task 1's `selectSubtitleCanvasRect(...)`. +- Produces: `SubtitleVideoRectSync.updateAndReconcileAfterLayout()`; at most one + pre-draw observer per `PlayerView`, removed after execution or during disposal. + +- [x] **Step 1: Add failing lifecycle regression tests** + +Add Robolectric tests that mount a real `PlayerView` in an `Activity`, invoke +`SubtitleManager.syncSubtitleVideoBounds`, drive layout and pre-draw, and assert +the actual `SubtitleView` layout parameters for Fit → Zoom, Fit → Fill, +repeated switching, and Zoom → Fit. Count completed reconciliations so deleting +the coalescing guard fails the suite, and use sentinel layout parameters to +prove a detached view cannot be mutated by a pending observer: + +```kotlin +@Test +fun repeatedExplicitSyncsRunOnePostLayoutReconciliation() { + val mounted = MountedSubtitleCanvas() + var reconciliations = 0 + mounted.manager.postLayoutReconciliationObserver = { reconciliations++ } + + repeat(5) { + mounted.manager.syncSubtitleVideoBounds(mounted.playerView) + } + mounted.dispatchPreDraw() + + assertEquals(1, reconciliations) +} + +@Test +fun detachCancelsPendingPostLayoutReconciliationWithoutMutatingLayout() { + val mounted = MountedSubtitleCanvas() + val sentinel = FrameLayout.LayoutParams(17, 19) + mounted.subtitleView.layoutParams = sentinel + + mounted.detach() + mounted.dispatchPreDraw() + + assertSame(sentinel, mounted.subtitleView.layoutParams) +} +``` + +The execution observer is instance-local, internal, and null by default. It +adds only a null check in production and does not retain a `PlayerView`. + +- [x] **Step 2: Run the focused class and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --max-workers=2 --no-daemon +``` + +Expected: mounted transition assertions fail before the content-frame offset +and lifecycle-owned post-layout reconciliation are implemented. + +- [x] **Step 3: Implement one lifecycle-owned post-layout callback** + +In `SubtitleVideoRectSync`, register one removable pre-draw observer: + +```kotlin +private var pendingPreDrawObserver: ViewTreeObserver? = null +private val postLayoutUpdate = ViewTreeObserver.OnPreDrawListener { + clearPendingPostLayoutUpdate() + if (!isDisposed) { + update() + onPostLayoutReconciled() + } + true +} + +fun updateAndReconcileAfterLayout() { + update() + val playerView = playerViewRef.get() ?: return + if (isDisposed) return + pendingPreDrawObserver?.let { observer -> + if (observer.isAlive) return + pendingPreDrawObserver = null + } + val observer = playerView.viewTreeObserver + if (!observer.isAlive) return + pendingPreDrawObserver = observer + observer.addOnPreDrawListener(postLayoutUpdate) +} +``` + +Change `SubtitleManager.syncSubtitleVideoBounds` to call: + +```kotlin +sync.updateAndReconcileAfterLayout() +``` + +In `dispose`, remove the observer before clearing listeners: + +```kotlin +clearPendingPostLayoutUpdate() +``` + +`clearPendingPostLayoutUpdate()` removes the listener from the exact +`ViewTreeObserver` used for registration and clears the reference. Ordinary +layout/video-size callbacks continue calling `update()` directly, keeping the +extra reconciliation bounded to explicit screen sync requests. + +- [x] **Step 4: Run focused tests and verify GREEN** + +Run the Step 2 command. + +Expected: all `SubtitleManagerAppearanceTest` tests pass. + +- [x] **Step 5: Run neighboring subtitle geometry tests** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest \ + --tests org.prairieserver.prairie.common.player.LetterboxInsetTest \ + --tests org.prairieserver.prairie.common.player.TitleSafeInsetTest \ + --max-workers=2 --no-daemon +``` + +Expected: zero failures. + +- [x] **Step 6: Commit the lifecycle correction** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(subtitles): reconcile canvas after aspect layout" +``` + +--- + +### Task 3: Lock phone/TV wiring and verify release behaviour + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt` +- Verify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt:1085-1123` +- Verify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt:1775-1793,3088-3113` + +**Interfaces:** +- Consumes: existing `SubtitleManager.syncSubtitleVideoBounds(PlayerView)`, phone resize-mode mapping, and TV `applyPlayerViewVideoFillMode`. +- Produces: platform source-contract tests ensuring each resize update is immediately followed by shared subtitle reconciliation. + +- [x] **Step 1: Add phone and TV source-contract tests** + +Phone: + +```kotlin +class SubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryIndex = source.indexOf("PlayerView(ctx).apply {") + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf("update = { view ->", factoryIndex) + require(updateIndex > factoryIndex) { + "PlayerView update lambda is missing or misordered" + } + val endIndex = source.indexOf("modifier = Modifier", updateIndex) + require(endIndex > updateIndex) { + "PlayerView update lambda terminator is missing or misordered" + } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterResizeModeUpdate() { + val source = source( + "org/prairieserver/prairie/android/ui/screens/player/PlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + assertTrue(update.contains("view.resizeMode = resizeMode")) + assertTrue(update.contains("subtitleManager.syncSubtitleVideoBounds(view)")) + assertTrue( + update.indexOf("view.resizeMode = resizeMode") < + update.indexOf("subtitleManager.syncSubtitleVideoBounds(view)") + ) + } +} +``` + +TV: + +```kotlin +class TvSubtitleAspectModeWiringSourceTest { + private fun source(path: String): String { + val moduleRelative = File("src/androidMain/kotlin/$path") + val projectRelative = File("androidTvApp/src/androidMain/kotlin/$path") + return (moduleRelative.takeIf(File::exists) ?: projectRelative).readText() + } + + private fun playerViewUpdateBlock(source: String): String { + val factoryIndex = source.indexOf(") as PlayerView).apply {") + require(factoryIndex >= 0) { "PlayerView factory anchor is missing" } + val androidViewIndex = source.lastIndexOf("AndroidView(", factoryIndex) + require(androidViewIndex >= 0) { "Enclosing AndroidView is missing" } + val updateIndex = source.indexOf("update = { view ->", factoryIndex) + require(updateIndex > factoryIndex) { + "PlayerView update lambda is missing or misordered" + } + val endIndex = source.indexOf( + "if (!isInPictureInPictureMode", + updateIndex, + ) + require(endIndex > updateIndex) { + "PlayerView update lambda terminator is missing or misordered" + } + return source.substring(updateIndex, endIndex) + } + + @Test + fun playerViewReconcilesSubtitlesAfterFillModeUpdate() { + val source = source( + "org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt" + ) + val update = playerViewUpdateBlock(source) + + val aspectCall = "applyPlayerViewVideoFillMode(view, state.videoFillMode)" + val subtitleCall = "subtitleManager.syncSubtitleVideoBounds(view)" + assertTrue(update.contains(aspectCall)) + assertTrue(update.contains(subtitleCall)) + assertTrue(update.indexOf(aspectCall) < update.indexOf(subtitleCall)) + } +} +``` + +Both files import `java.io.File`, `kotlin.test.Test`, and +`kotlin.test.assertTrue`. + +- [x] **Step 2: Prove the source tests detect reversed ordering** + +Temporarily reverse each extracted ordering assertion (`<` to `>`) and run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleAspectModeWiringSourceTest' \ + --max-workers=2 --no-daemon +``` + +Expected: both tests fail on their ordering assertion. Restore `<` before +continuing. + +- [x] **Step 3: Run the source tests GREEN** + +Run the Step 2 command after restoring the intended assertions. + +Expected: both tests pass. + +- [x] **Step 4: Run the complete relevant feature gate** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SubtitleManagerAppearanceTest' \ + --tests '*LetterboxInsetTest' \ + --tests '*TitleSafeInsetTest' \ + --tests '*SubtitleAspectModeWiringSourceTest' \ + --max-workers=2 --no-daemon +``` + +Expected: zero failures. + +- [x] **Step 5: Run full debug unit tests** + +```bash +./gradlew testDebugUnitTest --max-workers=2 --no-daemon +``` + +Expected: build succeeds with zero test failures. + +- [x] **Step 6: Run supply-chain and release compilation gates** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +./gradlew \ + :androidApp:assembleRelease \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 --no-daemon +``` + +Expected: policy scripts exit zero and both minified release assemblies succeed. + +- [ ] **Step 7: Verify on the physical Pixel only — blocked: device disconnected** + +First confirm serial `58211FDCQ000CU`, compare the candidate and installed +package/version/signing certificate, and stop if the signer differs. Then use +only: + +```bash +adb -s 58211FDCQ000CU install -r \ + androidApp/build/outputs/apk/release/androidApp-universal-release.apk +adb -s 58211FDCQ000CU shell am start -W \ + -n org.prairieserver.prairie/org.prairieserver.prairie.android.MainActivity +``` + +With Bluetooth earbuds disconnected, play a title containing centred text +subtitles and switch Fit → Fill → Stretch → Fit. Capture screenshots after +layout settles and verify: + +- Fill and Stretch centre the subtitle canvas in the full visible viewport. +- Returning to Fit restores the fitted-video canvas. +- repeated switching does not retain an earlier offset; +- subtitle timing and vertical position remain stable; +- no immediate fatal exception, ANR, or player error appears in Pixel logcat. + +Do not issue any ADB command to the Shield or an emulator. + +- [x] **Step 8: Request independent focused review** + +Review only the branch diff against: + +- mode-aware stale-frame rejection; +- authored cue preservation; +- bounded callback ownership and detach cancellation; +- phone/TV wiring; +- absence of unrelated playback changes. + +Address every substantive finding test-first and rerun Tasks 1-3's focused +gates. + +- [x] **Step 9: Commit verification contracts** + +```bash +git add \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/player/SubtitleAspectModeWiringSourceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleAspectModeWiringSourceTest.kt +git commit -m "test(subtitles): lock aspect recenter wiring" +``` + +- [x] **Step 10: Final diff and branch verification** + +```bash +git diff --check origin/main...HEAD +git status --short +git log --oneline origin/main..HEAD +``` + +Expected: no whitespace errors, clean worktree, and only the approved spec, +plan, shared geometry fix, lifecycle reconciliation, and platform tests. diff --git a/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md b/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md new file mode 100644 index 000000000..129a3a912 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-phone-director-credit-and-review-hardening.md @@ -0,0 +1,696 @@ +# Phone Director Credit and Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add PR #129's movie-only “Directed by …” hero credit to Android phone with one shared phone/TV formatting rule, add the missing PR #128/#129 regressions, and deterministically fix the unrelated hosted purger-test race. + +**Architecture:** A pure `movieDirectorCredit(ItemDetail): String?` presentation helper will live in `android-shared` and serve both Android clients. Phone and TV keep their platform-specific Compose rendering but use the shared string. Runtime work is characterization coverage only, while the purger correction is confined to explicit test-harness gates and does not change production semantics. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose, Kotlin coroutines and `CompletableDeferred`, Kotlin test/JUnit, Gradle, repository shell supply-chain checks. + +## Global Constraints + +- Show the credit on movie detail pages only. +- Place it directly below the synopsis and optional description translation, and directly above the facts row. +- Match only crew jobs whose trimmed value equals `Director`, case-insensitively. +- Trim names, remove blanks, preserve first occurrence while de-duplicating, and show at most three names. +- Do not change server APIs, catalog models, navigation, cast/crew sections, or production purge behavior. +- Do not widen timeouts or add retries to conceal the hosted purger-test race. +- Preserve PR #128's existing positive-runtime preference and duration fallback. +- Update PR #129, but do not merge it. + +--- + +### Task 1: Shared Director-Credit Rule and TV Migration + +**Files:** +- Create: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt` +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: `org.prairieserver.prairie.model.catalog.ItemDetail` and its ordered `crew: List`. +- Produces: public pure function `fun movieDirectorCredit(detail: ItemDetail): String?`. +- Produces: TV hero wiring through `directorText = movieDirectorCredit(detail)`. + +- [ ] **Step 1: Write the failing shared formatting tests** + +Create `DirectorCreditTest.kt` with concrete movie, non-movie, exact-job, cleanup, de-duplication, and cap cases: + +```kotlin +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.CrewMember +import org.prairieserver.prairie.model.catalog.ItemDetail +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DirectorCreditTest { + @Test + fun movieCreditMatchesExactDirectorJobAndCleansNames() { + val detail = ItemDetail( + contentId = "movie-1", + type = "MoViE", + title = "Movie", + crew = listOf( + CrewMember(name = " Alice ", job = " director "), + CrewMember(name = "Camera", job = "Director of Photography"), + CrewMember(name = "", job = "Director"), + CrewMember(name = "Alice", job = "DIRECTOR"), + CrewMember(name = "Bob", job = "Director"), + ), + ) + + assertEquals("Directed by Alice, Bob", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditKeepsServerOrderAndCapsAtThreeNames() { + val detail = ItemDetail( + contentId = "movie-2", + type = "movie", + title = "Movie", + crew = listOf("One", "Two", "Three", "Four").map { + CrewMember(name = it, job = "Director") + }, + ) + + assertEquals("Directed by One, Two, Three", movieDirectorCredit(detail)) + } + + @Test + fun movieCreditIsAbsentForNonMoviesOrMissingDirectors() { + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "episode-1", + type = "episode", + title = "Episode", + crew = listOf(CrewMember(name = "Alice", job = "Director")), + ), + ), + ) + assertNull( + movieDirectorCredit( + ItemDetail( + contentId = "movie-3", + type = "movie", + title = "Movie", + crew = listOf(CrewMember(name = "Camera", job = "Cinematographer")), + ), + ), + ) + } +} +``` + +- [ ] **Step 2: Run the shared test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.ui.DirectorCreditTest' \ + --no-daemon +``` + +Expected: compilation fails because `movieDirectorCredit` does not exist. + +- [ ] **Step 3: Implement the minimal shared rule** + +Create `DirectorCredit.kt`: + +```kotlin +package org.prairieserver.prairie.common.ui + +import org.prairieserver.prairie.model.catalog.ItemDetail + +fun movieDirectorCredit(detail: ItemDetail): String? { + if (!detail.type.equals("movie", ignoreCase = true)) return null + val names = detail.crew + .asSequence() + .filter { it.job?.trim().equals("Director", ignoreCase = true) } + .map { it.name.trim() } + .filter { it.isNotEmpty() } + .distinct() + .take(3) + .toList() + return names.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "Directed by ", separator = ", ") +} +``` + +- [ ] **Step 4: Write the failing TV wiring/placement test** + +Create `TvDirectorCreditSourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class TvDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + + @Test + fun tvMovieHeroUsesSharedDirectorCredit() { + assertTrue(screen.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun tvCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} +``` + +- [ ] **Step 5: Run the TV source test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.detail.TvDirectorCreditSourceTest' \ + --no-daemon +``` + +Expected: `tvMovieHeroUsesSharedDirectorCredit` fails because PR #129 still calls `TvDetailMetadata.directorText`. + +- [ ] **Step 6: Migrate TV to the shared helper** + +Delete `TvDetailMetadata.directorText`. Import `org.prairieserver.prairie.common.ui.movieDirectorCredit` in `TvItemDetailScreen.kt` and replace: + +```kotlin +directorText = TvDetailMetadata.directorText(detail), +``` + +with: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +- [ ] **Step 7: Run shared and TV tests and verify GREEN** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --no-daemon +``` + +Expected: all director formatting and TV wiring tests pass. + +- [ ] **Step 8: Commit Task 1** + +```bash +git add \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/DirectorCredit.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/ui/DirectorCreditTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt +git commit -m "refactor(detail): share movie director credit" +``` + +--- + +### Task 2: Phone Movie-Hero Director Credit + +**Files:** +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt` +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: Task 1's `fun movieDirectorCredit(detail: ItemDetail): String?`. +- Produces: optional `directorText: String? = null` parameter on `DetailHero`. +- Produces: movie-only phone wiring `directorText = movieDirectorCredit(detail)`. + +- [ ] **Step 1: Write the failing phone wiring/placement tests** + +Create `PhoneDirectorCreditSourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.android.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +class PhoneDirectorCreditSourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt", + ).readText() + private val movie = File( + "src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt", + ).readText() + + @Test + fun phoneMovieHeroUsesSharedDirectorCredit() { + assertTrue(hero.contains("directorText: String? = null")) + assertTrue(movie.contains("directorText = movieDirectorCredit(detail)")) + } + + @Test + fun phoneCreditStaysBetweenTranslationAndFacts() { + val translation = hero.indexOf("translation?.invoke()") + val director = hero.indexOf("directorText?.takeIf") + val facts = hero.indexOf("if (factsLine.isNotEmpty())", startIndex = director) + assertTrue(translation >= 0 && translation < director && director < facts) + } +} +``` + +- [ ] **Step 2: Run the phone test and verify RED** + +Run: + +```bash +./gradlew :androidApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.android.ui.screens.detail.PhoneDirectorCreditSourceTest' \ + --no-daemon +``` + +Expected: both tests fail because phone has no director hero parameter, rendering, or shared-helper call. + +- [ ] **Step 3: Add the minimal phone hero rendering** + +In `DetailSharedComponents.kt`, add this parameter immediately before `translation`: + +```kotlin +directorText: String? = null, +``` + +Immediately after `translation?.invoke()` and before the facts condition, render: + +```kotlin +directorText?.takeIf { it.isNotBlank() }?.let { line -> + Text( + text = line, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = DetailTertiaryText, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) +} +``` + +In `MovieDetailContent.kt`, import: + +```kotlin +import org.prairieserver.prairie.common.ui.movieDirectorCredit +``` + +and pass: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +to `DetailHero`. Do not change the `SeriesDetailContent` call; the optional default keeps all non-movie paths unchanged. + +- [ ] **Step 4: Run the phone and shared director tests and verify GREEN** + +Run: + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --no-daemon +``` + +Expected: shared formatting and phone wiring/placement tests pass. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/PhoneDirectorCreditSourceTest.kt +git commit -m "feat(phone): show movie director credit" +``` + +--- + +### Task 3: PR #128 Runtime Preference and Fallback Coverage + +**Files:** +- Modify: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt` + +**Interfaces:** +- Consumes: existing `featuredHeroMetadata(SectionItem): List`. +- Consumes: existing `TvMarqueeContent.from(SectionItem, String): TvMarqueeContent`. +- Produces: characterization coverage only; no production interface changes. + +- [ ] **Step 1: Add explicit phone preference and fallback tests** + +Append to `FeaturedHeroMetadataTest`: + +```kotlin +@Test +fun catalogRuntimeWinsOverPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + ) + + assertEquals(listOf("2h 5m"), chips.map { it.label }) +} + +@Test +fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnPhone() { + val chips = featuredHeroMetadata( + SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + ) + + assertEquals(listOf("1h 56m"), chips.map { it.label }) +} +``` + +- [ ] **Step 2: Add explicit TV preference and fallback tests** + +Append to `TvFocusMarqueeModelTest`: + +```kotlin +@Test +fun catalogRuntimeWinsOverPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime", + type = "movie", + title = "Movie", + runtime = 125, + durationSeconds = 60.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("2h 5m"), content.metaParts) +} + +@Test +fun invalidCatalogRuntimeFallsBackToPlaybackDurationOnTv() { + val content = TvMarqueeContent.from( + item = SectionItem( + contentId = "movie-runtime-fallback", + type = "movie", + title = "Movie", + runtime = 0, + durationSeconds = 6_960.0, + ), + rowTitle = "Row", + ) + + assertEquals(listOf("1h 56m"), content.metaParts) +} +``` + +- [ ] **Step 3: Prove the characterization tests detect regression** + +Temporarily replace the catalog-runtime branch in both production metadata builders with duration-only selection, without staging the mutation. Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --no-daemon +``` + +Expected: both `catalogRuntimeWins...` tests fail with `1m` instead of `2h 5m`. Restore the two production files exactly with: + +```bash +git restore \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadata.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModel.kt +``` + +- [ ] **Step 4: Run the runtime tests against real production code** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --no-daemon +``` + +Expected: all phone and TV hero-runtime tests pass. + +- [ ] **Step 5: Commit Task 3** + +```bash +git add \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/home/FeaturedHeroMetadataTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvFocusMarqueeModelTest.kt +git commit -m "test(home): cover hero runtime preference" +``` + +--- + +### Task 4: Deterministic Purger Second-Pass Test Harness + +**Files:** +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt` + +**Interfaces:** +- Consumes: existing `OrphanedServerDataPurger.start(): Job`. +- Produces: explicit test-only `secondPurgeStarted` and `allowSecondPurge` gates. +- Does not modify `OrphanedServerDataPurger` or any production source. + +- [ ] **Step 1: Preserve the concrete RED evidence** + +Record the hosted assertion already observed: + +```text +OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge +removed server was unexpectedly part of the startup orphan snapshot +OrphanedServerDataPurgerTest.kt:410 +``` + +Run the exact test once before editing: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: it may pass locally because the defect is an ordering race; the hosted failure is the RED evidence. Do not broaden production scope to force reproduction. + +- [ ] **Step 2: Add deterministic second-pass deletion gates** + +In the test, create: + +```kotlin +val secondPurgeStarted = CompletableDeferred() +val allowSecondPurge = CompletableDeferred() +``` + +Change the `purgeRows` branch for `serverId` so the gate occurs before deletion: + +```kotlin +purgeRows = { orphanId -> + if (orphanId == "preexisting-orphan") { + initialPurgeStarted.complete(Unit) + finishInitialPurge.await() + } + if (orphanId == serverId) { + secondPurgeStarted.complete(Unit) + allowSecondPurge.await() + } + db.serverPurgeDao().deleteAllRowsForServer(orphanId) + if (orphanId == "preexisting-orphan") { + initialRowsPurged.complete(Unit) + } + if (orphanId == serverId) { + rowsPurged.complete(Unit) + } +}, +``` + +After `initialRowsPurged.await()`, wait for the second pass before asserting: + +```kotlin +secondPurgeStarted.await() +assertTrue(observer.isActive, "purge observer stopped: $observerFailure") +assertNull(db.downloadDao().get("preexisting-orphan", "p1", 11)) +assertTrue( + db.downloadDao().get(serverId, "p1", 10) != null, + "second-pass deletion must remain gated until the snapshot assertion completes", +) + +allowSecondPurge.complete(Unit) +rowsPurged.await() +assertNull(db.downloadDao().get(serverId, "p1", 10)) +observer.cancel() +observer.join() +``` + +Remove the old ungated assertion/wait sequence. Do not add sleeps, retries, or timeout changes. + +- [ ] **Step 3: Run repeated exact-test verification** + +Run the exact command five times: + +```bash +for run in 1 2 3 4 5; do + ./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest.removal during startup scan triggers a second purge' \ + --max-workers=2 --rerun-tasks --no-daemon || exit 1 +done +``` + +Expected: 5/5 passes with the observer finishing cleanly. + +- [ ] **Step 4: Run the complete purger test class** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.downloads.OrphanedServerDataPurgerTest' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: the whole class passes. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/downloads/OrphanedServerDataPurgerTest.kt +git commit -m "test(downloads): gate purger second-pass assertion" +``` + +--- + +### Task 5: Full Verification, Review, and PR #129 Update + +**Files:** +- Review: all files changed from `origin/main...HEAD` +- Update remotely: PR #129 branch `RXWatcher:feat/tv-detail-director-credit` + +**Interfaces:** +- Consumes: Tasks 1–4. +- Produces: a clean reviewed PR #129 head with fresh local and hosted evidence. + +- [ ] **Step 1: Run all focused regressions together** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*DirectorCreditTest' \ + --tests '*FeaturedHeroMetadataTest' \ + --tests '*TvFocusMarqueeModelTest' \ + --tests '*OrphanedServerDataPurgerTest' \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: all selected tests pass. + +- [ ] **Step 2: Run supply-chain policy** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit zero. + +- [ ] **Step 3: Run complete unit and compile gates** + +```bash +./gradlew \ + :android-shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Audit scope and cleanliness** + +```bash +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git status --short +git diff --name-only origin/main...HEAD | \ + grep -E '(^server/|proxy|nginx|CatalogModels.kt)' && exit 1 || true +``` + +Expected: no whitespace errors, no unstaged changes, and no server/proxy/catalog-model files. + +- [ ] **Step 5: Request independent review** + +Provide the reviewer: + +- the approved spec; +- this plan; +- `git diff origin/main...HEAD`; +- focused and full verification results; and +- explicit review questions about exact Director matching, phone/TV placement parity, test-only purger ownership, and accidental production behavior changes. + +Resolve every substantive finding with a bounded RED/GREEN fix and rerun the affected focused test. Do not defer confirmed defects. + +- [ ] **Step 6: Push the reviewed branch to PR #129** + +```bash +git push git@github.com:RXWatcher/prairie-android.git \ + HEAD:feat/tv-detail-director-credit +``` + +Expected: the remote head advances without force-push and PR #129 retains its commit ancestry. + +- [ ] **Step 7: Verify PR state and hosted checks** + +```bash +gh pr view 129 --repo Prairie-Server/prairie-android \ + --json state,isDraft,baseRefName,headRefName,headRefOid,mergeable,reviewDecision,url +gh pr checks 129 --repo Prairie-Server/prairie-android --watch +``` + +Expected: PR #129 remains open against `main`; hosted checks finish green. Report review requirements separately. Do not merge. diff --git a/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md b/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md new file mode 100644 index 000000000..51520e722 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-remove-tv-detail-starring-overlay.md @@ -0,0 +1,307 @@ +# Remove Android TV Detail Starring Overlay Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the duplicated floating `Starring …` credit from the Android TV detail hero while preserving the lower cast/crew section and the shared movie director credit. + +**Architecture:** Delete the starring presentation at its existing TV-only boundaries: metadata derivation, detail-screen wiring, and hero rendering. Add one focused source-contract regression that proves those boundaries stay absent without changing cast models, the cast rail, phone UI, or any server-facing behavior. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose for TV, Kotlin Test/JUnit, Gradle, Android Debug Bridge for an emulator-only smoke check. + +## Global Constraints + +- Remove only the Android TV detail hero's floating upper-right `Starring …` overlay. +- Preserve `TvCastCrewSection` as the complete TV cast and crew presentation. +- Preserve the movie-only `Directed by …` credit on Android TV and phone. +- Do not add a replacement shadow, glyph halo, vignette, panel, or inline actor-credit row. +- Preserve existing title-detail content, actions, focus behavior, hero gradients, synopsis, translation, and fact tokens. +- Make no phone production UI, server, API, model, persistence, navigation, playback, protocol, or Apple-client changes. +- Do not refactor unrelated hero layout or metadata formatting. + +--- + +## File Map + +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt`: remove the `starringText` API, upper-right overlay, and obsolete KDoc. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt`: stop deriving and passing the starring credit. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt`: remove the unused `starringText(ItemDetail): String?` formatter. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt`: guard the three production boundaries against reintroducing the duplicate overlay. +- Preserve `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt`: no edits; its presence on the detail page is checked during review and smoke validation. +- Preserve `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt`: no edits; its existing tests continue to protect the director-credit call and ordering. + +### Task 1: Remove the TV Hero Starring Presentation + +**Files:** +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt:55-98,160-190` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt:433-452` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt:80-85` +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt` +- Test unchanged: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDirectorCreditSourceTest.kt` + +**Interfaces:** +- Consumes: `TvDetailHero(...)`, `TvDetailMetadata`, and the existing `TvCastCrewSection(...)` call in `TvItemDetailScreen`. +- Produces: `TvDetailHero(...)` without a `starringText: String?` parameter; `TvDetailMetadata` without `starringText(ItemDetail): String?`. + +- [ ] **Step 1: Write the failing source-contract regression** + +Create `TvStarringOverlaySourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.detail + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvStarringOverlaySourceTest { + private val hero = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt", + ).readText() + private val screen = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt", + ).readText() + private val metadata = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt", + ).readText() + + @Test + fun tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay() { + assertFalse(hero.contains("starringText")) + assertFalse(screen.contains("TvDetailMetadata.starringText(detail)")) + assertFalse(metadata.contains("fun starringText(")) + } + + @Test + fun tvDetailStillRendersTheFullCastSection() { + assertTrue(screen.contains("TvCastCrewSection(")) + } +} +``` + +- [ ] **Step 2: Run the new test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvStarringOverlaySourceTest \ + --no-daemon +``` + +Expected: `tvDetailDoesNotDeriveOrRenderDuplicatedStarringOverlay` fails because the current hero, screen, and metadata formatter still contain `starringText`. The cast-section assertion passes. + +- [ ] **Step 3: Remove the metadata formatter** + +Delete this function from `TvDetailMetadata.kt`: + +```kotlin +fun starringText(detail: ItemDetail): String? { + val names = detail.cast.take(3).map { it.name.trim() }.filter { it.isNotEmpty() } + if (names.isEmpty()) return null + return "Starring ${names.joinToString(", ")}" +} +``` + +Do not alter any other metadata token formatting. + +- [ ] **Step 4: Remove the detail-screen wiring** + +Delete only this argument from the `TvDetailHero` call in `TvItemDetailScreen.kt`: + +```kotlin +starringText = TvDetailMetadata.starringText(detail), +``` + +Leave the adjacent director call intact: + +```kotlin +directorText = movieDirectorCredit(detail), +``` + +Leave the existing `TvCastCrewSection(...)` call unchanged. + +- [ ] **Step 5: Remove the hero API and overlay** + +In `TvDetailHero.kt`: + +1. Remove `starringText: String?` from the `TvDetailHero` parameters. +2. Delete the complete `starringText?.takeIf { ... }` composable block. +3. Rewrite the class KDoc so the layout description ends with the bottom-anchored editorial and action column; remove the claim that a starring credit floats in the upper-right. +4. Keep `TextAlign`, `TextStyle`, `Shadow`, `Offset`, and `widthIn` imports because the remaining title, metadata, and editorial code still uses them. + +The resulting signature around the affected parameters must be: + +```kotlin +factsLine: List, +directorText: String?, +actions: @Composable () -> Unit, +``` + +- [ ] **Step 6: Run the focused tests and verify GREEN** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvStarringOverlaySourceTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvDirectorCreditSourceTest \ + --tests org.prairieserver.prairie.tv.ui.screens.detail.TvDetailMetadataTest \ + --no-daemon +``` + +Expected: all selected tests pass. In particular, the new regression finds no starring derivation or rendering, the existing director-credit source tests keep passing, and unrelated metadata behavior is unchanged. + +- [ ] **Step 7: Inspect the production diff** + +Run: + +```bash +git diff --check +git diff -- \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt +rg -n "starringText|Starring …|Starring " \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail +``` + +Expected: `git diff --check` succeeds; the diff contains only the scoped deletions plus the regression test; `rg` finds no production starring overlay or formatter. A match inside the new negative source-contract test is expected. + +- [ ] **Step 8: Commit the coherent behavior change** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailHero.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvDetailMetadata.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvStarringOverlaySourceTest.kt +git commit -m "fix(tv): remove duplicated hero starring overlay" +``` + +### Task 2: Verify the TV Detail Experience + +**Files:** +- Verify unchanged: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvCastCrewSection.kt` +- Verify unchanged: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt` +- Verify unchanged: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/MovieDetailContent.kt` +- Verify: all files changed by Task 1 + +**Interfaces:** +- Consumes: the Task 1 `TvDetailHero(...)` signature without `starringText`. +- Produces: verification evidence that the TV app compiles, the cast rail remains, the movie director credit remains, and no phone production code changed. + +- [ ] **Step 1: Run the repository supply-chain checks** + +Run: + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit successfully without modifying dependency verification metadata. + +- [ ] **Step 2: Run the complete Android TV unit suite** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --max-workers=2 --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` with no Android TV unit-test failures. + +- [ ] **Step 3: Compile both Android TV variants** + +Run: + +```bash +./gradlew \ + :androidTvApp:assembleDebug \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true \ + --max-workers=2 \ + --no-daemon +``` + +Expected: `BUILD SUCCESSFUL`. This is a compile/signing gate only; do not distribute the debug-signed release artifact. + +- [ ] **Step 4: Confirm platform and scope boundaries** + +Run: + +```bash +git diff origin/main...HEAD --name-only +git diff origin/main...HEAD -- androidApp shared android-shared +rg -n "TvCastCrewSection\\(|directorText = movieDirectorCredit\\(detail\\)" \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailScreen.kt +``` + +Expected: + +- the branch diff contains the approved spec, plan, three TV production files, and one TV test; +- the phone/shared diff is empty; +- both the TV cast section and director-credit wiring remain present. + +- [ ] **Step 5: Perform a dedicated TV-emulator smoke check** + +First prove that `emulator-5554` is the dedicated TV emulator before using it: + +```bash +adb -s emulator-5554 get-state +adb -s emulator-5554 shell getprop ro.boot.qemu.avd_name +adb -s emulator-5554 shell getprop ro.build.characteristics +``` + +Proceed only if the device state is `device`, the AVD name is `Silo_TV`, and characteristics include `tv`. Do not issue ADB commands to any physical serial. + +Install and launch the debug build: + +```bash +adb -s emulator-5554 install -r \ + androidTvApp/build/outputs/apk/debug/androidTvApp-universal-debug.apk +adb -s emulator-5554 shell monkey \ + -p org.prairieserver.prairie \ + -c android.intent.category.LEANBACK_LAUNCHER \ + 1 +``` + +Using the existing emulator profile, open a movie detail page and verify: + +1. no floating `Starring …` credit appears in the upper-right; +2. the backdrop remains unobstructed there; +3. `Directed by …` remains below synopsis/translation and above facts; +4. scrolling reaches the unchanged cast and crew section; +5. hero actions, directional focus, Back, and body scrolling behave normally. + +If `emulator-5554` is absent, offline, not `Silo_TV`, or requires destructive profile setup, do not substitute a physical device; record this single visual gate as pending. + +- [ ] **Step 6: Request focused code review** + +Provide the reviewer: + +- the approved design spec; +- this implementation plan; +- `git diff origin/main...HEAD`; +- focused/full test and build outputs; +- emulator evidence or the explicitly pending emulator gate. + +The review question is: does the branch remove every TV detail starring boundary while preserving cast/crew, director credit, phone scope, and existing focus/layout behavior? + +Address only findings that violate the approved scope or reveal a correctness regression. Re-run the smallest affected test after each correction, then repeat Steps 1–4 before completion. + +- [ ] **Step 7: Record final verification state** + +Run: + +```bash +git status --short --branch +git log --oneline --decorate origin/main..HEAD +git diff --check origin/main...HEAD +``` + +Expected: the worktree is clean; the branch contains the spec commit, plan commit, and implementation commit; the final diff has no whitespace errors. Do not merge or deploy as part of this plan. diff --git a/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md b/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md new file mode 100644 index 000000000..14415fcb3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md @@ -0,0 +1,95 @@ +# Section Cache Profile-Switch Test Race Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the deterministic scheduling race in the shared section-cache test helper without changing production behavior. + +**Architecture:** Keep the existing gated mock engine and its two synchronization points. Assign the response fixture to the current request before notifying the waiting test coroutine, so later requests cannot change that request's fixture through the shared counter. + +**Tech Stack:** Kotlin, kotlinx.coroutines-test, Ktor MockEngine, Kotlin Test/JUnit, Gradle. + +## Global Constraints + +- Modify only the shared test helper. +- Make no production-code, timeout, worker-count, or application-binary changes. +- Preserve all existing profile-isolation assertions. +- Do not add sleeps, retries, or timeout widening. + +--- + +### Task 1: Order Fixture Capture Before Entry Notification + +**Files:** +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt:56-61` + +**Interfaces:** +- Consumes: `onRequest: () -> Unit`, `body: () -> String`, `requestEntered: CompletableDeferred`. +- Produces: the same `gatedRepository(...)` helper signature and behavior with deterministic per-request fixture capture. + +- [x] **Step 1: Preserve RED evidence** + +Record the hosted failure from workflow `30453920065`: + +```text +expected:<[Old]> but was:<[New]> +at SectionRepositoryCacheTest.kt:218 +``` + +- [x] **Step 2: Apply the minimal ordering fix** + +Change the MockEngine body from: + +```kotlin +onRequest() +requestEntered.complete(Unit) +val responseBody = body() +releaseResponse.await() +``` + +to: + +```kotlin +onRequest() +val responseBody = body() +requestEntered.complete(Unit) +releaseResponse.await() +``` + +- [x] **Step 3: Verify the exact regression repeatedly** + +Run the exact test at least five times: + +```bash +for run in 1 2 3 4 5; do + ./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.SectionRepositoryCacheTest.homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile' \ + --max-workers=2 --rerun-tasks --no-daemon +done +``` + +Expected: all five runs pass. + +- [x] **Step 4: Verify the containing class and shared suite** + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.repository.SectionRepositoryCacheTest' \ + --max-workers=2 --rerun-tasks --no-daemon + +./gradlew :shared:testDebugUnitTest \ + --max-workers=2 --rerun-tasks --no-daemon +``` + +Expected: both commands pass. + +- [x] **Step 5: Inspect and commit** + +```bash +git diff --check +git diff -- shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +git add \ + docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md \ + docs/superpowers/plans/2026-07-29-section-cache-profile-switch-test-race.md \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +git commit -m "test(shared): stabilize profile-switch request fixture" +``` diff --git a/docs/superpowers/plans/2026-07-29-specials-first-season-order.md b/docs/superpowers/plans/2026-07-29-specials-first-season-order.md new file mode 100644 index 000000000..da1325086 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-specials-first-season-order.md @@ -0,0 +1,255 @@ +# Android Specials-First Season Order Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Display Specials first in Android phone and TV season selectors while ordinary series openings continue to select the first regular season. + +**Architecture:** Put Specials detection, deterministic display sorting, and initial-season choice in shared catalog-model helpers. Both Android detail view models consume the same helpers, preventing phone/TV drift while leaving composables, routes, server responses, and playback sequencing unchanged. + +**Tech Stack:** Kotlin 2.1, Kotlin Multiplatform common code, Android ViewModel/coroutines, Kotlin Test/JUnit, Gradle. + +## Global Constraints + +- Visible order is `Specials, Season 1, Season 2, …`. +- Keep the visible label **Specials**; never relabel it “Season 0.” +- Treat a season as Specials when `isSpecials == true` or `seasonNumber == 0`. +- Honor a requested/deep-linked season, including Specials. +- Without a requested season, select the first regular season; select Specials only when no regular season exists. +- Do not change the Prairie server, web client, Apple clients, API schema, routes, or playback sequencing. + +--- + +### Task 1: Shared Ordering and Initial-Selection Contract + +**Files:** +- Create: `shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt:354-381` + +**Interfaces:** +- Consumes: existing `Season(contentId, seasonNumber, isSpecials, title, …)`. +- Produces: + - `fun Season.isSpecialsForDisplay(): Boolean` + - `fun List.sortedForDisplay(): List` + - `fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season?` + +- [ ] **Step 1: Write failing shared ordering tests** + +Create `SeasonDisplayOrderTest.kt` with a local factory and these cases: + +```kotlin +package org.prairieserver.prairie.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonDisplayOrderTest { + private fun season( + number: Int, + specials: Boolean = false, + id: String = "season-$number-$specials", + ) = Season( + contentId = id, + seasonNumber = number, + isSpecials = specials, + ) + + @Test + fun `specials sort before regular seasons`() { + val result = listOf(season(2), season(0), season(1)).sortedForDisplay() + assertEquals(listOf(0, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `specials flag is authoritative even for nonzero season number`() { + val result = listOf(season(1), season(99, specials = true), season(2)).sortedForDisplay() + assertEquals(listOf(99, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `ordinary opening selects first regular season`() { + val result = listOf(season(0), season(2), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(1, result?.seasonNumber) + } + + @Test + fun `requested specials remains selected`() { + val result = listOf(season(2), season(0), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = 0) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `specials-only series selects specials`() { + val result = listOf(season(0)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(0, result?.seasonNumber) + } +} +``` + +- [ ] **Step 2: Run the shared test and verify RED** + +Run: + +```bash +./gradlew :shared:testDebugUnitTest \ + --tests org.prairieserver.prairie.model.catalog.SeasonDisplayOrderTest \ + --no-daemon +``` + +Expected: compilation fails because `initialSeasonForDisplay` and +`isSpecialsForDisplay` do not exist, or the Specials-first assertion fails +against the current Specials-last comparator. + +- [ ] **Step 3: Implement the minimal shared helpers** + +Replace the current comparator and add: + +```kotlin +fun Season.isSpecialsForDisplay(): Boolean = + isSpecials || seasonNumber == 0 + +fun List.sortedForDisplay(): List = + sortedWith( + compareByDescending { it.isSpecialsForDisplay() } + .thenBy { it.seasonNumber } + .thenBy { it.title.orEmpty() } + .thenBy { it.contentId }, + ) + +fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season? { + val ordered = sortedForDisplay() + return preferredSeasonNumber + ?.let { preferred -> ordered.firstOrNull { it.seasonNumber == preferred } } + ?: ordered.firstOrNull { !it.isSpecialsForDisplay() } + ?: ordered.firstOrNull() +} +``` + +- [ ] **Step 4: Run the shared test and verify GREEN** + +Run the Step 2 command again. Expected: all five tests pass. + +- [ ] **Step 5: Commit the shared contract** + +```bash +git add \ + shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt \ + shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt +git commit -m "fix(catalog): place specials first in season order" +``` + +--- + +### Task 2: Wire Phone and TV Initial Selection + +**Files:** +- Create: `androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonInitialSelectionWiringSourceTest.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonInitialSelectionWiringSourceTest.kt` +- Modify: `androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt:463-480` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt:652-673` + +**Interfaces:** +- Consumes: `List.sortedForDisplay()` and + `List.initialSeasonForDisplay(preferredSeasonNumber: Int?)` from + Task 1. +- Produces: identical phone/TV automatic selection behavior with existing + `selectedSeasonNumber` and `selectedSeason` state fields. + +- [ ] **Step 1: Write failing wiring tests** + +The phone test reads `ItemDetailViewModel.kt` and asserts: + +```kotlin +assertTrue( + source.contains( + "val selectedSeason = seasons.initialSeasonForDisplay(initialSeasonNumber)", + ), +) +``` + +The TV test reads `TvItemDetailViewModel.kt` and asserts: + +```kotlin +assertTrue( + source.contains( + "val selectedSeason = seasons.initialSeasonForDisplay(preferredSeasonNumber)", + ), +) +``` + +Each source-test file resolves its module-relative production file with +`File("src/androidMain/kotlin/…").readText()`. + +- [ ] **Step 2: Run both wiring tests and verify RED** + +Run: + +```bash +./gradlew \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + --tests '*SeasonInitialSelectionWiringSourceTest' \ + --no-daemon +``` + +Expected: both tests fail because the view models still implement selection +inline. + +- [ ] **Step 3: Wire the shared selection helper** + +In the phone view model, import `initialSeasonForDisplay` and replace the +inline requested-or-first choice with: + +```kotlin +val seasons = result.data.seasons.sortedForDisplay() +val selectedSeason = seasons.initialSeasonForDisplay(initialSeasonNumber) +``` + +In the TV view model, import `initialSeasonForDisplay` and replace +`selectedSeason`/`firstRegular` with: + +```kotlin +val seasons = r.data.seasons.sortedForDisplay() +val selectedSeason = seasons.initialSeasonForDisplay(preferredSeasonNumber) +``` + +Use `selectedSeason` for `selectedSeason`, episode loading, and null fallback. +Do not change routing, state field types, or episode-loading behavior. + +- [ ] **Step 4: Run both wiring tests and verify GREEN** + +Run the Step 2 command again. Expected: both tests pass. + +- [ ] **Step 5: Run focused and compile verification** + +Run: + +```bash +./gradlew \ + :shared:testDebugUnitTest \ + :androidApp:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidApp:assembleDebug \ + :androidTvApp:assembleDebug \ + --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` with no failed tests. + +- [ ] **Step 6: Check the final diff and commit** + +```bash +git diff --check +git status --short +git add \ + androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt \ + androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/screens/detail/SeasonInitialSelectionWiringSourceTest.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvSeasonInitialSelectionWiringSourceTest.kt +git commit -m "fix(android): keep regular season selected by default" +``` + +Confirm the final branch contains only the design, plan, shared ordering, +phone/TV wiring, and focused tests. diff --git a/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md b/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md new file mode 100644 index 000000000..51f700c75 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-playback-buffer-architecture.md @@ -0,0 +1,512 @@ +# Playback Buffer Architecture Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop the buffer from idling the socket long enough for an upstream proxy to drop the connection, while letting buffer depth grow as far as memory and throughput allow. + +**Architecture:** `maxBufferMs` becomes derived (`min + MAX_LOAD_IDLE_MS`) rather than hand-written, which makes the dropped-connection failure unrepresentable. Depth is governed by a memory budget and observed throughput, with an explicit floor and ceiling. The dead three-mode enum is deleted. + +**Tech Stack:** Kotlin 2.1, Java 21, AndroidX Media3 (ExoPlayer), kotlin.test/JUnit4 unit tests in `android-shared/src/androidUnitTest`. + +**Spec:** `docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md` — read it first; it is the authority on behaviour and carries the reasoning behind every number. + +## Global Constraints + +- **The invariant is the point of this work:** `maxBufferMs == minBufferMs + MAX_LOAD_IDLE_MS`, always, for every reachable policy including after the memory budget reduces depth. `MAX_LOAD_IDLE_MS = 15_000` — a 30s wall-clock budget scaled *down* by the slowest selectable playback rate: `30_000 * 0.5`. (0.5x is offered for audiobooks, which share this load control and which `DefaultLoadControl` does not scale for. Media time converts back to wall clock by dividing: `15_000 / 0.5 = 30_000`.) +- **Depth bounds:** requested floor `20_000` ms, ceiling `180_000` ms. The floor is where depth starts, not a guarantee: a known bitrate whose budget funds less than 20s yields the smaller number rather than a claimed depth memory cannot hold. +- **Startup:** `bufferForPlaybackMs = 2_000`, `bufferForPlaybackAfterRebufferMs = 5_000`. +- **No user setting, no server-driven wire value.** `PlaybackBufferMode` and its `fromWire` are deleted, not repurposed. +- **No transcode/HLS special case.** One policy; throughput governs. The server's `TranscodeThrottler` owns the transcode-ahead ceiling. +- Package root is `org.prairieserver.prairie`; buffer code lives in `org.prairieserver.prairie.common.player`. +- Build/test: `./gradlew :android-shared:testDebugUnitTest` for these tests; `./gradlew :androidApp:assembleDebug` and `./gradlew :androidTvApp:assembleDebug` must both still build (the module is shared). +- Per repo guidelines, add focused tests for the high-risk behaviour only — do not blanket-test UI or trivial changes. +- Commit per task. Push to `origin` (the RXWatcher fork), never a PR against Prairie-Server without being asked. + +--- + +### Task 1: Derive `maxBufferMs` and delete the dead mode enum + +This task alone fixes the reported dropped connections. + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt:318-323` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt` + +**Interfaces:** +- Produces: `PlaybackBufferPolicy.forConditions(deviceProfile: PlaybackBufferDeviceProfile): PlaybackBufferPolicy`, plus companion constants `MAX_LOAD_IDLE_MS = 15_000`, `MIN_DEPTH_MS = 20_000`, `MAX_DEPTH_MS = 180_000`, `ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000`. The `PlaybackBufferPolicy` data class keeps its existing six fields unchanged. +- Removes: `PlaybackBufferMode` (whole enum, including `fromWire`) and `PlaybackBufferPolicy.forMode(...)`. `PlaybackBufferDeviceProfile` stays exactly as it is. + +- [ ] **Step 1: Write the failing tests** + +Replace the whole body of `PlaybackBufferPolicyTest` (its existing tests reference `forMode`/`PlaybackBufferMode`, which this task deletes): + +```kotlin +package org.prairieserver.prairie.common.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PlaybackBufferPolicyTest { + + private val roomy = PlaybackBufferDeviceProfile(memoryClassMb = 512, isLowRamDevice = false) + private val lowRam = PlaybackBufferDeviceProfile(memoryClassMb = 96, isLowRamDevice = true) + + // The load control stops reading the socket once the buffer reaches + // maxBufferMs and does not resume until it drains below minBufferMs, so + // this gap IS how long the connection sits idle. An upstream proxy with a + // 60s send timeout drops it if the gap approaches that. This is the + // property the whole design exists to guarantee. + @Test + fun `idle window is bounded for every device profile`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertEquals( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, + policy.maxBufferMs - policy.minBufferMs, + "idle window for $profile", + ) + } + } + + @Test + fun `idle window stays well under the proxy send timeout it guards against`() { + assertTrue( + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS * 2 <= + PlaybackBufferPolicy.ASSUMED_PROXY_SEND_TIMEOUT_MS, + "idle window should keep a wide margin below the assumed timeout", + ) + } + + @Test + fun `playback starts on a small cushion and recovers quickly after a stall`() { + val policy = PlaybackBufferPolicy.forConditions(roomy) + assertEquals(2_000, policy.bufferForPlaybackMs) + assertEquals(5_000, policy.bufferForPlaybackAfterRebufferMs) + } + + @Test + fun `depth stays within the declared floor and ceiling`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.minBufferMs >= PlaybackBufferPolicy.MIN_DEPTH_MS, "floor for $profile") + assertTrue(policy.minBufferMs <= PlaybackBufferPolicy.MAX_DEPTH_MS, "ceiling for $profile") + } + } + + @Test + fun `startup thresholds never exceed the depth the policy asks for`() { + listOf(roomy, lowRam, PlaybackBufferDeviceProfile.Unknown).forEach { profile -> + val policy = PlaybackBufferPolicy.forConditions(profile) + assertTrue(policy.bufferForPlaybackMs <= policy.minBufferMs, "start for $profile") + assertTrue( + policy.bufferForPlaybackAfterRebufferMs <= policy.minBufferMs, + "rebuffer for $profile", + ) + } + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackBufferPolicyTest*'` +Expected: FAIL — `forConditions` and the constants are unresolved references. + +- [ ] **Step 3: Implement** + +Replace the contents of `PlaybackBufferPolicy.kt` with: + +```kotlin +package org.prairieserver.prairie.common.player + +/** + * Buffering policy derived from what the player can observe. There is no user + * setting and no server-supplied mode: the numbers follow from the device and + * the stream. + */ +data class PlaybackBufferPolicy( + val minBufferMs: Int, + val maxBufferMs: Int, + val bufferForPlaybackMs: Int, + val bufferForPlaybackAfterRebufferMs: Int, + val targetBufferBytes: Int, + val prioritizeTimeOverSizeThresholds: Boolean, +) { + companion object { + /** + * How long the load control may stop reading the socket. + * + * DefaultLoadControl fills to maxBufferMs, then requests nothing until + * the buffer drains below minBufferMs — so the gap between them is + * literally how long the connection sits idle. Upstream proxies close + * an idle response body: nginx's send_timeout defaults to 60s. The old + * hand-written 50s/120s pair left a 70s gap and dropped the connection + * every time the buffer filled on a long direct-play file. + * + * maxBufferMs is therefore never written by hand; it is always + * minBufferMs + this. Depth can grow without ever widening the window. + */ + const val MAX_LOAD_IDLE_MS = 15_000 + + /** + * The timeout MAX_LOAD_IDLE_MS is chosen to stay clear of. The window is + * this budgeted down to 30s of wall clock and then multiplied by + * SLOWEST_PLAYBACK_SPEED to express it in media time, + * because the invariant is in media time and a proxy measures wall clock. + */ + const val ASSUMED_PROXY_SEND_TIMEOUT_MS = 60_000 + + /** The depth the policy asks for before the memory budget has its say. */ + const val MIN_DEPTH_MS = 20_000 + + /** + * Never buffer more than this even when memory allows. Past a few + * minutes we are mostly prefetching content the viewer may seek away + * from — wasted bandwidth, and wasted allowance on mobile data. + */ + const val MAX_DEPTH_MS = 180_000 + + private const val START_MS = 2_000 + + /** + * After a stall the viewer is watching a spinner, so the cushion we + * rebuild before resuming is deliberately small. + */ + private const val REBUFFER_MS = 5_000 + + fun forConditions( + deviceProfile: PlaybackBufferDeviceProfile = PlaybackBufferDeviceProfile.Unknown, + ): PlaybackBufferPolicy { + val depthMs = MAX_DEPTH_MS + return PlaybackBufferPolicy( + minBufferMs = depthMs, + maxBufferMs = depthMs + MAX_LOAD_IDLE_MS, + bufferForPlaybackMs = START_MS, + bufferForPlaybackAfterRebufferMs = REBUFFER_MS, + targetBufferBytes = memoryBudgetBytes(deviceProfile), + prioritizeTimeOverSizeThresholds = false, + ) + } + + /** + * The byte ceiling this device can afford. PrairieLoadControl sizes the + * real target from the stream's bitrate and clamps it to this. + */ + internal fun memoryBudgetBytes(deviceProfile: PlaybackBufferDeviceProfile): Int = when { + deviceProfile.isLowRamDevice -> 48 * MIB + deviceProfile.memoryClassMb <= 0 -> 48 * MIB + deviceProfile.memoryClassMb < 192 -> 48 * MIB + deviceProfile.memoryClassMb < 384 -> 96 * MIB + else -> 160 * MIB + } + + private const val MIB = 1024 * 1024 + } +} + +data class PlaybackBufferDeviceProfile( + val memoryClassMb: Int, + val isLowRamDevice: Boolean, +) { + companion object { + val Unknown = PlaybackBufferDeviceProfile(memoryClassMb = 0, isLowRamDevice = false) + } +} +``` + +Then update the call site in `PrairiePlayerFactory.kt` (around line 318). Replace the `PlaybackBufferPolicy.forMode(PlaybackBufferMode.Balanced, playbackBufferDeviceProfile())` call and its preceding comment with: + +```kotlin + // Start on a small cushion and keep filling in the background. Depth is + // bounded by the device's memory budget in PrairieLoadControl; the gap + // between min and max is fixed so the connection is never idle long + // enough for an upstream proxy to close it. + val bufferPolicy = PlaybackBufferPolicy.forConditions(playbackBufferDeviceProfile()) +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackBufferPolicyTest*'` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Confirm both apps still build** + +Run: `./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug` +Expected: BUILD SUCCESSFUL. If either fails on an unresolved `PlaybackBufferMode`, there is a second reference to the deleted enum — find it with `grep -rn "PlaybackBufferMode" --include="*.kt" .` and remove it. + +- [ ] **Step 6: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicy.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackBufferPolicyTest.kt +git commit -m "fix(playback): bound the load-idle window so proxies stop dropping the connection" +``` + +--- + +### Task 2: Fit depth to the memory budget instead of letting bytes silently truncate it + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: `PlaybackBufferPolicy.MIN_DEPTH_MS`, `PlaybackBufferPolicy.MAX_LOAD_IDLE_MS` (Task 1); the existing internal helpers `selectBufferSizingBitrateBps(...)` and `calculateBitrateTargetBufferBytes(...)`, both unchanged. +- Produces: `internal fun affordableDepthMs(desiredDepthMs: Int, selectedBitrateBps: Long?, budgetBytes: Int, minimumDepthMs: Int): Int` — the depth the budget can actually fund, never above `desiredDepthMs`. `minimumDepthMs` is the floor only on the unknown-bitrate path, where there is nothing to size from; with a known bitrate the helper returns the true affordable depth even when that is below `minimumDepthMs`, since claiming the floor would reinstate exactly the silent overrun this task removes. + +**Context an implementer needs:** today `calculateTargetBufferBytes` clamps bytes to the budget and stops there, so on a 60 Mbps remux the loader quietly stops at whatever the cap affords (about 5s on a low-RAM device) while the policy still claims a much larger depth. The fix is not to raise the cap — memory is genuinely finite — but to make the reduction explicit, so the resulting depth is a number the code chose rather than an accident. + +- [ ] **Step 1: Write the failing tests** + +Append to `PrairieLoadControlTest`: + +```kotlin + @Test + fun `depth follows the budget honestly, even below the floor`() { + // 60 Mbps against a 48 MiB budget: accounting for the same 15% + // overhead margin calculateBitrateTargetBufferBytes applies when it + // turns this depth back into bytes, the budget only really affords + // ~5.8s. The requested 180s cannot be held, and neither can the 20s + // floor — the budget wins over the floor because a false, rounded-up + // report would be worse than an honest shortfall. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 60_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertTrue(depth < 20_000, "expected reduction below the floor, got $depth") + assertEquals(5_835, depth, "should report the honest budget-derived value") + } + + @Test + fun `depth is left alone when the budget can fund it`() { + // 5 Mbps against 160 MiB: ~268s available, more than the 180s asked for. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 5_000_000L, + budgetBytes = 160 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(180_000, depth) + } + + @Test + fun `depth falls back to the request when the bitrate is unknown`() { + val depth = + affordableDepthMs( + desiredDepthMs = 120_000, + selectedBitrateBps = null, + budgetBytes = 96 * 1024 * 1024, + minimumDepthMs = 20_000, + ) + + assertEquals(120_000, depth) + } + + @Test + fun `reducing depth never widens the idle window`() { + // The invariant has to survive the reduction: whatever depth the budget + // affords, max is still exactly one idle window above it. + val depth = + affordableDepthMs( + desiredDepthMs = 180_000, + selectedBitrateBps = 80_000_000L, + budgetBytes = 48 * 1024 * 1024, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + val max = depth + PlaybackBufferPolicy.MAX_LOAD_IDLE_MS + + assertEquals(PlaybackBufferPolicy.MAX_LOAD_IDLE_MS, max - depth) + } +``` + +Add `import org.junit.Assert.assertTrue` to the file's imports. + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: FAIL — `affordableDepthMs` is an unresolved reference. + +- [ ] **Step 3: Implement** + +Add to `PrairieLoadControl.kt`, beside the other internal helpers: + +```kotlin +/** + * The forward buffer this device can actually hold at this bitrate. + * + * Memory is finite, so a high-bitrate stream genuinely cannot be buffered as + * deeply as a low-bitrate one. Computing that reduction here — rather than + * letting the byte clamp truncate the buffer wherever it happens to land — + * means the resulting depth is a number the code chose and can be reasoned + * about, and it keeps maxBufferMs one idle window above a depth that is real. + * + * An unknown bitrate leaves the request untouched; the byte clamp still + * applies downstream. + */ +internal fun affordableDepthMs( + desiredDepthMs: Int, + selectedBitrateBps: Long?, + budgetBytes: Int, + minimumDepthMs: Int, +): Int { + val bitrate = selectedBitrateBps?.takeIf { it > 0L } + ?: return desiredDepthMs.coerceAtLeast(minimumDepthMs) + // The 115/100 mirrors the overhead margin calculateBitrateTargetBufferBytes + // multiplies back in when it turns a depth into bytes. Without it, a + // budget-derived depth still produces a byte figure that overshoots the + // budget once that margin is applied and clamps back to the ceiling, + // erasing the depth's effect on the byte target entirely. + val affordableMs = budgetBytes.toLong() * 8L * 1_000L * 100L / (bitrate * 115L) + // Deliberately NOT coerced up to minimumDepthMs: with a known bitrate the + // honest budget-derived depth wins over the floor, since claiming a depth + // the loader cannot hold is the exact silent overrun this task removes. + // minimumDepthMs bounds only the unknown-bitrate branch above. + return affordableMs.coerceAtMost(desiredDepthMs.toLong()).toInt() +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: PASS — the four new tests plus the existing bitrate-selection ones. + +- [ ] **Step 5: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "feat(playback): fit buffer depth to the device memory budget" +``` + +--- + +### Task 3: Apply the affordable depth to the live load control + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt` +- Test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt` + +**Interfaces:** +- Consumes: `affordableDepthMs(...)` (Task 2), `PlaybackBufferPolicy` (Task 1). +- Produces: `PrairieLoadControl.currentDepthMs(): Int` — the depth most recently computed from observed bitrate, for tests and diagnostics. Defaults to the policy's `minBufferMs` before any track selection has happened. + +**Context an implementer needs:** `DefaultLoadControl` reads its min/max durations from constructor arguments and does not re-read them, so the depth reduction cannot change the running loader's time thresholds. It can and must still change the *byte* target, which is what actually stops the loader. `currentDepthMs()` exists so the reduction is observable rather than implicit — do not attempt to mutate the superclass's durations. + +- [ ] **Step 1: Write the failing test** + +Append to `PrairieLoadControlTest`: + +```kotlin + @Test + fun `byte target follows the affordable depth rather than the requested one`() { + // A 40 Mbps stream on a 48 MiB budget can hold ~10s, not the 180s the + // policy asks for. The byte target must reflect the affordable depth, + // and must never exceed the budget. + val budgetBytes = 48 * 1024 * 1024 + val depth = + affordableDepthMs( + desiredDepthMs = PlaybackBufferPolicy.MAX_DEPTH_MS, + selectedBitrateBps = 40_000_000L, + budgetBytes = budgetBytes, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + + val bytes = + calculateBitrateTargetBufferBytes( + selectedBitrateBps = 40_000_000L, + desiredForwardBufferMs = depth, + minimumBytes = PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, + maximumBytes = budgetBytes, + unknownBitrateFallbackBytes = budgetBytes, + ) + + assertTrue(bytes <= budgetBytes, "byte target $bytes exceeded budget $budgetBytes") + assertTrue(bytes >= PrairieLoadControl.MIN_TARGET_BUFFER_BYTES, "byte target below floor") + } +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PrairieLoadControlTest*'` +Expected: FAIL — `MIN_TARGET_BUFFER_BYTES` is `internal` inside a companion that the test can reach, but `calculateTargetBufferBytes` does not yet size from an affordable depth. If it compiles and passes immediately, the sizing path was already correct; still complete step 3 so the running loader uses it. + +- [ ] **Step 3: Implement** + +Replace `PrairieLoadControl`'s `calculateTargetBufferBytes` override and add the depth field: + +```kotlin + @Volatile private var depthMs: Int = policy.minBufferMs + + /** The forward buffer the memory budget currently affords, in ms. */ + internal fun currentDepthMs(): Int = depthMs + + override fun calculateTargetBufferBytes( + parameters: LoadControl.Parameters, + trackSelections: Array, + ): Int { + val selectedBitrateBps = + selectBufferSizingBitrateBps( + trackSelections.mapNotNull { selection -> + selection?.let { + BufferSizingTrackBitrates( + averageBitrateBps = it.selectedFormat.averageBitrate, + peakBitrateBps = it.selectedFormat.peakBitrate, + latestNetworkEstimateBps = it.latestBitrateEstimate, + ) + } + }, + ) + val fallback = super.calculateTargetBufferBytes(parameters, trackSelections) + val affordableMs = + affordableDepthMs( + desiredDepthMs = policy.minBufferMs, + selectedBitrateBps = selectedBitrateBps, + budgetBytes = policy.targetBufferBytes, + minimumDepthMs = PlaybackBufferPolicy.MIN_DEPTH_MS, + ) + depthMs = affordableMs + return calculateBitrateTargetBufferBytes( + selectedBitrateBps = selectedBitrateBps, + desiredForwardBufferMs = affordableMs, + minimumBytes = MIN_TARGET_BUFFER_BYTES, + maximumBytes = policy.targetBufferBytes, + unknownBitrateFallbackBytes = fallback, + ) + } +``` + +- [ ] **Step 4: Run the full module test suite** + +Run: `./gradlew :android-shared:testDebugUnitTest` +Expected: PASS, no regressions in the existing player tests. + +- [ ] **Step 5: Confirm both apps build** + +Run: `./gradlew :androidApp:assembleDebug :androidTvApp:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControl.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PrairieLoadControlTest.kt +git commit -m "feat(playback): size the byte target from the affordable buffer depth" +``` + +--- + +## Self-review notes (already applied) + +- Spec coverage: the invariant and enum deletion → Task 1; memory-governed depth with floor/ceiling → Tasks 2 and 3; startup/rebuffer numbers → Task 1; no-transcode-special-case and no-user-setting are satisfied by never introducing them. +- Throughput-driven depth is represented by the existing `latestBitrateEstimate` fallback inside `selectBufferSizingBitrateBps`, which already prefers measured network throughput when the container declares no bitrate. No separate task: adding a second throughput mechanism would duplicate it. +- Type consistency: `affordableDepthMs` has one signature, used identically in Tasks 2 and 3; `PlaybackBufferPolicy.forConditions` takes only a device profile in both Task 1 and its call site. +- `MIN_TARGET_BUFFER_BYTES` stays `internal const` on `PrairieLoadControl`'s companion, unchanged from today, so the Task 3 test can reference it. diff --git a/docs/superpowers/plans/2026-08-01-fire-tv-playback-selection-ux.md b/docs/superpowers/plans/2026-08-01-fire-tv-playback-selection-ux.md new file mode 100644 index 000000000..d2d37e4c2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-fire-tv-playback-selection-ux.md @@ -0,0 +1,716 @@ +# Fire TV Playback Selection UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix Android TV detail-selector contrast, preserve semantic source/subtitle intent across episode transitions, and keep long in-player subtitle pickers scrolled to D-pad focus. + +**Architecture:** Keep the existing detail popup, eager HUD focus graph, playback coordinator, and per-item durable preferences. Add pure visual-state and episode-handoff policies, pass the handoff through the existing TV route/start request, resolve it only after the target episode's catalog detail is known, and apply it without transferring raw file IDs or track indexes. + +**Tech Stack:** Kotlin 2.1, Kotlin serialization, Jetpack Compose/Compose for TV, Navigation Compose, Media3, Kotlin test/JUnit, Gradle, repository supply-chain scripts. + +## Global Constraints + +- Android TV behavior only; phone production behavior remains unchanged. +- No server, API, schema, database, proxy, or production-configuration changes. +- Keep durable track selections scoped to `(server, profile, contentId, fileId)`. +- Never transfer a raw file ID or subtitle index between episodes. +- Explicit subtitle Off remains Off; Auto remains Auto; a missing explicit match falls back to profile Auto. +- Source matching uses resolution first, then codec, Dolby Vision/HDR, and container as deterministic tie-breakers. +- Do not add cross-episode audio continuity. +- Keep Watch Together auto-advance suppression and playback shutdown ordering unchanged. +- Keep the eager HUD option `Column`; do not restore the removed lazy focus graph. +- Do not install on a physical Fire TV, Shield, phone, or other device without a new explicit request. + +--- + +## File Map + +- `androidTvApp/.../ui/components/TvSelectorRowVisualState.kt`: pure focused/selected/disabled color policy for anchored selector rows. +- `androidTvApp/.../ui/components/TvAnchoredSelectorMenu.kt`: renders the existing anchored popup with explicit TV focus state. +- `androidTvApp/.../ui/screens/player/TvPlayerHud.kt`: explicitly brings a focused HUD picker row into the clipped viewport. +- `android-shared/.../player/video/EpisodeSelectionHandoff.kt`: serializable semantic source/subtitle intent plus pure capture/resolve policy. +- `android-shared/.../player/video/VideoPlaybackStartRequest.kt`: optional episode handoff on the existing coordinator request. +- `android-shared/.../player/video/VideoPlaybackStartResult.kt`: reports the target decision back to the TV view model. +- `androidTvApp/.../ui/navigation/TvRoute.kt`: carries one URL-encoded handoff payload through player replacement. +- `androidTvApp/.../ui/navigation/TvAppNavigation.kt`: decodes the payload and passes it into the next TV player. +- `androidTvApp/.../ui/screens/player/TvVideoPlaybackStarter.kt`: resolves source and subtitle against the target episode before session start. +- `androidTvApp/.../ui/screens/player/TvPlayerViewModel.kt`: captures outgoing intent and prevents a stale target override. +- `androidTvApp/.../ui/screens/player/TvPlayerScreen.kt`: threads the handoff through launch arguments and next navigation. +- `androidTvApp/.../ui/screens/detail/TvItemDetailViewModel.kt`: resolves old next-up selection after the new watch detail loads. + +--- + +### Task 1: Define and test a semantic episode-selection handoff + +**Files:** +- Create: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt` +- Create test: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt` + +**Interfaces:** +- Produces: a serializable `EpisodeSelectionHandoff` containing semantic source and subtitle intent only. +- Consumes: `FileVersion`, `PlayerSubtitleInfo`, and the existing normalized codec/language metadata. +- Security boundary: payloads contain no server URL, token, file ID, download ID, track ID, or track index. + +- [ ] **Step 1: Write failing source-resolution tests** + +Cover these cases in `EpisodeSelectionHandoffTest`: + +```kotlin +@Test fun sourceUsesResolutionBeforeCodecAndContainer() { /* 2160p remains 2160p */ } +@Test fun sourceUsesCodecDynamicRangeAndContainerAsTieBreakers() { /* exact semantic candidate wins */ } +@Test fun ambiguousBestSourceFallsBackToAutomaticSelection() { /* tied best candidates return null */ } +@Test fun unavailableResolutionFallsBackToAutomaticSelection() { /* no forced upscale/downgrade */ } +@Test fun sourceIntentNeverSerializesTheOriginalFileId() { /* encoded payload omits raw IDs */ } +``` + +Use actual `FileVersion` fixtures with different IDs so the test proves resolution returns a target ID selected from the target list rather than the source episode's ID. + +- [ ] **Step 2: Write failing subtitle-resolution tests** + +```kotlin +@Test fun explicitSubtitleMatchesSemanticsAtADifferentTargetIndex() { /* language/accessibility/source/codec */ } +@Test fun explicitOffRemainsOff() { /* result is -1 and intentSpecified=true */ } +@Test fun automaticSubtitleRemainsUnspecified() { /* null and intentSpecified=false */ } +@Test fun unavailableExplicitSubtitleUsesProfileAutoWithoutTargetDurableRestore() { + /* null and intentSpecified=true */ +} +@Test fun malformedPayloadDecodesToNull() { /* navigation cannot crash */ } +``` + +The explicit-missing case is load-bearing: `subtitleTrackIndex = null` selects profile Auto, while `intentSpecified = true` prevents the target episode's durable per-file subtitle from overriding that fallback. + +- [ ] **Step 3: Run the focused tests and verify RED** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*EpisodeSelectionHandoffTest' --no-daemon +``` + +Expected: compilation fails because the handoff contract and policy do not exist. + +- [ ] **Step 4: Implement the minimal serializable contract** + +```kotlin +@Serializable +data class EpisodeSelectionHandoff( + val source: EpisodeSourceIntent? = null, + val subtitle: EpisodeSubtitleIntent = EpisodeSubtitleIntent.auto(), +) + +@Serializable +data class EpisodeSourceIntent( + val resolution: String, + val videoCodec: String? = null, + val dynamicRange: EpisodeDynamicRange? = null, + val container: String? = null, +) + +@Serializable enum class EpisodeDynamicRange { SDR, HDR, DOLBY_VISION } +@Serializable enum class EpisodeSubtitleMode { AUTO, OFF, TRACK } + +@Serializable +data class EpisodeSubtitleIntent( + val mode: EpisodeSubtitleMode, + val language: String? = null, + val codecFamily: String? = null, + val forced: Boolean? = null, + val hearingImpaired: Boolean? = null, + val external: Boolean? = null, +) { + companion object { + fun auto() = EpisodeSubtitleIntent(EpisodeSubtitleMode.AUTO) + fun off() = EpisodeSubtitleIntent(EpisodeSubtitleMode.OFF) + } +} + +data class ResolvedEpisodeSubtitle(val trackIndex: Int?, val intentSpecified: Boolean) +data class ResolvedEpisodeSelection( + val fileId: Int?, + val subtitleTrackIndex: Int?, + val subtitleIntentSpecified: Boolean, +) +``` + +Add pure capture/resolve helpers and JSON encode/decode helpers. Source resolution must require an exact normalized resolution, then score codec, Dolby Vision/HDR, and container. Return a target file ID only when the highest-scoring candidate is unique. Subtitle resolution must compare normalized language, codec family, forced, hearing-impaired, and embedded/external semantics. Do not reuse `TrackSelectionFingerprint`: its index is intentionally file-scoped. + +- [ ] **Step 5: Verify the contract and serialization boundary** + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests '*EpisodeSelectionHandoffTest' --no-daemon +rg -n 'fileId|downloadId|trackId|trackIndex|accessToken|serverUrl' \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt +``` + +Expected: tests pass; restricted names appear only in resolved target result types where needed, never in serialized intent fields. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoff.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/video/EpisodeSelectionHandoffTest.kt +git commit -m "feat(tv): define semantic episode selection handoff" +``` + +--- + +### Task 2: Resolve the handoff at the playback-start boundary + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt` +- Create test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt` + +**Interfaces:** +- `VideoPlaybackStartRequest.episodeSelectionHandoff: EpisodeSelectionHandoff? = null` keeps phone and existing callers source-compatible. +- `VideoPlaybackStartResult.Ready.resolvedEpisodeSelection: ResolvedEpisodeSelection? = null` reports exactly what was chosen after target catalog resolution. + +- [ ] **Step 1: Write failing precedence and fallback tests** + +Extract a pure `resolveTvPlaybackStartSelection(...)` policy and test: + +```kotlin +@Test fun explicitDetailFileIdWinsOverEpisodeHandoff() { /* manual launch remains authoritative */ } +@Test fun episodeHandoffWinsOverTargetLastFileAndQuality() { /* autoplay carries current intent */ } +@Test fun noHandoffPreservesExistingLastFileAndQualitySelection() { /* regression guard */ } +@Test fun subtitleIsResolvedAgainstTheChosenTargetVersion() { /* not another version's indexes */ } +@Test fun missingExplicitSubtitleReturnsSpecifiedProfileAuto() { /* null + true */ } +@Test fun explicitOffIsRetainedClientSide() { /* -1 is not sent to server */ } +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvEpisodeHandoffPlaybackStartTest' --no-daemon +``` + +Expected: compilation fails because the request/result fields and resolver do not exist. + +- [ ] **Step 3: Add the optional request/result fields** + +Add the two nullable fields with defaults. Do not change constructor behavior for Android phone, explicit detail launches, retries, Watch Together, or download playback. + +- [ ] **Step 4: Resolve only after target watch detail is available** + +In `TvVideoPlaybackStarter`, feed target `FileVersion` and subtitle lists into `resolveTvPlaybackStartSelection`. Apply precedence in this exact order: + +1. explicit `preferredFileId` from the detail screen; +2. unique semantic source handoff match; +3. existing target `lastFileId` / quality / automatic selection. + +Resolve subtitle against the selected target version. Forward only a non-negative target subtitle index to the server start request because the server rejects `-1`; retain Off as `-1` in `resolvedEpisodeSelection` for the client-side Media3 selection. Populate the resolved result on `Ready`. + +- [ ] **Step 5: Run focused and neighboring coordinator tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvEpisodeHandoffPlaybackStartTest' \ + --tests '*TvPlaybackFreshLoadOwnershipTest' \ + :androidTvApp:compileDebugKotlinAndroid --no-daemon +``` + +Expected: all selected tests pass and TV compilation succeeds. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartRequest.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlaybackStartResult.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvEpisodeHandoffPlaybackStartTest.kt +git commit -m "feat(tv): resolve episode selection during playback start" +``` + +--- + +### Task 3: Give anchored selector rows an explicit TV focus state + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualState.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAnchoredSelectorMenu.kt:152-214` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt:80-220` +- Create test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualStateTest.kt` + +**Interfaces:** +- Produces: `tvSelectorRowVisualState(focused: Boolean, selected: Boolean, enabled: Boolean): TvSelectorRowVisualState`. +- Consumes: existing `FocusedContainer`, `FocusedContent`, `DarkSurfaceElevated`, and `PrairieOnSurface` theme colors. + +- [ ] **Step 1: Write failing visual-state tests** + +```kotlin +class TvSelectorRowVisualStateTest { + @Test fun focusedRowsUseInvertedTvContrast() { + val state = tvSelectorRowVisualState(focused = true, selected = false, enabled = true) + assertEquals(FocusedContainer, state.container) + assertEquals(FocusedContent, state.content) + assertTrue(state.border.alpha > 0f) + } + + @Test fun selectedIdleRowsRemainDistinctFromIdleRows() { + val selected = tvSelectorRowVisualState(false, true, true) + val idle = tvSelectorRowVisualState(false, false, true) + assertNotEquals(idle.container, selected.container) + assertNotEquals(idle.border, selected.border) + } + + @Test fun disabledRowsStayMutedEvenWhenSelected() { + val state = tvSelectorRowVisualState(false, true, false) + assertTrue(state.content.alpha < 0.5f) + } +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSelectorRowVisualStateTest' --no-daemon +``` + +Expected: compilation fails because the visual-state types do not exist. + +- [ ] **Step 3: Implement the minimal pure policy** + +```kotlin +internal data class TvSelectorRowVisualState( + val container: Color, + val content: Color, + val border: Color, +) + +internal fun tvSelectorRowVisualState( + focused: Boolean, + selected: Boolean, + enabled: Boolean, +): TvSelectorRowVisualState = when { + !enabled -> TvSelectorRowVisualState( + DarkSurfaceElevated, + PrairieOnSurface.copy(alpha = 0.38f), + Color.Transparent, + ) + focused -> TvSelectorRowVisualState( + FocusedContainer, + FocusedContent, + FocusedContent.copy(alpha = 0.22f), + ) + selected -> TvSelectorRowVisualState( + PrairieOnSurface.copy(alpha = 0.14f), + PrairieOnSurface, + PrairieOnSurface.copy(alpha = 0.28f), + ) + else -> TvSelectorRowVisualState(DarkSurfaceElevated, PrairieOnSurface, Color.Transparent) +} +``` + +- [ ] **Step 4: Wire the policy into every anchored menu row** + +Add a stable `key: String` to `TvSelectorOption` and populate it from file ID, audio/subtitle stable identity, or edition key at all `TvPlaybackSelectorRow` call sites. For each option, remember a `MutableInteractionSource` by key, collect focus, pass that interaction source to `DropdownMenuItem`, and apply the resolved background, border, text, and icon colors. Keep anchoring, semantics, enablement, callbacks, and trigger focus restoration unchanged. + +```kotlin +val interactionSource = remember(option.key) { MutableInteractionSource() } +val focused by interactionSource.collectIsFocusedAsState() +val visual = tvSelectorRowVisualState(focused, option.selected, option.enabled) + +DropdownMenuItem( + interactionSource = interactionSource, + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(visual.container) + .border(1.dp, visual.border, RoundedCornerShape(8.dp)) + .semantics { selected = option.selected }, + colors = MenuDefaults.itemColors( + textColor = visual.content, + leadingIconColor = visual.content, + disabledTextColor = visual.content, + disabledLeadingIconColor = visual.content, + ), + // retain the existing text, leading icon, enabled value, and onClick body +) +``` + +- [ ] **Step 5: Run focused tests and compile** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvSelectorRowVisualStateTest' \ + --tests '*TvPlaybackFormattingTest' \ + :androidTvApp:compileDebugKotlinAndroid --no-daemon +``` + +Expected: selected tests pass and Android TV Kotlin compilation succeeds. + +- [ ] **Step 6: Commit Task 3** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvSelectorRowVisualStateTest.kt +git commit -m "fix(tv): make detail selector focus legible" +``` + +--- + +### Task 4: Keep long HUD picker lists aligned with D-pad focus + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt:2060-2190` +- Create test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt` + +**Interfaces:** +- Produces: every `HudPickerOptionRow` owns one `BringIntoViewRequester` and relocates only when focus enters. +- Preserves: eager `Column.verticalScroll`, modal focus trap, stable option keys, and Select-to-commit behavior. + +- [ ] **Step 1: Write the failing wiring regression** + +```kotlin +class TvHudPickerFocusWiringSourceTest { + private val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt", + ).readText() + + @Test fun focusedPickerRowsAreExplicitlyBroughtIntoView() { + assertContains(source, "remember { BringIntoViewRequester() }") + assertContains(source, ".bringIntoViewRequester(bringIntoViewRequester)") + assertContains(source, "bringIntoViewRequester.bringIntoView()") + } + + @Test fun pickerKeepsTheEagerFocusGraph() { + val picker = source.substringAfter("internal fun HudPickerDialog") + .substringBefore("private fun formatTime") + assertContains(picker, ".verticalScroll(rememberScrollState())") + assertFalse(picker.contains("LazyColumn")) + } +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvHudPickerFocusWiringSourceTest' --no-daemon +``` + +Expected: bring-into-view assertions fail against the implicit-scroll implementation. + +- [ ] **Step 3: Add focused-row relocation** + +```kotlin +val bringIntoViewRequester = remember { BringIntoViewRequester() } +val scope = rememberCoroutineScope() + +Modifier + .bringIntoViewRequester(bringIntoViewRequester) + .onFocusChanged { state -> + if (state.isFocused) { + onFocused() + scope.launch { bringIntoViewRequester.bringIntoView() } + } + } +``` + +Remove the old one-line `onFocusChanged` so `onFocused()` fires exactly once per focus entry. + +- [ ] **Step 4: Run the regression and neighboring HUD tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvHudPickerFocusWiringSourceTest' \ + --tests '*TvPlayerHudTabsTest' \ + --tests '*TvSubtitleHudStateTest' \ + :androidTvApp:compileDebugKotlinAndroid --no-daemon +``` + +Expected: all tests and compilation pass. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvHudPickerFocusWiringSourceTest.kt +git commit -m "fix(tv): scroll HUD pickers with focus" +``` + +--- + +### Task 5: Carry the semantic handoff through next-episode navigation + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt:84-145` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt:780-835` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt:412-433,1431-1515,2879-2910` +- Modify test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt` +- Create test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt` + +**Interfaces:** +- `TvRoute.Player` adds one optional URL-encoded `episodeSelectionHandoff` query value. +- `TvPlayerLaunchArgs` and `PlayNextRequest` add `episodeSelectionHandoff: EpisodeSelectionHandoff?`. +- `onPlayNext` passes one semantic handoff object instead of using `preferredQuality` as cross-episode authority. + +- [ ] **Step 1: Extend route tests and verify RED** + +Test payload round-trip, absent-payload compatibility, malformed-payload fallback, and query-value encoding: + +```kotlin +@Test fun playerRouteRoundTripsEpisodeSelectionHandoff() { /* semantic payload survives replacement */ } +@Test fun playerRouteWithoutHandoffKeepsExistingDefaults() { /* existing deep links work */ } +@Test fun malformedEpisodeHandoffIsIgnored() { /* player still opens */ } +``` + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvPlayerRouteTest' --no-daemon +``` + +Expected: new route assertions fail. + +- [ ] **Step 2: Add the optional route and launch fields** + +Serialize with `encodeEpisodeSelectionHandoff`, route-encode the result once, declare a nullable string navigation argument, route-decode it once, and parse with `decodeEpisodeSelectionHandoff`. A malformed value becomes null rather than aborting navigation. Thread it through `TvPlayerScreen`, `TvPlayerLaunchArgs`, and `VideoPlaybackStartRequest`. + +- [ ] **Step 3: Write failing outgoing-handoff tests** + +Use production-shaped `FileVersion`, downloaded playback identity, and `PlayerSubtitleInfo` fixtures: + +```kotlin +@Test fun nextEpisodeCapturesCurrentSourceAndCommittedSubtitleSemantics() { /* no IDs */ } +@Test fun nextEpisodeCarriesExplicitOff() { /* OFF survives */ } +@Test fun nextEpisodeCarriesAutoWhenNoExplicitSubtitleWasCommitted() { /* AUTO */ } +@Test fun downloadedPlaybackDropsDownloadIdentityButKeepsMediaSemantics() { /* safe handoff */ } +@Test fun watchTogetherStillSuppressesSoloAutoAdvance() { /* unchanged guard */ } +@Test fun profileOrServerReplacementDoesNotReuseAnOldHandoff() { /* session boundary */ } +``` + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvPlayNextSelectionHandoffTest' --no-daemon +``` + +Expected: the new continuity tests fail. + +- [ ] **Step 4: Capture and emit semantic intent** + +At `advanceToNextEpisode`, capture source intent from the active `FileVersion` and subtitle intent from the committed subtitle identity. Strip `fileId`, `downloadId`, `trackId`, server index, and Media3 index. Preserve `autoAdvanceCount`. Keep Watch Together suppression and shutdown order untouched. Navigate with the handoff payload and remove `preferredQuality` as the episode-continuity mechanism; it remains available for ordinary playback-quality semantics. + +- [ ] **Step 5: Apply the resolved target decision without stale override** + +When `VideoPlaybackStartResult.Ready.resolvedEpisodeSelection` exists: + +- set the pending initial subtitle index, including `-1` for Off; +- apply an explicit target match or Off after Media3 tracks appear; +- when `subtitleIntentSpecified` is true, skip the target file's durable `localTrackSelection.subtitleFingerprint` restore; +- for a missing explicit match, leave the index null so profile Auto applies; +- keep audio restore and all no-handoff launch behavior unchanged. + +- [ ] **Step 6: Run focused player and route tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvPlayerRouteTest' \ + --tests '*TvPlayNextSelectionHandoffTest' \ + --tests '*TvTrackSelectionPersistenceTest' \ + --tests '*TvPlaybackFreshLoadOwnershipTest' \ + :androidTvApp:compileDebugKotlinAndroid --no-daemon +``` + +Expected: all selected tests pass and TV compilation succeeds. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvPlayerRouteTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayNextSelectionHandoffTest.kt +git commit -m "fix(tv): preserve episode source and subtitle intent" +``` + +--- + +### Task 6: Preserve the same intent when choosing Next Up on detail pages + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt:877-1110` +- Modify test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt` +- Create test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt` + +**Interfaces:** +- Produces: an in-memory pending `EpisodeSelectionHandoff` tied to the expected target content/generation. +- Preserves: per-item Room persistence and audio restore; the carried selection is not persisted until the user explicitly changes it on the target item. + +- [ ] **Step 1: Write failing target-refresh tests** + +```kotlin +@Test fun changingNextUpResolvesOldSourceAgainstNewEpisodeFiles() { /* semantic source carries */ } +@Test fun changingNextUpResolvesSubtitleAtDifferentCombinedIndex() { /* semantic track carries */ } +@Test fun explicitOffRemainsOffAcrossNextUpRefresh() { /* -1 */ } +@Test fun missingExplicitSubtitleUsesAutoAndDoesNotRestoreTargetDurableSubtitle() { /* null */ } +@Test fun autoAllowsExistingTargetDurableSubtitleRestore() { /* existing behavior */ } +@Test fun staleRefreshCompletionCannotApplyHandoffToAnotherEpisode() { /* generation fence */ } +@Test fun carriedSelectionIsNotPersistedBeforeExplicitUserInput() { /* Room remains item-scoped */ } +@Test fun profileOrServerChangeClearsPendingNextUpHandoff() { /* identity boundary */ } +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvNextUpSelectionHandoffTest' \ + --tests '*TvTrackSelectionPersistenceTest' --no-daemon +``` + +Expected: continuity assertions fail against the current reset-to-null behavior. + +- [ ] **Step 3: Capture before clearing and resolve after loading** + +Before `refreshNextUp` clears `selectedNextUpFileId` and `selectedNextUpSubtitleIndex`, capture semantic intent from the old selected version and subtitle. Store it with the expected target content ID and refresh generation. After the new watch detail loads, resolve it against the new file list and selected target version before merging session/durable state. + +Merge rules: + +1. carried source/subtitle intent for the new target; +2. existing in-memory target session selection where the handoff is Auto/unspecified; +3. existing per-item durable file/audio/subtitle restore where not suppressed; +4. profile Auto fallback. + +Keep durable audio behavior unchanged. If a subtitle handoff was specified, block target durable subtitle restore even when no match exists. Do not save the resolved handoff to Room until an explicit selector callback occurs. Clear pending intent on success, error, content mismatch, or generation mismatch. + +- [ ] **Step 4: Run focused detail tests and compile** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvNextUpSelectionHandoffTest' \ + --tests '*TvTrackSelectionPersistenceTest' \ + --tests '*TvItemDetailSubtitlePreferenceTest' \ + :androidTvApp:compileDebugKotlinAndroid --no-daemon +``` + +Expected: all selected tests pass and TV compilation succeeds. + +- [ ] **Step 5: Commit Task 6** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvNextUpSelectionHandoffTest.kt +git commit -m "fix(tv): retain selection across next-up detail refresh" +``` + +--- + +### Task 7: Verify the complete Android TV change and publish a draft + +**Files:** +- Modify only if evidence changes: tests or production files from Tasks 1-6. +- Review: `docs/superpowers/specs/2026-07-31-fire-tv-playback-selection-ux-design.md` +- Review: `docs/superpowers/plans/2026-08-01-fire-tv-playback-selection-ux.md` + +- [ ] **Step 1: Audit scope and forbidden changes** + +```bash +git diff --check upstream/main...HEAD +git diff --name-only upstream/main...HEAD +git diff --stat upstream/main...HEAD +git diff upstream/main...HEAD -- androidApp silo-server +``` + +Expected: no whitespace errors; no phone production, server, API, schema, database, proxy, or production-configuration diff. + +- [ ] **Step 2: Run supply-chain policy checks** + +```bash +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: both scripts exit zero without changing verification metadata. + +- [ ] **Step 3: Run the full shared and TV unit-test gate** + +```bash +./gradlew --no-daemon --max-workers=2 \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest +``` + +Expected: both unit-test tasks pass. If a failure appears, use `superpowers:systematic-debugging`; do not widen timeouts or rerun blindly. + +- [ ] **Step 4: Build debug and minified release variants** + +```bash +./gradlew --no-daemon --max-workers=2 \ + :androidTvApp:assembleDebug \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true +``` + +Expected: both Android TV assemblies succeed. This gate builds artifacts only; it does not install them. + +- [ ] **Step 5: Run emulator-only D-pad smoke if a dedicated TV emulator is already available** + +Use only an explicitly identified emulator serial. Do not target a physical Shield, Fire TV, phone, or unknown ADB device. Verify: + +1. Version, Audio, Subtitle, and Edition popup focus is clearly visible over bright and dark art. +2. A subtitle list longer than the viewport follows focus to its first and last options and Back restores the trigger. +3. Episode 1 to Episode 2 keeps a semantically matching resolution and subtitle even when IDs/indexes differ. +4. Explicit Off remains Off. +5. A missing explicit subtitle falls back to profile Auto. +6. Manual target selection and ordinary no-handoff launches remain unchanged. + +Capture serial-scoped screenshots/logs in a temporary directory outside the repository. If no suitable emulator is available, record that limitation in the PR instead of touching a physical device. + +- [ ] **Step 6: Request independent focused review** + +Use `superpowers:requesting-code-review` with the spec, plan, and `upstream/main...HEAD` diff. Require the reviewer to check: + +- serialized intent contains no raw IDs/indexes or credentials; +- explicit detail selection beats handoff, which beats target durable/automatic source choice; +- explicit missing subtitle cannot resurrect a target durable subtitle; +- Auto still permits existing target behavior; +- durable preference scope and write timing remain unchanged; +- generation fencing prevents a stale next-up refresh; +- Watch Together and playback shutdown sequencing remain unchanged; +- selector focus and HUD scroll fixes retain Back/focus behavior; +- Android phone behavior is unchanged. + +Address every substantive finding with a focused regression and rerun the smallest affected gate, then repeat review until approved. + +- [ ] **Step 7: Re-run final evidence after review fixes** + +```bash +git diff --check upstream/main...HEAD +./scripts/check-build-supply-chain.sh +./gradlew --no-daemon --max-workers=2 \ + :android-shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleRelease \ + -PallowDebugReleaseSigning=true +git status --short --branch +``` + +Expected: clean diff checks, green tests/build, and a clean branch. + +- [ ] **Step 8: Push and open a draft pull request** + +```bash +git push -u origin fix/firetv-playback-selection-ux +gh pr create --draft --base main --head fix/firetv-playback-selection-ux \ + --title "fix(tv): improve Fire TV selection and episode continuity" \ + --body-file /tmp/firetv-playback-selection-ux-pr.md +``` + +The PR body must list the three original defects, behavior decisions, exact test/build evidence, emulator limitation or evidence, security/privacy boundary, and confirmation that phone/server behavior is unchanged. Do not merge. + +--- + +## Plan Self-Review Checklist + +- [x] Every approved behavior in the design spec maps to a production step and a regression test. +- [x] Every named type and file exists now or is explicitly created by an earlier task. +- [x] No placeholder instructions, deferred hardening, arbitrary timeout changes, or raw cross-episode IDs/indexes remain. +- [x] Task ordering is dependency-safe and each task ends with focused verification and a small commit. +- [x] Full verification covers supply-chain policy, shared tests, TV tests, debug/release compilation, review, and emulator-only smoke without physical-device installation. + +Self-review found and corrected task-number drift introduced while composing the plan, removed test selectors for classes that do not exist on current `upstream/main`, and added explicit profile/server identity-boundary regressions. No unresolved contradiction or material ambiguity remains. diff --git a/docs/superpowers/plans/2026-08-02-android-tv-auth-ime-relocation.md b/docs/superpowers/plans/2026-08-02-android-tv-auth-ime-relocation.md new file mode 100644 index 000000000..24f87e5af --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-android-tv-auth-ime-relocation.md @@ -0,0 +1,100 @@ +# Android TV Auth IME Relocation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the stock Android TV keyboard while reliably revealing the focused field and its label across every TV authentication form. + +**Architecture:** Add one reusable Compose modifier that reacts to focus plus the measured IME inset after layout, and one reusable scroll-state helper that restores the normal top position when the IME closes. Apply those primitives to the existing auth screens and the shared TV text-input dialog, while giving the server screen one outer vertical scroll owner. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose Foundation, Android `WindowInsets.ime`, Kotlin/JUnit 4, Gradle, ADB. + +## Global Constraints + +- Continue using the stock Shield/Android TV IME. +- Preserve every keyboard-closed composition, style, focus order, and D-pad action. +- Reveal the focused field context with exactly 32dp of bottom clearance. +- Do not change server APIs, authentication logic, validation, or credential storage. +- Install the verified debug APK on `192.168.1.128:5555` without launching it. + +--- + +### Task 1: Shared IME-aware relocation primitives + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareForm.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvImeAwareFormTest.kt` + +**Interfaces:** +- Produces: `Modifier.tvImeAwareFieldContext(bottomClearance: Dp = 32.dp)`. +- Produces: `rememberTvImeAwareFormScrollState(): ScrollState`. +- Produces: pure internal relocation-key and keyboard-transition policies used by the Compose helpers and JVM tests. + +- [ ] **Step 1: Write failing JVM tests for relocation eligibility and keyboard-close restoration** + +Cover focus-before-IME, IME-before-focus, zero-size fields, non-zero IME size changes, duplicate snapshots, and visible-to-hidden restoration. Each expected result is a literal and exercises the production policy. + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: `JAVA_HOME=/Users/jimcole/.local/share/mise/installs/java/temurin-21.0.11+10.0.LTS ./gradlew :androidTvApp:testDebugUnitTest --tests '*TvImeAwareFormTest'` + +Expected: compilation failure because the shared production policy does not exist. + +- [ ] **Step 3: Implement the minimal shared helpers** + +The modifier records descendant focus and measured bounds, reads `WindowInsets.ime`, waits one frame after a valid key change, then requests a rectangle extending 32dp below the field context. The scroll helper resets only on a visible-to-hidden IME transition. Lifecycle cancellation and `runCatching` make disposal/navigation harmless. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the command from Step 2 and require zero failures. + +### Task 2: Apply the shared behavior to all TV entry forms + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvTextInputDialog.kt` + +**Interfaces:** +- Consumes: `Modifier.tvImeAwareFieldContext()` and `rememberTvImeAwareFormScrollState()` from Task 1. +- Preserves: existing `FocusRequester`, `KeyboardOptions`, `KeyboardActions`, validation callbacks, and stock-IME invocation. + +- [ ] **Step 1: Replace per-field focus-time requests with the shared modifier** + +Attach the modifier to each label-and-field context for server URL, login username/password, setup username/email/password, signup username/email/password/invite, and the shared text-input dialog field. + +- [ ] **Step 2: Give each screen the shared outer scroll state** + +Replace anonymous `rememberScrollState()` calls with `rememberTvImeAwareFormScrollState()`. + +- [ ] **Step 3: Remove the server card's competing vertical scroll owner** + +Allow the chooser row to keep a 300dp minimum height and grow for validation content; remove `ManualEntryCard`'s nested `verticalScroll` so the outer page owns IME relocation. + +- [ ] **Step 4: Compile and run Android TV unit tests** + +Run: `JAVA_HOME=/Users/jimcole/.local/share/mise/installs/java/temurin-21.0.11+10.0.LTS ./gradlew :androidTvApp:testDebugUnitTest :androidTvApp:assembleDebug` + +Expected: all tests pass and the universal debug APK is produced. + +### Task 3: Shield install and visual verification + +**Files:** +- Verify: `androidTvApp/build/outputs/apk/debug/androidTvApp-universal-debug.apk` + +**Interfaces:** +- Target package: `org.prairieserver.prairie`. +- Target device: `192.168.1.128:5555`. + +- [ ] **Step 1: Replace the existing debug installation without launching it** + +Run `adb -s 192.168.1.128:5555 install -r androidTvApp/build/outputs/apk/debug/androidTvApp-universal-debug.apk`. If the signing identity differs, uninstall only `org.prairieserver.prairie` and reinstall, as already authorized for this Shield task. + +- [ ] **Step 2: Verify package state** + +Use `dumpsys package org.prairieserver.prairie` to verify the expected version and `stopped=true notLaunched=true` immediately after installation. + +- [ ] **Step 3: Hand off visual QA** + +Do not launch Silo. Ask the user to open the server and login fields; once they do, capture Shield screenshots through ADB and confirm the label, complete field, and 32dp clearance are visible without layout oscillation. diff --git a/docs/superpowers/plans/2026-08-02-instant-external-srt-switching-android.md b/docs/superpowers/plans/2026-08-02-instant-external-srt-switching-android.md new file mode 100644 index 000000000..3cb3fe43b --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-instant-external-srt-switching-android.md @@ -0,0 +1,300 @@ +# Instant External SRT Switching Android Implementation Plan + +> Superseded protocol note (2026-08-06): the platform-neutral v3 contract no +> longer defines `external_text_sidecar_set_v1`. Subtitle support is advertised +> per delivery through `subtitles.sidecar_text`, and the server publishes the +> authoritative `playback_plan.subtitle.inventory`. The feature-token steps +> below are retained only as pre-neutral implementation history. +> +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Mount a negotiated server-provided external SRT/VTT set in Media3 while preserving the current selected-artifact and staged-replan behavior against older servers. + +**Architecture:** Extend the tolerant Kotlin V3 model, advertise the feature only through the local Media3 playback context, and merge valid sidecars into the existing `PlayerSubtitleInfo` pipeline. Absence of the field is represented by an empty list, so all pre-feature server behavior remains byte-for-byte on the existing path. + +**Tech Stack:** Kotlin Multiplatform, kotlinx.serialization, Android Media3, Kotlin/JUnit tests, Gradle. + +## Global Constraints + +- A server without `external_text_sidecar_set_v1` must continue working exactly as today. +- Missing or empty `subtitle.sidecars` must leave the singular selected artifact unchanged. +- Catalog rows without a mounted URL must continue to invoke staged replan. +- Only valid nonnegative SRT/SubRip or VTT/WebVTT sidecar entries may be mounted. +- Cast must not negotiate this local Media3 mounting feature. +- Keep stock Android IME and all previously approved TV UI behavior unchanged. +- Build and install the ARM64 debug TV APK on Shield without launching it. +- Commands assume the repository root is the cwd. + +--- + +### Task 1: Decode the additive sidecar contract and prove old-server compatibility + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt` + +**Interfaces:** +- Produces: `EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE`. +- Produces: `PlaybackSubtitleSidecarV3(trackId, index, url, mimeType, format, timingOriginSeconds)`. +- Produces: `PlaybackSubtitleDecisionV3.sidecars: List = emptyList()`. + +- [ ] **Step 1: Write the old-server regression test first** + +Decode a plan JSON whose subtitle object contains only the existing singular artifact: + +```kotlin +val decoded = PrairieJson.decodeFromString( + """{"plan_id":"plan","delivery":"original_http","engine":"media3_direct", + "stream":{"url":"/stream/session","protocol":"http_progressive"}, + "subtitle":{"mode":"convert","track_id":"file:42:subtitle:0", + "artifact":{"url":"/stream/session/subtitles/0.vtt","mime_type":"text/vtt","format":"vtt","timing_origin_seconds":0}}, + "decision_reason":"test"}""" +) +assertTrue(decoded.subtitle.sidecars.isEmpty()) +assertEquals("/stream/session/subtitles/0.vtt", decoded.subtitle.artifact?.url) +``` + +- [ ] **Step 2: Write the new-server decode test** + +Decode `sidecars` with one SRT entry and assert every field, including combined index and timing origin. + +- [ ] **Step 3: Run the shared test and confirm failure** + +Run: `./gradlew :shared:testDebugUnitTest --tests '*PlaybackProtocolV3Test*sidecar*'` + +Expected: compile failure because `sidecars` does not exist. + +- [ ] **Step 4: Add the serializable model with an empty default** + +Add: + +```kotlin +const val EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE = "external_text_sidecar_set_v1" + +@Serializable +data class PlaybackSubtitleSidecarV3( + @SerialName("track_id") val trackId: String, + val index: Int, + val url: String, + @SerialName("mime_type") val mimeType: String, + val format: String, + @SerialName("timing_origin_seconds") val timingOriginSeconds: Double = 0.0, +) +``` + +and `val sidecars: List = emptyList()` to `PlaybackSubtitleDecisionV3`. + +- [ ] **Step 5: Run and pass both compatibility tests** + +Run: `./gradlew :shared:testDebugUnitTest --tests '*PlaybackProtocolV3Test*'` + +Expected: PASS, including the singular-artifact-only old-server JSON. + +- [ ] **Step 6: Commit the shared contract** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt +git commit -m "feat(playback): decode external text sidecar sets" +``` + +### Task 2: Negotiate the feature only for local Media3 playback + +**Files:** +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt` +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt` + +**Interfaces:** +- Consumes: `EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE`. +- Produces: the feature in local `client_features` and local `client_playback_context.features`. +- Preserves: Cast context and Cast requests without the feature. + +- [ ] **Step 1: Add failing local-vs-Cast negotiation tests** + +Assert the local detected context contains the feature. Capture a normal V3 start request and assert both feature arrays contain it. Capture a Cast start request/context and assert neither feature array contains it. + +- [ ] **Step 2: Run tests and confirm failure** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackSessionManagerSeekReanchorTest*' --tests '*CastPlaybackPreparerTest*'` + +Expected: local assertions fail because the feature is absent. + +- [ ] **Step 3: Add local context negotiation** + +Add `EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE` to `PlaybackCapabilityDetector`'s `contextFeatures`. Do not add it to `chromecastPlaybackContext`. + +Add a shared helper: + +```kotlin +fun playbackStartClientFeatures(context: ClientPlaybackContext): List = + if (EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE in context.features) { + PLAYBACK_START_CLIENT_FEATURES_V3 + EXTERNAL_TEXT_SIDECAR_SET_V1_FEATURE + } else { + PLAYBACK_START_CLIENT_FEATURES_V3 + } +``` + +Pass `clientFeatures = playbackStartClientFeatures(clientPlaybackContext)` when `PlaybackSessionManager` creates `PlaybackStartRequestV3`. + +- [ ] **Step 4: Run and pass negotiation tests** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackSessionManagerSeekReanchorTest*' --tests '*CastPlaybackPreparerTest*'` + +Expected: PASS. + +- [ ] **Step 5: Commit feature negotiation** + +```bash +git add shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetector.kt android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt +git commit -m "feat(playback): negotiate external text sidecars" +``` + +### Task 3: Merge valid sidecars into the existing Media3 mount pipeline + +**Files:** +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt` +- Modify: `shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt` +- Modify: `shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt` + +**Interfaces:** +- Consumes: `PlaybackSubtitleDecisionV3.sidecars`. +- Produces: one `PlayerSubtitleInfo` per valid combined index with a nonblank URL. +- Preserves: singular-artifact-only output when `sidecars` is empty. + +- [ ] **Step 1: Add failing adapter tests** + +Add tests covering: + +- two valid sidecars become two mountable `PlayerSubtitleInfo` rows; +- a sidecar duplicating the selected singular artifact is deduplicated by index; +- negative index, blank URL, and unsupported `text/x-ssa` entries are ignored; +- `sidecars = emptyList()` returns exactly the existing one selected artifact; +- mode `OFF` can still carry mountable alternatives without selecting one. + +- [ ] **Step 2: Run tests and confirm failure** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackV3SessionTest*' :shared:testDebugUnitTest --tests '*PlaybackSubtitleChoicesTest*'` + +Expected: sidecars are decoded but not exposed to the session. + +- [ ] **Step 3: Implement a pure sidecar mapper** + +In `PlaybackV3Session.kt`, map only entries satisfying: + +```kotlin +sidecar.index >= 0 && sidecar.url.isNotBlank() && + sidecar.format.lowercase() in setOf("srt", "subrip", "vtt", "webvtt") && + sidecar.mimeType.lowercase().substringBefore(';') in + setOf("application/x-subrip", "text/vtt") +``` + +Create `PlayerSubtitleInfo(index = sidecar.index, codec = sidecar.format, source = "external", url = sidecar.url)` and combine it with the existing selected-artifact row. Deduplicate by index with the sidecar row preferred so its stable external identity and raw URL win. + +- [ ] **Step 4: Preserve catalog metadata during the existing merge** + +Use `buildPlaybackSubtitleChoices` unchanged where possible. Add only focused assertions that the planned sidecar URL survives while catalog language/title/forced/default metadata are copied onto the row. + +- [ ] **Step 5: Run and pass adapter and catalog tests** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackV3SessionTest*' :shared:testDebugUnitTest --tests '*PlaybackSubtitleChoicesTest*'` + +Expected: PASS. + +- [ ] **Step 6: Commit sidecar mounting data flow** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackV3SessionTest.kt shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt +git commit -m "feat(player): mount negotiated external text sidecars" +``` + +### Task 4: Prove mounted switches are instant and old-server switches still replan + +**Files:** +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt` + +**Interfaces:** +- Consumes: mounted `PlayerSubtitleInfo` rows from Task 3. +- Preserves: staged replan for catalog rows with blank URLs or absent mounted track IDs. + +- [ ] **Step 1: Add the new-server fast-path integration test** + +Create a session with two server sidecars and a fake Media3 track graph containing `silo-subtitle:0` and `silo-subtitle:1`. Select index 1 and assert: + +```kotlin +assertEquals(1, localSubtitleSelections.size) +assertEquals(0, replanRequests.size) +assertEquals(0, mediaItemReplacements.size) +``` + +- [ ] **Step 2: Add the mandatory old-server fallback regression test** + +Create a plan with only the existing selected artifact at index 0 and a catalog-only index 1 with `url = ""`. Select index 1 and assert: + +```kotlin +assertEquals(0, localSubtitleSelections.size) +assertEquals(1, replanRequests.size) +assertEquals(1, stagedPlanPublications.size) +``` + +Also assert playback position and pause/play state are restored through the existing staged-replan path. This test is the acceptance gate for the user's backward-compatibility requirement. + +- [ ] **Step 3: Run both focused paths** + +Run: `./gradlew :android-shared:testDebugUnitTest --tests '*PlaybackSessionManagerStagedReplanTest*' :androidTvApp:testDebugUnitTest --tests '*TvSubtitleTransactionAdapterTest*'` + +Expected: PASS without changing production fallback logic. + +- [ ] **Step 4: Commit compatibility coverage** + +```bash +git add android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt +git commit -m "test(player): preserve old-server subtitle replans" +``` + +### Task 5: Verify, build, and install without launching + +**Files:** +- Verify only. + +**Interfaces:** +- Produces: an installed Shield debug build with both negotiated and legacy paths covered. + +- [ ] **Step 1: Run all Android tests** + +Run: `./gradlew test` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 2: Build the ARM64 TV debug APK** + +Run: `./gradlew :androidTvApp:assembleDebug` + +Expected: BUILD SUCCESSFUL and a debug APK under `androidTvApp/build/outputs/apk/debug/`. + +- [ ] **Step 3: Confirm Shield connectivity and ABI** + +Run: `adb -s 192.168.1.128:5555 get-state && adb -s 192.168.1.128:5555 shell getprop ro.product.cpu.abi` + +Expected: `device` and `arm64-v8a`. + +- [ ] **Step 4: Install without launching** + +Run: `adb -s 192.168.1.128:5555 install -r androidTvApp/build/outputs/apk/debug/androidTvApp-debug.apk` + +Expected: `Success`. + +- [ ] **Step 5: Ensure the app remains stopped** + +Run: `adb -s 192.168.1.128:5555 shell am force-stop org.prairieserver.prairie` + +Expected: no activity launch command is issued. + +- [ ] **Step 6: Inspect final state** + +Run: `git status --short --branch && git log --oneline -8` + +Expected: clean working tree on local `main`, ahead of `upstream/main` only by intentional commits. diff --git a/docs/superpowers/plans/2026-08-02-tv-mounted-srt-fast-switch.md b/docs/superpowers/plans/2026-08-02-tv-mounted-srt-fast-switch.md new file mode 100644 index 000000000..12c199de4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-tv-mounted-srt-fast-switch.md @@ -0,0 +1,202 @@ +# TV Mounted SRT Fast Switching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Switch an already-mounted Android TV SRT sidecar through Media3 track selection without staging a new playback session or re-preparing video. + +**Architecture:** Extend the transaction adapter's locally mountable identity gate to admit `ServerSidecar`. The injected typed mounted-track resolver remains authoritative: a sidecar takes the shortcut only when the exact identity exists in the live Media3 snapshot, while all other cases retain the server-replan path. + +**Tech Stack:** Kotlin 2.1, Android Media3, Kotlin coroutines/Flow, JUnit/Kotlin Test, Gradle. + +## Global Constraints + +- Do not change phone playback behavior. +- Do not change server burn-in or subtitle conversion decisions. +- Do not replace the active `MediaItem` for an already-mounted SRT switch. +- Preserve acknowledgement, rollback, persistence, supersession, and coupled audio/quality/output-route behavior. +- Keep unmounted or unresolved sidecars on the staged server-replan path. +- Build the ARM64 TV debug APK and install it on `192.168.1.128:5555` without launching it. + +--- + +### Task 1: Route Mounted Server Sidecars Through Local Confirmation + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt` + +**Interfaces:** +- Consumes: `TvSubtitleTransactionAdapter(isLocallyMountable: (SubtitleIdentity) -> Boolean)` and `SubtitleIdentity.ServerSidecar`. +- Produces: `requiresLocalMountConfirmation(): Boolean` returns `true` for `ServerSidecar`; exact mounted-track resolution still decides whether `commitLocallyMountableSelection` succeeds. + +- [ ] **Step 1: Add a failing mounted-sidecar regression test** + +Add near the embedded local-selection tests: + +```kotlin +@Test +fun `a server sidecar the player already exposes stays local`() = runTest { + val target = sidecar(4) + val harness = harness( + backgroundScope, + isLocallyMountable = { identity -> identity == target }, + ) + + harness.adapter.select(target) + runCurrent() + + assertTrue( + harness.port.requests.isEmpty(), + "an already-mounted sidecar must not ask the server to replan", + ) + assertEquals(target, harness.adapter.snapshot.localMountIdentity) + assertEquals(sidecar(3), harness.adapter.snapshot.committedIdentity) + + harness.adapter.reportMountedSelection( + identity = target, + selected = true, + snapshotKey = "mounted-sidecar-selected", + settled = true, + ) + runCurrent() + + assertEquals(target, harness.adapter.snapshot.committedIdentity) + assertNull(harness.adapter.snapshot.pendingIdentity) + assertEquals(listOf(target), harness.persistence.persisted.map { it.identity }) +} +``` + +- [ ] **Step 2: Add an unmounted-sidecar fallback regression test** + +```kotlin +@Test +fun `a server sidecar the player cannot expose is staged to the server`() = runTest { + val harness = harness(backgroundScope, isLocallyMountable = { false }) + + harness.adapter.select(sidecar(4)) + runCurrent() + + assertEquals( + listOf(4), + harness.port.requests.map { it.subtitleTrackIndex }, + "an unmounted sidecar must retain the staged replan fallback", + ) + assertNull(harness.adapter.snapshot.localMountIdentity) +} +``` + +Change the test harness default from an unconditional local result to a realistic non-sidecar default: + +```kotlin +isLocallyMountable: (SubtitleIdentity) -> Boolean = { identity -> + identity !is SubtitleIdentity.ServerSidecar +}, +``` + +- [ ] **Step 3: Run the focused test and verify RED** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleTransactionAdapterTest' +``` + +Expected: the mounted-sidecar test fails because it produces a staged request and no `localMountIdentity`. The fallback test passes. + +- [ ] **Step 4: Implement the minimal production change** + +```kotlin +private fun SubtitleIdentity.requiresLocalMountConfirmation(): Boolean = + this is SubtitleIdentity.ServerSidecar || + this is SubtitleIdentity.LocalMedia3 || + this is SubtitleIdentity.Downloaded || + this is SubtitleIdentity.Embedded +``` + +Do not change `isClientOwnedSubtitle`, `serverTrackIndex`, staged validation, or media mounting. + +- [ ] **Step 5: Run the focused test and verify GREEN** + +Run the command from Step 3. Expected: all `TvSubtitleTransactionAdapterTest` tests pass. + +- [ ] **Step 6: Run adjacent transaction tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleSettlementOwnershipTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleFinalRollbackTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleMountDeadlineTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.SubtitleTransactionIntegrationTest' +``` + +Expected: all transaction, rollback, timeout, and integration tests pass unchanged. + +- [ ] **Step 7: Commit the tested behavior** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapter.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvSubtitleTransactionAdapterTest.kt +git commit -m "fix(tv): switch mounted SRT subtitles without rebuffering" +``` + +--- + +### Task 2: Verify, Build, and Install Without Launching + +**Files:** +- Verify: all repository sources and tests +- Build output: `androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk` + +**Interfaces:** +- Consumes: the mounted-sidecar fast path from Task 1. +- Produces: a tested ARM64 debug APK installed on the Shield, with `org.prairieserver.prairie` force-stopped. + +- [ ] **Step 1: Run complete verification** + +```bash +./gradlew test :androidTvApp:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`; all tests pass and the TV debug APK is assembled. + +- [ ] **Step 2: Check final repository state** + +```bash +git diff --check +git status --short --branch +git log -4 --oneline +``` + +Expected: no whitespace errors or uncommitted implementation changes. + +- [ ] **Step 3: Install the ARM64 debug APK** + +```bash +adb -s 192.168.1.128:5555 install -r \ + androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk +``` + +Expected: `Success`. Do not issue `am start`, `monkey`, D-pad input, or navigation. + +- [ ] **Step 4: Force-stop and verify the app remains closed** + +```bash +adb -s 192.168.1.128:5555 shell am force-stop org.prairieserver.prairie +adb -s 192.168.1.128:5555 shell \ + 'pidof org.prairieserver.prairie >/dev/null; code=$?; echo pidof_exit=$code; exit 0' +``` + +Expected: `pidof_exit=1`. + +- [ ] **Step 5: Verify installed metadata** + +```bash +adb -s 192.168.1.128:5555 shell dumpsys package org.prairieserver.prairie \ + | rg 'primaryCpuAbi=|versionCode=|versionName=|DEBUGGABLE' +``` + +Expected: `primaryCpuAbi=arm64-v8a` and `DEBUGGABLE`, with the current project version. + +- [ ] **Step 6: Report completion** + +Report focused/full verification, installed version and ABI, stopped-process proof, commit hashes, local upstream divergence, and that nothing was pushed. diff --git a/docs/superpowers/plans/2026-08-02-tv-player-transport-accessibility.md b/docs/superpowers/plans/2026-08-02-tv-player-transport-accessibility.md new file mode 100644 index 000000000..d1e35f39d --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-tv-player-transport-accessibility.md @@ -0,0 +1,169 @@ +# TV Player Transport Accessibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make D-pad Down reveal and focus the transport controls, and enlarge the existing icon-only controls without changing their visual style. + +**Architecture:** Change the shared remote-key classifier so hidden and visible playback both route Down to the transport focus target. Extract transport dimensions into a small pure policy consumed by the Compose row, allowing JVM tests to enforce minimum legibility while the UI retains its existing circles, grouping, and focus inversion. + +**Tech Stack:** Kotlin, Jetpack Compose for TV, Android `KeyEvent`, Kotlin test, Gradle, ADB. + +## Global Constraints + +- D-pad Down while playback controls are hidden reveals the idle overlay and focuses Play/Pause. +- Menu and Settings remote keys continue opening the information/settings HUD. +- Preserve circular controls, grouping, icon-only presentation, borders, colors, and white/black focus inversion. +- Every transport button is 44dp; Play/Pause is 22dp; every secondary glyph is 20dp. +- Keep the 5dp inter-button gap and existing left/right group layout. +- Do not change subtitle selection, HUD content, player state, or transport actions. +- Build and install the ARM64 debug APK without launching it. + +--- + +### Task 1: Route D-pad Down to Play/Pause + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt` + +**Interfaces:** +- Consumes: `tvPlayerRemoteKeyAction(keyCode: Int, action: Int, repeatCount: Int, dpadHorizontalSeek: Boolean)`. +- Produces: `TvPlayerRemoteKeyAction.FocusTransport` for the initial D-pad Down press in both hidden- and visible-overlay states. + +- [ ] **Step 1: Change the existing Down-key test to express the desired behavior** + +Rename the test to `down always moves focus to transport while menu and settings open hud`. Require `FocusTransport` for both default and `dpadHorizontalSeek = false` calls. Keep the existing Menu and Settings assertions requiring `OpenHud`. + +- [ ] **Step 2: Run the focused test and verify the regression assertion fails** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvPlayerRemoteKeyActionTest.down always moves focus to transport while menu and settings open hud' +``` + +Expected: FAIL because hidden-overlay Down currently returns `OpenHud`. + +- [ ] **Step 3: Implement the minimal mapping change** + +In `tvPlayerRemoteKeyAction`, map `KEYCODE_DPAD_DOWN` on the initial `ACTION_DOWN` directly to `FocusTransport`, independent of `dpadHorizontalSeek`. Continue returning `null` for KeyUp. Update the nearby comment to describe transport-first behavior; leave Menu and Settings handling unchanged. + +- [ ] **Step 4: Run the complete remote-key test class** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvPlayerRemoteKeyActionTest' +``` + +Expected: PASS with no failures. + +- [ ] **Step 5: Commit the navigation fix** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyAction.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerRemoteKeyActionTest.kt +git commit -m "fix(tv): focus player transport on dpad down" +``` + +### Task 2: Enforce legible transport dimensions + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicy.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicyTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt` + +**Interfaces:** +- Produces: `TvTransportControlMetrics(buttonSizeDp: Float, symbolSizeDp: Float)`. +- Produces: `tvTransportControlMetrics(isPrimary: Boolean): TvTransportControlMetrics`. +- Consumes: those metrics in `TransportIconButton` before converting each Float to Compose `Dp`. + +- [ ] **Step 1: Extract the current dimensions without changing behavior** + +Create the pure policy with the existing values: button `33f`, primary glyph `15f`, secondary glyph `12.5f`. Replace the local constants in `TransportIconButton` with values returned by `tvTransportControlMetrics(isPrimary)`. + +- [ ] **Step 2: Verify the behavior-preserving extraction compiles** + +Run: + +```bash +./gradlew :androidTvApp:compileDebugKotlinAndroid +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Add a failing legibility test** + +Create tests that require both primary and secondary button targets to be at least `44f`, require the secondary glyph to be at least `20f`, and require the primary glyph to be at least `22f`. These thresholds independently encode the approved television legibility contract. + +- [ ] **Step 4: Run the policy test and verify it fails on the current sizes** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvPlayerTransportVisualPolicyTest' +``` + +Expected: FAIL because the extracted policy still returns 33dp buttons and 12.5dp/15dp glyphs. + +- [ ] **Step 5: Update the policy to the approved dimensions** + +Return `44f` for every button, `22f` for the primary glyph, and `20f` for secondary glyphs. Do not alter gaps, colors, focus behavior, grouping, or descriptions. + +- [ ] **Step 6: Run the policy and remote-key tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvPlayerTransportVisualPolicyTest' --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvPlayerRemoteKeyActionTest' +``` + +Expected: PASS with no failures. + +- [ ] **Step 7: Commit the visual sizing fix** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportCluster.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicy.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerTransportVisualPolicyTest.kt +git commit -m "fix(tv): enlarge player transport controls" +``` + +### Task 3: Full verification and Shield installation + +**Files:** +- Verify all files changed in Tasks 1 and 2. +- Build artifact: `androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk`. + +**Interfaces:** +- Consumes: completed navigation and dimension policies. +- Produces: a verified debug APK installed on the Shield with the app stopped. + +- [ ] **Step 1: Run the full Android test and TV build command** + +Run: + +```bash +./gradlew test :androidTvApp:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 2: Check repository cleanliness and patch formatting** + +Run `git diff --check`, inspect `git status --short --branch`, and confirm local `main` remains zero commits behind `upstream/main`. + +- [ ] **Step 3: Install the ARM64 debug APK without launching** + +Run: + +```bash +adb -s 192.168.1.128:5555 install -r androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk +``` + +Expected: `Success`. Do not issue `am start`, `monkey`, or any other launch command. + +- [ ] **Step 4: Verify installed package and stopped state** + +Read `dumpsys package org.prairieserver.prairie` for version information and run `pidof org.prairieserver.prairie`. The package query must succeed and `pidof` must return no process immediately after installation. + +- [ ] **Step 5: Report the result** + +Report the two implementation commits, full test/build result, installed debug version, stopped app state, and whether anything was pushed. diff --git a/docs/superpowers/plans/2026-08-02-tv-subtitle-picker-and-sizing.md b/docs/superpowers/plans/2026-08-02-tv-subtitle-picker-and-sizing.md new file mode 100644 index 000000000..893d0f424 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-tv-subtitle-picker-and-sizing.md @@ -0,0 +1,401 @@ +# TV Subtitle Picker Dismissal and Sizing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close all playback chrome after a CC quick-picker selection and render plain-text television subtitles at consistent, couch-readable fixed SP sizes. + +**Architecture:** Add a pure quick-picker chrome policy so selection and Back remain intentionally different, then consume it from the player screen's local quick-picker state. Add a pure Android subtitle text-size policy that keeps phone fractions intact but returns fixed SP values for television; `SubtitleManager` translates that policy into the appropriate Media3 API. + +**Tech Stack:** Kotlin, Jetpack Compose for TV, Media3 `SubtitleView`, Robolectric/Kotlin test, Gradle. + +## Global Constraints + +- Selecting any CC quick-picker row, including Off, applies the selection, closes the picker, and hides playback controls. +- Back closes only the CC quick picker and leaves playback controls visible. +- The Settings HUD subtitle-track picker remains unchanged. +- TV plain-text subtitle sizes are exactly Small 18sp, Medium 22sp, Large 26sp, X-Large 32sp, and XX-Large 40sp. +- Phone subtitle fractions remain exactly 22.5/720, 29.25/720, 36/720, 45/720, and 54/720. +- ASS/SSA subtitles continue preserving authored libass styling. +- Do not change subtitle transactions, persistence, search, download, translation, remount, or failure behavior. + +--- + +### Task 1: Close the CC quick picker and playback controls after selection + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicy.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicyTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt` + +**Interfaces:** +- Produces: `TvQuickSubtitlePickerExit` with `Selection` and `Back`. +- Produces: `TvQuickSubtitlePickerChromeState(pickerVisible: Boolean, controlsVisible: Boolean)`. +- Produces: `tvQuickSubtitlePickerChromeState(exit: TvQuickSubtitlePickerExit): TvQuickSubtitlePickerChromeState`. +- Consumes: the policy in `TvPlayerScreen` after a valid quick-picker row resolves to a `SubtitleIdentity`. + +- [ ] **Step 1: Write the failing quick-picker chrome policy test** + +Create `TvQuickSubtitlePickerChromePolicyTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvQuickSubtitlePickerChromePolicyTest { + @Test + fun selectionClosesPickerAndPlaybackControls() { + assertEquals( + TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = false, + ), + tvQuickSubtitlePickerChromeState(TvQuickSubtitlePickerExit.Selection), + ) + } + + @Test + fun backClosesPickerButKeepsPlaybackControlsVisible() { + assertEquals( + TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = true, + ), + tvQuickSubtitlePickerChromeState(TvQuickSubtitlePickerExit.Back), + ) + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvQuickSubtitlePickerChromePolicyTest' +``` + +Expected: compilation fails because the policy types and function do not exist. + +- [ ] **Step 3: Add the minimal pure chrome policy** + +Create `TvQuickSubtitlePickerChromePolicy.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.screens.player + +internal enum class TvQuickSubtitlePickerExit { + Selection, + Back, +} + +internal data class TvQuickSubtitlePickerChromeState( + val pickerVisible: Boolean, + val controlsVisible: Boolean, +) + +internal fun tvQuickSubtitlePickerChromeState( + exit: TvQuickSubtitlePickerExit, +): TvQuickSubtitlePickerChromeState = when (exit) { + TvQuickSubtitlePickerExit.Selection -> TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = false, + ) + TvQuickSubtitlePickerExit.Back -> TvQuickSubtitlePickerChromeState( + pickerVisible = false, + controlsVisible = true, + ) +} +``` + +- [ ] **Step 4: Wire distinct selection and Back outcomes into the quick picker** + +In `TvPlayerScreen`, add a local helper beside `selectTvSubtitle`: + +```kotlin +fun applyQuickSubtitlePickerExit(exit: TvQuickSubtitlePickerExit) { + val chrome = tvQuickSubtitlePickerChromeState(exit) + showQuickSubtitlePicker = chrome.pickerVisible + viewModel.setControlsVisible(chrome.controlsVisible) +} +``` + +Change the `TvQuickSubtitlePicker` call to provide a selection callback that applies the existing selection first and then the selection exit: + +```kotlin +TvQuickSubtitlePicker( + presentation = subtitlePresentation, + onSelect = { identity -> + subtitlePresentation.onSelect(identity) + applyQuickSubtitlePickerExit(TvQuickSubtitlePickerExit.Selection) + }, + onDismiss = { + applyQuickSubtitlePickerExit(TvQuickSubtitlePickerExit.Back) + }, +) +``` + +Change `TvQuickSubtitlePicker` to accept `onSelect: (SubtitleIdentity) -> Unit`, and forward only a successfully resolved row: + +```kotlin +@Composable +private fun TvQuickSubtitlePicker( + presentation: TvSubtitleHudPresentation, + onSelect: (SubtitleIdentity) -> Unit, + onDismiss: () -> Unit, +) { + // existing setup remains + HudPickerDialog( + presentation = HudPickerPresentation( + // existing title/options/selection/focus remain + closeOnSelect = false, + onFocused = presentation.onFocused, + onSelect = { stableId -> + presentation.rows + .firstOrNull { row -> row.stableId == stableId } + ?.let { row -> onSelect(row.identity) } + }, + ), + onClose = onDismiss, + ) +} +``` + +Keep the HUD subtitle picker and all shared subtitle transaction callbacks unchanged. Retain `closeOnSelect = false` because the local Compose state removes the quick picker after the valid selection callback; an invalid stable ID must not dismiss it. + +- [ ] **Step 5: Run the focused policy and existing subtitle presentation tests** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvQuickSubtitlePickerChromePolicyTest' \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleHudStateTest' +``` + +Expected: PASS with no failures. + +- [ ] **Step 6: Commit the quick-picker behavior** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicy.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvQuickSubtitlePickerChromePolicyTest.kt +git commit -m "fix(tv): dismiss player chrome after subtitle selection" +``` + +### Task 2: Render television plain-text subtitles with fixed SP presets + +**Files:** +- Create: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicy.kt` +- Create: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicyTest.kt` +- Modify: `android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt` +- Modify: `android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt` + +**Interfaces:** +- Produces: sealed `AndroidSubtitleTextSize` with `Fractional(fraction: Float)` and `FixedSp(sp: Float)`. +- Produces: `androidSubtitleTextSize(presentation: AndroidSubtitlePresentation, preset: SubtitleFontSizePreset): AndroidSubtitleTextSize`. +- Consumes: the result in `SubtitleManager.applyAppearance` via Media3 `setFractionalTextSize` or `setFixedTextSize`. + +- [ ] **Step 1: Write the failing pure size-policy tests** + +Create `AndroidSubtitleTextSizePolicyTest.kt`: + +```kotlin +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset +import kotlin.test.Test +import kotlin.test.assertEquals + +class AndroidSubtitleTextSizePolicyTest { + @Test + fun televisionUsesFixedCouchReadableSpLadder() { + val expected = mapOf( + SubtitleFontSizePreset.Small to 18f, + SubtitleFontSizePreset.Medium to 22f, + SubtitleFontSizePreset.Large to 26f, + SubtitleFontSizePreset.XLarge to 32f, + SubtitleFontSizePreset.XXLarge to 40f, + ) + + expected.forEach { (preset, sp) -> + assertEquals( + AndroidSubtitleTextSize.FixedSp(sp), + androidSubtitleTextSize(AndroidSubtitlePresentation.Television, preset), + ) + } + } + + @Test + fun phonePreservesExistingFractionalLadder() { + val expected = mapOf( + SubtitleFontSizePreset.Small to 22.5f / 720f, + SubtitleFontSizePreset.Medium to 29.25f / 720f, + SubtitleFontSizePreset.Large to 36f / 720f, + SubtitleFontSizePreset.XLarge to 45f / 720f, + SubtitleFontSizePreset.XXLarge to 54f / 720f, + ) + + expected.forEach { (preset, fraction) -> + assertEquals( + AndroidSubtitleTextSize.Fractional(fraction), + androidSubtitleTextSize(AndroidSubtitlePresentation.Phone, preset), + ) + } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest --tests 'org.prairieserver.prairie.common.player.AndroidSubtitleTextSizePolicyTest' +``` + +Expected: compilation fails because `AndroidSubtitleTextSize` and `androidSubtitleTextSize` do not exist. + +- [ ] **Step 3: Add the minimal pure subtitle-size policy** + +Create `AndroidSubtitleTextSizePolicy.kt`: + +```kotlin +package org.prairieserver.prairie.common.player + +import org.prairieserver.prairie.model.settings.SubtitleFontSizePreset + +internal sealed interface AndroidSubtitleTextSize { + data class Fractional(val fraction: Float) : AndroidSubtitleTextSize + data class FixedSp(val sp: Float) : AndroidSubtitleTextSize +} + +internal fun androidSubtitleTextSize( + presentation: AndroidSubtitlePresentation, + preset: SubtitleFontSizePreset, +): AndroidSubtitleTextSize = when (presentation) { + AndroidSubtitlePresentation.Phone -> AndroidSubtitleTextSize.Fractional( + when (preset) { + SubtitleFontSizePreset.Small -> 22.5f + SubtitleFontSizePreset.Medium -> 29.25f + SubtitleFontSizePreset.Large -> 36f + SubtitleFontSizePreset.XLarge -> 45f + SubtitleFontSizePreset.XXLarge -> 54f + } / 720f, + ) + AndroidSubtitlePresentation.Television -> AndroidSubtitleTextSize.FixedSp( + when (preset) { + SubtitleFontSizePreset.Small -> 18f + SubtitleFontSizePreset.Medium -> 22f + SubtitleFontSizePreset.Large -> 26f + SubtitleFontSizePreset.XLarge -> 32f + SubtitleFontSizePreset.XXLarge -> 40f + }, + ) +} +``` + +- [ ] **Step 4: Make `SubtitleManager` consume the size policy** + +Import `androidx.annotation.Dimension`. In `applyAppearance`, replace the unconditional fractional call with: + +```kotlin +when (val textSize = androidSubtitleTextSize(presentation, safe.fontSize)) { + is AndroidSubtitleTextSize.Fractional -> subtitleView.setFractionalTextSize( + textSize.fraction, + /* fractionalRelativeToTextSize = */ false, + ) + is AndroidSubtitleTextSize.FixedSp -> subtitleView.setFixedTextSize( + Dimension.SP, + textSize.sp, + ) +} +``` + +Delete the now-unused private `fractionalSizeFor` method. Do not change `setApplyEmbeddedStyles(false)`, `setApplyEmbeddedFontSizes(false)`, libass attachment, style, position, or video-bound synchronization. + +- [ ] **Step 5: Remove the obsolete reflection assertions from the appearance test** + +In `SubtitleManagerAppearanceTest.kt`, remove `phoneSubtitleTextFractionsAreOneEighthLarger`, `televisionSubtitleTextFractionsPreserveExistingScale`, and their private `fractionalSize` reflection helper. The new pure policy test replaces those exact-value assertions; keep every style, padding, libass, and video-bound test unchanged. + +- [ ] **Step 6: Run size-policy and appearance tests** + +Run: + +```bash +./gradlew :android-shared:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.common.player.AndroidSubtitleTextSizePolicyTest' \ + --tests 'org.prairieserver.prairie.common.player.SubtitleManagerAppearanceTest' +``` + +Expected: PASS with no failures. + +- [ ] **Step 7: Run the TV wiring test and compile the TV app** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.player.TvSubtitleAspectSyncWiringTest' \ + :androidTvApp:compileDebugKotlinAndroid +``` + +Expected: PASS and BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit the fixed television sizing** + +```bash +git add android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicy.kt \ + android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleManager.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/AndroidSubtitleTextSizePolicyTest.kt \ + android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/SubtitleManagerAppearanceTest.kt +git commit -m "fix(tv): use readable fixed subtitle sizes" +``` + +### Task 3: Full verification and debug APK assembly + +**Files:** +- Verify all files changed in Tasks 1 and 2. +- Build artifact: `androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk`. + +**Interfaces:** +- Consumes: the completed quick-picker chrome and subtitle-size policies. +- Produces: a verified ARM64 TV debug APK; installation is intentionally not performed without separate user authorization. + +- [ ] **Step 1: Run the full Android test and TV build command** + +Run: + +```bash +./gradlew test :androidTvApp:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 2: Check repository cleanliness and patch formatting** + +Run: + +```bash +git diff --check +git status --short --branch +git rev-list --left-right --count upstream/main...main +``` + +Expected: no formatting errors, no uncommitted source changes, and local `main` remains zero commits behind `upstream/main`. + +- [ ] **Step 3: Verify the ARM64 debug artifact exists** + +Run: + +```bash +test -f androidTvApp/build/outputs/apk/debug/androidTvApp-arm64-v8a-debug.apk +``` + +Expected: exit code 0. + +- [ ] **Step 4: Report completion** + +Report the two implementation commits, focused and full test results, APK path, repository divergence, and whether anything was pushed or installed. diff --git a/docs/superpowers/plans/2026-08-03-apk-only-release-publish.md b/docs/superpowers/plans/2026-08-03-apk-only-release-publish.md new file mode 100644 index 000000000..5396fb553 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-apk-only-release-publish.md @@ -0,0 +1,133 @@ +# APK-only GitHub Release Publishing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure APK-only release tags automatically publish their signed APKs as the normal GitHub `Latest` release when Google Play is intentionally skipped. + +**Architecture:** Keep the current release graph and add an explicit status-aware gate to the final `publish-release` job. Protect that gate with a focused shell invariant test executed by the release workflow before the Gradle test suite. + +**Tech Stack:** GitHub Actions YAML, Bash, `actionlint`, GitHub CLI. + +## Global Constraints + +- Play publishing must remain skipped for prerelease-suffixed tags. +- Setup, test, Play, signing, or APK build failures must continue blocking GitHub releases. +- Cancelled runs must not publish. +- Release naming, asset naming, release classification, and GitHub `Latest` behavior must not change. +- The validation process must not trigger a new release. + +--- + +### Task 1: Add the release gate regression test + +**Files:** +- Create: `scripts/test-release-workflow.sh` +- Modify: `.github/workflows/release.yml` +- Test: `scripts/test-release-workflow.sh` + +**Interfaces:** +- Consumes: `.github/workflows/release.yml` and its `publish-release` job. +- Produces: an executable self-test that exits nonzero unless the final release job explicitly requires non-cancellation and successful `setup` and `apks` jobs. + +- [ ] **Step 1: Create the focused workflow invariant test** + +Create `scripts/test-release-workflow.sh` with strict Bash mode. Resolve the repository root relative to the script, extract the `publish-release` job from `.github/workflows/release.yml`, and assert that it contains these exact invariants: + +```text +needs: [setup, apks] +if: >- +!cancelled() +needs.setup.result == 'success' +needs.apks.result == 'success' +``` + +Each missing invariant must print `FAIL: publish-release must ...` to stderr and increment a failure counter. A clean run prints `All release workflow self-tests passed`. + +- [ ] **Step 2: Run the test to verify RED** + +Run: + +```bash +chmod +x scripts/test-release-workflow.sh +./scripts/test-release-workflow.sh +``` + +Expected: nonzero exit with `FAIL: publish-release must define an explicit job condition` because the current workflow has no `if` gate. + +- [ ] **Step 3: Add the minimal publish condition** + +Add this immediately after `needs: [setup, apks]`: + +```yaml +if: >- + ${{ !cancelled() && + needs.setup.result == 'success' && + needs.apks.result == 'success' }} +``` + +- [ ] **Step 4: Execute the regression test in release CI** + +In the `unit-tests` job, add this command before the existing supply-chain checks: + +```bash +./scripts/test-release-workflow.sh +``` + +- [ ] **Step 5: Verify GREEN** + +Run: + +```bash +bash -n scripts/test-release-workflow.sh +./scripts/test-release-workflow.sh +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: every command exits zero and both self-test suites print their success messages. + +- [ ] **Step 6: Commit the implementation** + +```bash +git add scripts/test-release-workflow.sh .github/workflows/release.yml +git commit -m "fix(ci): publish APK-only GitHub releases" +``` + +### Task 2: Validate and deliver + +**Files:** +- Verify: `.github/workflows/release.yml` +- Verify: `scripts/test-release-workflow.sh` + +**Interfaces:** +- Consumes: the completed workflow fix and its regression test. +- Produces: a validated branch and pull request against `Silo-Server/prairie-android:main`. + +- [ ] **Step 1: Validate workflow syntax and repository state** + +Run: + +```bash +actionlint .github/workflows/release.yml +git diff --check upstream/main...HEAD +git status --short --branch +``` + +Expected: `actionlint` and `git diff --check` exit zero; the worktree is clean and the branch is ahead of `upstream/main` only by the design, plan, and implementation commits. + +- [ ] **Step 2: Re-run the full focused verification** + +Run: + +```bash +bash -n scripts/test-release-workflow.sh +./scripts/test-release-workflow.sh +./scripts/test-check-build-supply-chain.sh +./scripts/check-build-supply-chain.sh +``` + +Expected: all commands exit zero. + +- [ ] **Step 3: Push and open the pull request** + +Push `fix/apk-only-release-publish` to the writable fork remote and open a PR targeting `Silo-Server/prairie-android:main`. The PR body must document run `30814079342` as the reproduction, explain the explicit status gate, state that Play and `Latest` behavior are unchanged, and list every verification command. diff --git a/docs/superpowers/plans/2026-08-03-firetv-rc-review-fixes.md b/docs/superpowers/plans/2026-08-03-firetv-rc-review-fixes.md new file mode 100644 index 000000000..f40edb5db --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-firetv-rc-review-fixes.md @@ -0,0 +1,79 @@ +# Fire TV rc.1+4 Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve every actionable review finding on Android PR #161 without regressing reanchored playback, track persistence, or TV focus behavior. + +**Architecture:** Keep layout constants shared, model exit-position and source-start decisions as testable pure logic, and use the established focus-scoped re-anchor loop for Compose focus relocation. Preserve the existing player timeline mapping while bypassing only the transient presentation gates during final Stop capture. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose for TV, Media3, Kotlin/JUnit tests, Gradle. + +## Global Constraints + +- Preserve compatibility with legacy long pairing codes and newly bounded server codes. +- Preserve source/movie-time mapping for reanchored HLS playback. +- Keep explicit subtitle Off (`-1`) distinct from unresolved/keep-current (`null`). +- Do not alter phone behavior. + +--- + +### Task 1: Pairing-code shared width + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvLoginMatchCodeLayoutTest.kt` + +- [x] Verify `matchCodeTileWidthDp` budgets `MATCH_CODE_SEPARATOR_WIDTH_DP` while `MatchCodeCard` renders 12dp. +- [x] Render separators with `MATCH_CODE_SEPARATOR_WIDTH_DP.dp`. +- [x] Run the match-code layout tests. + +### Task 2: Nullable track-selection persistence + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvItemDetailViewModel.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvTrackSelectionPersistenceTest.kt` + +- [x] Add a failing test proving a null keep-current subtitle retains the previous explicit selection. +- [x] Make `rememberPlaybackReturn` fall back to `previous?.subtitle` only for null; retain `-1` and nonnegative values. +- [x] Preserve the previously selected file version when the exit snapshot has no reliable file identifier. +- [x] Run the focused persistence tests. + +### Task 3: Final Stop snapshot + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackExitSnapshotTest.kt` + +- [x] Add failing behavioral tests for supplied final player position, missing samples, and reanchored timeline mapping. +- [x] Extract a pure exit-snapshot resolver that maps player time to source time and clamps to server duration. +- [x] Apply that snapshot directly to `_uiState` before persistence, without invoking seek/mount presentation gates. +- [x] Run the focused exit-snapshot tests. + +### Task 4: Rewound source-start metadata + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvVideoPlaybackStarter.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlaybackSourceStartTest.kt` + +- [x] Add a failing behavioral test showing a 600-second resume rewound to 593 seconds must adopt 593 as source start. +- [x] Resolve source start from `startRequestPosition`, server source start, then player start. +- [x] Verify Start Over zero and no-request server anchors remain intact. + +### Task 5: Focus-scoped For You re-anchor + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvMediaRow.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt` + +- [x] Add a row-level focus callback to `TvMediaRow`. +- [x] Track first-row focus and repeatedly re-anchor while focus relocation leaves the list below item zero, matching the existing library control-row pattern. +- [x] Keep an already-top list as a no-op and stop the loop immediately when row focus leaves. + +### Task 6: Verification and PR update + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvFireTvRcFeedbackOwnershipTest.kt` + +- [x] Remove superseded source-text assertions for Kotlin behavior while retaining workflow/Gradle contract checks. +- [x] Run focused tests, then the complete shared/TV unit and APK build gate with `1.0.0-rc.1+4` display version. +- [x] Push the follow-up commit and reply to each review thread with the verification evidence. diff --git a/docs/superpowers/plans/2026-08-03-for-you-late-focus-relocation.md b/docs/superpowers/plans/2026-08-03-for-you-late-focus-relocation.md new file mode 100644 index 000000000..c054ebf31 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-for-you-late-focus-relocation.md @@ -0,0 +1,159 @@ +# For You Late Focus-Relocation Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the For You list at its true top when a delayed focus-relocation pass moves it after the first recommendation row has already gained focus. + +**Architecture:** Convert the focus-scoped early-exit loop into an event-driven observer over `LazyListState` position changes. A small suspend helper owns the timing policy and is exercised with real coroutine flows so the delayed-displacement regression is testable without a Compose UI harness. + +**Tech Stack:** Kotlin, Jetpack Compose `snapshotFlow`, Kotlin coroutines `Flow`, `kotlinx-coroutines-test`, Gradle. + +## Global Constraints + +- Observe scroll changes only while the first recommendation row owns focus. +- Do no work while the list remains at item zero with offset zero. +- Re-check focus and position after the 80 ms relocation-settling delay. +- Do not change the shared bring-into-view policy or other recommendation rows. +- Preserve the full RC display version during verification. + +--- + +### Task 1: Event-driven top-anchor recovery + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsTopAnchorTest.kt` + +**Interfaces:** +- Consumes: a `Flow`, focus/position readers, an 80 ms settling callback, and a suspend scroll callback. +- Produces: `ForYouListPosition(firstVisibleItemIndex: Int, firstVisibleItemScrollOffset: Int)` and `maintainForYouTopAnchor(...)` for the screen effect and focused unit tests. + +- [x] **Step 1: Write the failing delayed-displacement regression test** + +```kotlin +@Test +fun delayedRelocationAfterAnInitiallyCorrectTopIsReanchored() = runTest { + var current = ForYouListPosition(0, 0) + var corrections = 0 + + maintainForYouTopAnchor( + positionEvents = flow { + emit(current) + current = ForYouListPosition(1, 24) + emit(current) + }, + isFirstRowFocused = { true }, + awaitRelocation = {}, + currentPosition = { current }, + scrollToTop = { + corrections += 1 + current = ForYouListPosition(0, 0) + }, + ) + + assertEquals(1, corrections) +} +``` + +Add two neighboring tests using `flowOf(...)`: a top-only sequence produces zero corrections, and focus becoming false inside `awaitRelocation` prevents a pending correction. + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests 'org.prairieserver.prairie.tv.ui.screens.recommendations.TvRecommendationsTopAnchorTest' \ + --no-daemon +``` + +Expected: compilation fails because `ForYouListPosition` and `maintainForYouTopAnchor` do not exist. + +- [x] **Step 3: Implement the minimal event-driven helper** + +```kotlin +internal data class ForYouListPosition( + val firstVisibleItemIndex: Int, + val firstVisibleItemScrollOffset: Int, +) { + val isAtTop: Boolean + get() = firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 +} + +internal suspend fun maintainForYouTopAnchor( + positionEvents: Flow, + isFirstRowFocused: () -> Boolean, + awaitRelocation: suspend () -> Unit, + currentPosition: () -> ForYouListPosition, + scrollToTop: suspend () -> Unit, +) { + positionEvents.collect { observed -> + if (!isFirstRowFocused() || observed.isAtTop) return@collect + awaitRelocation() + if (isFirstRowFocused() && !currentPosition().isAtTop) scrollToTop() + } +} +``` + +In `LaunchedEffect(firstRecommendationRowFocused)`, return immediately when focus is false. Otherwise pass a `snapshotFlow` of the real lazy-list position to the helper, retain the existing 80 ms settling delay, and call `recommendationsListState.animateScrollToItem(0)` only from `scrollToTop`. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the Step 2 command. + +Expected: all three `TvRecommendationsTopAnchorTest` cases pass. + +- [x] **Step 5: Commit the behavior and tests** + +```bash +git add \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt \ + androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsTopAnchorTest.kt +git commit -m "fix(tv): recover late For You focus relocation" +``` + +### Task 2: Full verification + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-03-for-you-late-focus-relocation.md` + +**Interfaces:** +- Consumes: the Task 1 helper, integration, and regression suite. +- Produces: a verified Android TV debug APK and completed plan checklist. + +- [x] **Step 1: Run the complete TV verification gate** + +```bash +./gradlew \ + :shared:testDebugUnitTest \ + :androidTvApp:testDebugUnitTest \ + :androidTvApp:assembleDebug \ + -PsiloVersionName=1.0.0 \ + -PsiloDisplayVersion=1.0.0-rc.2+5 \ + --no-daemon +bash scripts/test-release-workflow.sh +bash scripts/test-check-build-supply-chain.sh +bash scripts/check-build-supply-chain.sh +git diff --check +``` + +Expected: Gradle reports `BUILD SUCCESSFUL`, both workflow self-tests pass, the supply-chain check passes, and `git diff --check` emits no errors. + +- [x] **Step 2: Mark the plan complete and commit verification metadata** + +Change every task checkbox in this plan from `[ ]` to `[x]`, then run: + +```bash +git add docs/superpowers/plans/2026-08-03-for-you-late-focus-relocation.md +git commit -m "docs(tv): complete For You relocation plan" +``` + +- [x] **Step 3: Review branch scope** + +```bash +git status --short +git diff --stat upstream/main...HEAD +git log --oneline upstream/main..HEAD +``` + +Expected: the worktree is clean and the branch contains only the approved design, focused implementation/tests, and completed implementation plan. diff --git a/docs/superpowers/plans/2026-08-03-shield-focus-restoration.md b/docs/superpowers/plans/2026-08-03-shield-focus-restoration.md new file mode 100644 index 000000000..4a49986f4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-shield-focus-restoration.md @@ -0,0 +1,651 @@ +# Shield Focus Restoration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore deterministic D-pad focus on For You, Calendar, and Diagnostics on Shield Pro, and make only For You use the solid top-bar treatment already seen in Watchlist/Favorites. + +**Architecture:** Keep the existing Navigation Compose and TV shell structure. Add small pure routing/resolution functions with unit coverage, then wire stable `FocusRequester`s and bounded frame-based handoffs into the three affected screens. For You reuses `TvMediaRow`'s exact-card restore interface; Calendar and Diagnostics make their local focus zones explicit. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose for TV, Navigation Compose, coroutines, Kotlin test/JUnit, Gradle, Android Debug Bridge. + +## Global Constraints + +- Do not promote Watchlist to a top-level tab. +- Do not alter Watchlist or Favorites behavior, navigation, layout, or rendering. +- Do not redesign For You, Calendar, Diagnostics, or the global shell. +- Keep PR #162's event-driven first-row top-anchor correction. +- Use stable IDs before indices when resolving refreshed recommendation content. +- Bound every frame-based focus retry and stop immediately on success or disposal. +- Preserve the existing one-layer-per-press behavior for repeated D-pad events. +- Installation on the Shield and opening the app are separate explicit delivery steps after verification. + +--- + +## File Map + +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt`: pure For You focus-target resolution and the existing row-to-card focus bridge. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt`: save the focused recommendation identity, attach exact return requesters, perform post-return handoff, and report For You's top-bar treatment. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt`: distinguish For You detail returns from Home, expose the return token/requester, and draw a solid scrim only for the recommendations selection. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt`: track filter/week-strip zones, route Up deterministically, and acknowledge any successful Calendar control focus. +- `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt`: model and wire deterministic crash-report focus order and reliable initial focus. +- Existing unit-test files beside each feature validate the pure decisions without introducing UI instrumentation. + +--- + +### Task 1: Resolve For You return targets by stable identity + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt` + +**Interfaces:** +- Produces: `ForYouFocusTarget`, `ForYouFocusRow`, `ResolvedForYouFocusTarget`, and `resolveForYouReturnTarget(target, rows)`. +- Consumed by: Task 2's recommendation-screen focus restoration. + +- [ ] **Step 1: Write failing stable-ID and fallback tests** + +Add tests that exercise exact resolution, reorder handling, missing-card fallback, missing-section fallback, and an empty feed: + +```kotlin +private val target = ForYouFocusTarget("because-you-watched", "movie-b", 1, 2) + +@Test +fun exactReturnTargetUsesStableIdsAfterReorder() { + val resolved = resolveForYouReturnTarget( + target, + listOf( + ForYouFocusRow("because-you-watched", listOf("movie-c", "movie-b", "movie-a")), + ForYouFocusRow("trending", listOf("movie-d")), + ), + ) + assertEquals(ResolvedForYouFocusTarget(0, 1, true), resolved) +} + +@Test +fun missingCardUsesClosestIndexInSameSection() { + val resolved = resolveForYouReturnTarget( + target, + listOf(ForYouFocusRow("because-you-watched", listOf("movie-a", "movie-c"))), + ) + assertEquals(ResolvedForYouFocusTarget(0, 1, false), resolved) +} + +@Test +fun missingSectionUsesClosestRowFirstCard() { + val resolved = resolveForYouReturnTarget( + target, + listOf( + ForYouFocusRow("row-a", listOf("a")), + ForYouFocusRow("row-b", listOf("b")), + ), + ) + assertEquals(ResolvedForYouFocusTarget(1, 0, false), resolved) +} + +@Test +fun emptyFeedHasNoCardReturnTarget() { + assertEquals(null, resolveForYouReturnTarget(target, emptyList())) +} +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' +``` + +Expected: compilation fails because the new target types and resolver do not exist. + +- [ ] **Step 3: Implement the minimal resolver** + +Add the following pure models and algorithm: + +```kotlin +internal data class ForYouFocusTarget( + val sectionId: String, + val contentId: String, + val rowIndex: Int, + val cardIndex: Int, +) + +internal data class ForYouFocusRow( + val sectionId: String, + val contentIds: List, +) + +internal data class ResolvedForYouFocusTarget( + val rowIndex: Int, + val cardIndex: Int, + val exact: Boolean, +) + +internal fun resolveForYouReturnTarget( + target: ForYouFocusTarget, + rows: List, +): ResolvedForYouFocusTarget? { + if (rows.isEmpty()) return null + val stableRowIndex = rows.indexOfFirst { it.sectionId == target.sectionId } + if (stableRowIndex >= 0) { + val cards = rows[stableRowIndex].contentIds + if (cards.isEmpty()) return null + val stableCardIndex = cards.indexOf(target.contentId) + return if (stableCardIndex >= 0) { + ResolvedForYouFocusTarget(stableRowIndex, stableCardIndex, true) + } else { + ResolvedForYouFocusTarget( + stableRowIndex, + target.cardIndex.coerceIn(cards.indices), + false, + ) + } + } + val fallbackRowIndex = target.rowIndex.coerceIn(rows.indices) + val fallbackCards = rows[fallbackRowIndex].contentIds + if (fallbackCards.isEmpty()) return null + return ResolvedForYouFocusTarget(fallbackRowIndex, 0, false) +} +``` + +- [ ] **Step 4: Run the focused tests** + +Run the command from Step 2. Expected: all `TvRecommendationsFocusBridgeTest` tests pass. PR #162's separate `TvRecommendationsTopAnchorTest` remains covered by the full TV test task in Task 2 and Task 5. + +- [ ] **Step 5: Commit the resolver** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt +git commit -m "fix(tv): resolve For You return targets" +``` + +--- + +### Task 2: Restore For You to the launch card and solidify only its top bar + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt` + +**Interfaces:** +- Consumes: Task 1's `resolveForYouReturnTarget` and existing `requestRecommendationRowFocus`. +- Adds to `TvRecommendationsScreen`: `onRecommendationItemClick: (String) -> Unit`, `detailReturnFocusRequest: Int`, `detailReturnCardFocusRequester: FocusRequester`, and `onSolidTopBarChanged: (Boolean) -> Unit`. The existing `onItemClick` remains the unchanged saved-list callback. +- Produces in the shell: a For You-specific pending flag, requester, and return token; Home's existing path remains unchanged. + +- [ ] **Step 1: Add a failing bridge test for the filter fallback** + +Extend the focus-bridge tests to make both the no-row contract and a failed card request explicit: + +```kotlin +@Test +fun emptyFeedFallsBackToForYouFilter() { + assertTrue(shouldFallbackForYouReturnToFilter(resolveForYouReturnTarget(target, emptyList()))) + assertFalse( + shouldFallbackForYouReturnToFilter( + ResolvedForYouFocusTarget(rowIndex = 0, cardIndex = 0, exact = true), + ), + ) +} + +@Test +fun rejectedCardRequestCanBeRetried() = runTest { + val handled = requestRecommendationRowFocus( + requestRowContainer = { true }, + awaitFrame = {}, + requestFirstCard = { false }, + ) + assertFalse(handled) +} +``` + +- [ ] **Step 2: Run the focused test and confirm it fails** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' +``` + +Expected: compilation fails because `shouldFallbackForYouReturnToFilter` is undefined; after that symbol is introduced, the new card-rejection assertion still fails against the old bridge semantics. + +- [ ] **Step 3: Add the minimal fallback predicate** + +```kotlin +internal fun shouldFallbackForYouReturnToFilter( + resolved: ResolvedForYouFocusTarget?, +): Boolean = resolved == null +``` + +Also make `requestRecommendationRowFocus` return the card request result after the row hop and frame: + +```kotlin +if (!requestRowContainer()) return false +awaitFrame() +return requestFirstCard() +``` + +- [ ] **Step 4: Wire saveable focus identity and exact requesters in For You** + +In `TvRecommendationsScreen`, add saveable primitive fields for the last focused section/content IDs and indices. Construct `ForYouFocusTarget` only when both IDs are nonblank, and map `visibleSections` to `ForYouFocusRow` before calling the resolver. + +Use one row requester and the shell-provided card requester: + +```kotlin +val detailReturnRowFocusRequester = remember { FocusRequester() } +val returnRows = remember(visibleSections) { + visibleSections.map { section -> + ForYouFocusRow(section.id, section.items.map { it.contentId }) + } +} +val resolvedReturnTarget = lastFocusedTarget?.let { resolveForYouReturnTarget(it, returnRows) } +``` + +For each `TvMediaRow`, set: + +```kotlin +rowContainerFocusRequester = detailReturnRowFocusRequester + .takeIf { index == resolvedReturnTarget?.rowIndex }, +restoreFocusIndex = resolvedReturnTarget?.cardIndex ?: -1, +restoreFocusRequester = detailReturnCardFocusRequester + .takeIf { index == resolvedReturnTarget?.rowIndex }, +onItemFocusedAtIndex = { item, cardIndex -> + lastFocusedSectionId = section.id + lastFocusedContentId = item.contentId + lastFocusedRowIndex = index + lastFocusedCardIndex = cardIndex +}, +``` + +Wrap only recommendation-row clicks with `onRecommendationItemClick` so the focus identity is saved before navigation. Continue passing the existing `onItemClick` unchanged to `TvWatchlistInline` and `TvFavoritesInline`; this keeps saved-list detail returns on their current generic path. Do not reset `recommendationsListState` or a row's horizontal state. + +- [ ] **Step 5: Add the bounded post-return handoff** + +On a nonzero `detailReturnFocusRequest`, resolve the current target. If the row is not in `recommendationsListState.layoutInfo.visibleItemsInfo`, bring only that row into composition. Await frames, request the row container, await one more frame, then request the card through `requestRecommendationRowFocus`. If resolution returns null, request `forYouFocusRequester`. Retry for at most six frames and stop after the first success. + +```kotlin +LaunchedEffect(detailReturnFocusRequest, resolvedReturnTarget) { + if (detailReturnFocusRequest == 0) return@LaunchedEffect + val target = resolvedReturnTarget + if (target == null) { + repeat(6) { + withFrameNanos { } + if (forYouFocusRequester.requestFocus()) return@LaunchedEffect + } + return@LaunchedEffect + } + val rowVisible = recommendationsListState.layoutInfo.visibleItemsInfo + .any { it.index == target.rowIndex } + if (!rowVisible) recommendationsListState.scrollToItem(target.rowIndex) + repeat(6) { + withFrameNanos { } + val handled = requestRecommendationRowFocus( + requestRowContainer = { detailReturnRowFocusRequester.requestFocus() }, + awaitFrame = { withFrameNanos { } }, + requestFirstCard = { detailReturnCardFocusRequester.requestFocus() }, + ) + if (handled) return@LaunchedEffect + } +} +``` + +Use `runCatching` around requester calls in production so a disposed node ends the attempt safely rather than crashing. + +- [ ] **Step 6: Add the For You-specific shell return path** + +In `TvMainShell`, add `restoreForYouContentAfterDetail`, `forYouDetailReturnCardFocusRequester`, and `forYouDetailReturnFocusRequest`. A new recommendation-only click callback sets the pending flags before opening detail. Keep the existing generic `openContentItemDetail` callback for Watchlist/Favorites. On resume, use the For You requester as the content restorer fallback while its flag is set, increment the For You token, then clear the pending flag. Pass both click callbacks plus the token/requester into `TvRecommendationsScreen`. + +Keep the fallback priority explicit: + +```kotlin +val detailReturnFallback = when { + restoreHomeContentAfterDetail -> homeDetailReturnCardFocusRequester + restoreForYouContentAfterDetail -> forYouDetailReturnCardFocusRequester + else -> FocusRequester.Default +} +``` + +Do not change `openHomeItemDetail`, Home's request token, or Home's requester attachment. + +- [ ] **Step 7: Make only recommendations request a solid top scrim** + +Have `TvRecommendationsScreen` report `savedListSelection == null` through `onSolidTopBarChanged`, and reset the signal on disposal. In the shell, use an opaque theme-background scrim only when the current route is For You and that signal is true; otherwise retain the existing gradient. + +```kotlin +val useSolidForYouTopBar = + currentRoute == TvMainRoute.ForYou.route && forYouRequestsSolidTopBar +``` + +Do not edit `TvWatchlistInline`, `TvFavoritesInline`, or `TvPersonalScreens.kt`. + +- [ ] **Step 8: Run focused and full TV tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: both commands succeed with no failures. + +- [ ] **Step 9: Commit the For You restoration** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsScreen.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt +git commit -m "fix(tv): restore For You focus after detail" +``` + +--- + +### Task 3: Route Calendar Up through explicit control zones + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt` + +**Interfaces:** +- Produces: `CalendarControlFocusZone` and an expanded `calendarUpFallbackAction` that distinguishes filters from the week strip. +- Preserves: the shell's existing `onMoveUpToMenu` and content-up fallback registration protocol. + +- [ ] **Step 1: Replace the ambiguous control tests with failing zone tests** + +```kotlin +@Test +fun weekStripMovesUpToActiveFilter() { + assertEquals( + CalendarUpFallbackAction.FocusFilter, + calendarUpFallbackAction(null, 0, false, CalendarControlFocusZone.WeekStrip), + ) +} + +@Test +fun filterMovesUpToCalendarMenuTab() { + assertEquals( + CalendarUpFallbackAction.EnterMenu, + calendarUpFallbackAction(null, 0, false, CalendarControlFocusZone.Filter), + ) +} + +@Test +fun heldUpOnControlsDoesNotSkipALayer() { + assertEquals( + CalendarUpFallbackAction.StayInContent, + calendarUpFallbackAction( + null, + 0, + false, + CalendarControlFocusZone.WeekStrip, + isRepeat = true, + ), + ) +} +``` + +- [ ] **Step 2: Run the Calendar test and confirm it fails** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCalendarFocusRoutingTest' +``` + +Expected: compilation fails because the zone and `FocusFilter` action do not exist. + +- [ ] **Step 3: Implement the pure zone routing** + +```kotlin +internal enum class CalendarControlFocusZone { Filter, WeekStrip } + +internal enum class CalendarUpFallbackAction { + EnterMenu, + FocusFilter, + ReturnToControls, + StayInContent, + MoveWithinContent, +} +``` + +Extend `calendarUpFallbackAction` with `focusedControlZone: CalendarControlFocusZone?`. Preserve the shelf cases first, return `StayInContent` for repeats, then map `WeekStrip -> FocusFilter`, `Filter -> EnterMenu`, and null to the existing content movement behavior. Clear the control zone when a shelf gains focus so stale control state cannot affect shelf routing. + +- [ ] **Step 4: Track and acknowledge Calendar control focus** + +Maintain `focusedControlZone` in `CalendarList`. Pass zone-aware callbacks into `FilterSegment`, every `DayCell`, both chevrons, and `TodayButton`. When a control gains focus: + +1. Update the zone. +2. Run the existing snap-to-controls callback. +3. If the current `focusRequest` has not been acknowledged, record it and call `onInitialContentFocus()`. + +This makes a successful default weekday focus release `calendarFocusHandoffPending` even if the earlier imperative filter request returned false. + +- [ ] **Step 5: Wire the active filter requester into the Up fallback** + +Pass `filterFocusRequesters[state.filter] ?: filterFocusRequester` into `CalendarList`. Handle the new action with a safe direct request: + +```kotlin +CalendarUpFallbackAction.FocusFilter -> { + runCatching { activeFilterFocusRequester.requestFocus() }.getOrDefault(false) +} +``` + +Keep `EnterMenu` routed through `onMoveUpToMenu`, and keep the existing shelf-to-selected-day choreography unchanged. + +- [ ] **Step 6: Run focused and full TV tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCalendarFocusRoutingTest' +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: both commands succeed. + +- [ ] **Step 7: Commit Calendar routing** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt +git commit -m "fix(tv): route Calendar focus back to menu" +``` + +--- + +### Task 4: Make Diagnostics crash-report focus deterministic + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt` +- Test: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt` + +**Interfaces:** +- Produces: `TvDiagnosticsCrashFocus`, `tvDiagnosticsCrashFocusOrder`, and `nextTvDiagnosticsCrashFocus`. +- Consumes: `DiagnosticsConsentMode` and the existing `TvDiagnosticsAction` modifier hook. + +- [ ] **Step 1: Add failing focus-order tests** + +```kotlin +@Test +fun selectedConsentIsTheInitialCrashReportFocus() { + assertEquals( + TvDiagnosticsCrashFocus.ALWAYS, + initialTvDiagnosticsCrashFocus(DiagnosticsConsentMode.ALWAYS), + ) +} + +@Test +fun downTraversesConsentChoicesThenDebugLogging() { + assertEquals( + TvDiagnosticsCrashFocus.DEBUG_LOGGING, + nextTvDiagnosticsCrashFocus( + current = TvDiagnosticsCrashFocus.NEVER, + direction = TvDiagnosticsFocusDirection.Down, + debugLoggingEnabled = true, + ), + ) +} + +@Test +fun disabledDebugLoggingIsSkipped() { + assertEquals( + null, + nextTvDiagnosticsCrashFocus( + current = TvDiagnosticsCrashFocus.NEVER, + direction = TvDiagnosticsFocusDirection.Down, + debugLoggingEnabled = false, + ), + ) +} + +@Test +fun firstChoiceHoldsAtUpperBoundary() { + assertEquals( + TvDiagnosticsCrashFocus.ASK, + nextTvDiagnosticsCrashFocus( + current = TvDiagnosticsCrashFocus.ASK, + direction = TvDiagnosticsFocusDirection.Up, + debugLoggingEnabled = true, + ), + ) +} + +@Test +fun downFromLastEnabledChoiceFallsThroughToCaptureSection() { + assertEquals( + null, + nextTvDiagnosticsCrashFocus( + current = TvDiagnosticsCrashFocus.DEBUG_LOGGING, + direction = TvDiagnosticsFocusDirection.Down, + debugLoggingEnabled = true, + ), + ) +} +``` + +- [ ] **Step 2: Run the Diagnostics test and confirm it fails** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDiagnosticsStateTest' +``` + +Expected: compilation fails because the crash-focus types and functions do not exist. + +- [ ] **Step 3: Implement the pure focus order** + +```kotlin +internal enum class TvDiagnosticsCrashFocus { ASK, ALWAYS, NEVER, DEBUG_LOGGING } +internal enum class TvDiagnosticsFocusDirection { Up, Down } + +internal fun initialTvDiagnosticsCrashFocus(mode: DiagnosticsConsentMode) = when (mode) { + DiagnosticsConsentMode.ASK -> TvDiagnosticsCrashFocus.ASK + DiagnosticsConsentMode.ALWAYS -> TvDiagnosticsCrashFocus.ALWAYS + DiagnosticsConsentMode.NEVER -> TvDiagnosticsCrashFocus.NEVER +} + +internal fun tvDiagnosticsCrashFocusOrder(debugLoggingEnabled: Boolean) = buildList { + add(TvDiagnosticsCrashFocus.ASK) + add(TvDiagnosticsCrashFocus.ALWAYS) + add(TvDiagnosticsCrashFocus.NEVER) + if (debugLoggingEnabled) add(TvDiagnosticsCrashFocus.DEBUG_LOGGING) +} + +internal fun nextTvDiagnosticsCrashFocus( + current: TvDiagnosticsCrashFocus, + direction: TvDiagnosticsFocusDirection, + debugLoggingEnabled: Boolean, +): TvDiagnosticsCrashFocus? { + val order = tvDiagnosticsCrashFocusOrder(debugLoggingEnabled) + val index = order.indexOf(current).coerceAtLeast(0) + return when (direction) { + TvDiagnosticsFocusDirection.Up -> order[(index - 1).coerceAtLeast(0)] + TvDiagnosticsFocusDirection.Down -> order.getOrNull(index + 1) + } +} +``` + +- [ ] **Step 4: Attach stable requesters and key routing** + +Create a stable requester for each `TvDiagnosticsCrashFocus`. Map each consent mode to its focus target. Give each consent action and Debug logging its requester plus an `onPreviewKeyEvent` handler that: + +1. Handles only `KeyDown` Up/Down. +2. Calls `nextTvDiagnosticsCrashFocus` with `debugLoggingEnabled = state.consent != DiagnosticsConsentMode.NEVER`. +3. Requests the returned enabled target when nonnull. +4. Consumes the event only when a request was attempted. A null Down result from the last enabled crash-report action returns `false`, allowing normal focus search to enter the Capture section. + +Do not alter labels, consent callbacks, enabled state, sizes, or colors. + +- [ ] **Step 5: Replace the one-shot initial request with a bounded frame handoff** + +Key the effect by the selected consent mode. Await one frame, then request the selected option for at most six frames: + +```kotlin +LaunchedEffect(state.consent) { + val target = initialTvDiagnosticsCrashFocus(state.consent) + repeat(6) { + withFrameNanos { } + val focused = runCatching { + crashFocusRequesters.getValue(target).requestFocus() + }.getOrDefault(false) + if (focused) return@LaunchedEffect + } +} +``` + +Remove the old `firstFocus` requester and its unchecked `LaunchedEffect(Unit)`. + +- [ ] **Step 6: Run focused and full TV tests** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDiagnosticsStateTest' +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: both commands succeed. + +- [ ] **Step 7: Commit Diagnostics routing** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt +git commit -m "fix(tv): focus Diagnostics crash-report controls" +``` + +--- + +### Task 5: Verify the integrated TV build + +**Files:** +- Verify only; no production files should change. + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: a tested TV debug APK ready for an explicitly requested Shield installation. + +- [ ] **Step 1: Confirm Watchlist/Favorites were not edited** + +```bash +git diff 8a4bdb9c...HEAD -- androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/personal/TvPersonalScreens.kt +``` + +Expected: no output. + +- [ ] **Step 2: Run whitespace and complete TV unit-test gates** + +```bash +git diff --check +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: no whitespace errors and `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Assemble the TV debug APK** + +```bash +./gradlew :androidTvApp:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL` and an APK under `androidTvApp/build/outputs/apk/debug/`. + +- [ ] **Step 4: Inspect the final branch** + +```bash +git status --short --branch +git log --oneline --decorate 8a4bdb9c..HEAD +``` + +Expected: a clean `fix/shield-focus-restoration` branch containing the design, plan, and four focused implementation commits. + +- [ ] **Step 5: Stop before device mutation** + +Report the verified APK path and ask for explicit authorization before installing it on `192.168.1.128:5555`. Do not launch Silo after installation unless separately requested. diff --git a/docs/superpowers/plans/2026-08-04-focus-foundations-enabled-controls.md b/docs/superpowers/plans/2026-08-04-focus-foundations-enabled-controls.md new file mode 100644 index 000000000..90c9487e7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-focus-foundations-enabled-controls.md @@ -0,0 +1,971 @@ +# Focus Foundations and Enabled Controls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver Series A of the whole-application focus hardening by introducing a bounded observed-focus policy, applying it to dialog startup, making disabled TV controls truly ineligible, preserving Cascade row identity, and deriving playback-selector interactivity from final actionable options. + +**Architecture:** Put retry/exhaustion behavior in a pure Kotlin focus-policy unit so later screen migrations share one tested contract while Compose callers retain their own requesters and observed focus state. Keep control, Cascade, and playback changes local to their existing components; use JVM behavior tests where possible and source-wiring guards where the module's current pure-JVM harness cannot execute Compose focus semantics. + +**Tech Stack:** Kotlin 2.1, Kotlin coroutines, Jetpack Compose for TV, Kotlin test/JUnit 4, Gradle, Java 21 + +## Global Constraints + +- Android TV only; do not change phone behavior. +- Observed focus is authoritative; a successful call or `true` return is not focus acquisition. +- Retry loops are bounded and composition cancellation remains the disposal authority. +- Disabled controls must be skipped by D-pad focus search, expose disabled semantics, and reject activation at the primitive. +- Stable content identity uses library IDs rather than list positions. +- Selector interactivity is derived from the final enabled option model. +- Do not add a global focus coordinator or migrate screens assigned to Series B through E. +- Shield/Google TV and Fire TV device validation remains a release gate. + +--- + +## File map + +- Create `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt`: platform-free target, request-observation, retry, and terminal-result policy. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt`: exhaustive policy behavior tests. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt`: bounded dialog-specific adapter and Compose wiring. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt`: dialog attempt-budget and focus-observation tests. +- Modify the six reusable/control files listed in Task 3: propagate `enabled` into their actual TV `Surface`, `Card`, or `clickable` primitive. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDisabledControlWiringSourceTest.kt`: regression guard for those primitive-level enabled parameters. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt`: stable keys for eager and lazy library rows. +- Create `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentitySourceTest.kt`: regression guard for both keyed branches. +- Modify `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt`: materialize final option lists and compute actionability from them. +- Modify `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt`: final-option actionability tests. + +--- + +### Task 1: Add the bounded observed-focus policy + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt` + +**Interfaces:** +- Produces: `TvFocusTargetState { NotReady, Ready, Disposed }` +- Produces: `TvFocusRequestOutcome { Rejected, AcceptedUnobserved, Focused }` +- Produces: `TvObservedFocusResult { Focused, Exhausted, Disposed }` +- Produces: `observeTvFocusRequest(requestAccepted: Boolean, isFocused: Boolean): TvFocusRequestOutcome` +- Produces: `suspend requestFocusUntilObserved(maxAttempts: Int, awaitAttempt: suspend () -> Unit, targetState: () -> TvFocusTargetState, requestFocus: () -> Boolean, isFocused: () -> Boolean): TvObservedFocusResult` + +- [ ] **Step 1: Write the failing policy tests** + +Create `TvObservedFocusPolicyTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.focus + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvObservedFocusPolicyTest { + @Test + fun requestOutcomeDistinguishesRejectionAcceptanceAndObservation() { + assertEquals( + TvFocusRequestOutcome.Rejected, + observeTvFocusRequest(requestAccepted = false, isFocused = false), + ) + assertEquals( + TvFocusRequestOutcome.AcceptedUnobserved, + observeTvFocusRequest(requestAccepted = true, isFocused = false), + ) + assertEquals( + TvFocusRequestOutcome.Focused, + observeTvFocusRequest(requestAccepted = true, isFocused = true), + ) + } + + @Test + fun rejectedAndThrowingRequestsRetryUntilFocusIsObserved() = runTest { + var requests = 0 + var focused = false + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { + requests++ + when (requests) { + 1 -> false + 2 -> error("detached") + else -> true.also { focused = true } + } + }, + isFocused = { focused }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(3, requests) + } + + @Test + fun acceptedButUnobservedRequestsExhaustTheBudget() = runTest { + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 4, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { true.also { requests++ } }, + isFocused = { false }, + ) + + assertEquals(TvObservedFocusResult.Exhausted, result) + assertEquals(4, requests) + } + + @Test + fun notReadyTargetsWaitWithoutRequesting() = runTest { + var frames = 0 + var requests = 0 + var focused = false + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = { frames++ }, + targetState = { + if (frames < 3) TvFocusTargetState.NotReady else TvFocusTargetState.Ready + }, + requestFocus = { + requests++ + true.also { focused = true } + }, + isFocused = { focused }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(1, requests) + assertEquals(3, frames) + } + + @Test + fun disposedTargetStopsWithoutRequestingAgain() = runTest { + var frames = 0 + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 5, + awaitAttempt = { frames++ }, + targetState = { + if (frames < 2) TvFocusTargetState.Ready else TvFocusTargetState.Disposed + }, + requestFocus = { false.also { requests++ } }, + isFocused = { false }, + ) + + assertEquals(TvObservedFocusResult.Disposed, result) + assertEquals(1, requests) + } + + @Test + fun existingObservedFocusCompletesWithoutRequesting() = runTest { + var requests = 0 + + val result = requestFocusUntilObserved( + maxAttempts = 3, + awaitAttempt = {}, + targetState = { TvFocusTargetState.Ready }, + requestFocus = { true.also { requests++ } }, + isFocused = { true }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(0, requests) + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvObservedFocusPolicyTest' +``` + +Expected: test compilation fails because the policy types and functions do not exist. + +- [ ] **Step 3: Implement the pure policy** + +Create `TvObservedFocusPolicy.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.focus + +internal enum class TvFocusTargetState { NotReady, Ready, Disposed } + +internal enum class TvFocusRequestOutcome { Rejected, AcceptedUnobserved, Focused } + +internal enum class TvObservedFocusResult { Focused, Exhausted, Disposed } + +internal fun observeTvFocusRequest( + requestAccepted: Boolean, + isFocused: Boolean, +): TvFocusRequestOutcome = when { + isFocused -> TvFocusRequestOutcome.Focused + requestAccepted -> TvFocusRequestOutcome.AcceptedUnobserved + else -> TvFocusRequestOutcome.Rejected +} + +internal suspend fun requestFocusUntilObserved( + maxAttempts: Int, + awaitAttempt: suspend () -> Unit, + targetState: () -> TvFocusTargetState, + requestFocus: () -> Boolean, + isFocused: () -> Boolean, +): TvObservedFocusResult { + require(maxAttempts > 0) { "maxAttempts must be positive" } + + repeat(maxAttempts) { + awaitAttempt() + if (isFocused()) return TvObservedFocusResult.Focused + + when (targetState()) { + TvFocusTargetState.Disposed -> return TvObservedFocusResult.Disposed + TvFocusTargetState.NotReady -> Unit + TvFocusTargetState.Ready -> { + val accepted = runCatching(requestFocus).getOrDefault(false) + if (observeTvFocusRequest(accepted, isFocused()) == TvFocusRequestOutcome.Focused) { + return TvObservedFocusResult.Focused + } + } + } + } + + return when { + isFocused() -> TvObservedFocusResult.Focused + targetState() == TvFocusTargetState.Disposed -> TvObservedFocusResult.Disposed + else -> TvObservedFocusResult.Exhausted + } +} +``` + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the Task 1 command again. Expected: all six tests pass. + +- [ ] **Step 5: Commit the focus-policy foundation** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicy.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/focus/TvObservedFocusPolicyTest.kt +git commit -m "feat(tv): add observed focus retry policy" +``` + +### Task 2: Bound dialog initial-focus acquisition + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt` + +**Interfaces:** +- Consumes: `TvFocusTargetState.Ready`, `TvObservedFocusResult`, and `requestFocusUntilObserved(...)` from Task 1 +- Produces: `TvDialogInitialFocusMaxAttempts: Int = 40` +- Produces: `suspend requestTvDialogInitialFocus(awaitAttempt: suspend () -> Unit, isOverlayFocused: () -> Boolean, requestFocus: () -> Boolean): TvObservedFocusResult` +- Preserves: `rememberTvDialogInitialFocus(target: FocusRequester): Modifier` + +- [ ] **Step 1: Write failing tests for the dialog adapter** + +Create `TvDialogInitialFocusTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import kotlin.test.Test +import kotlin.test.assertEquals + +class TvDialogInitialFocusTest { + @Test + fun unobservedDialogFocusStopsAtTheFixedBudget() = runTest { + var attempts = 0 + + val result = requestTvDialogInitialFocus( + awaitAttempt = {}, + isOverlayFocused = { false }, + requestFocus = { true.also { attempts++ } }, + ) + + assertEquals(TvObservedFocusResult.Exhausted, result) + assertEquals(TvDialogInitialFocusMaxAttempts, attempts) + } + + @Test + fun focusOnAnyDialogChildStopsTargetRequests() = runTest { + var overlayFocused = false + var attempts = 0 + + val result = requestTvDialogInitialFocus( + awaitAttempt = { + if (attempts == 1) overlayFocused = true + }, + isOverlayFocused = { overlayFocused }, + requestFocus = { false.also { attempts++ } }, + ) + + assertEquals(TvObservedFocusResult.Focused, result) + assertEquals(1, attempts) + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDialogInitialFocusTest' +``` + +Expected: test compilation fails because the dialog adapter and attempt constant do not exist. + +- [ ] **Step 3: Replace the unbounded loop with the tested adapter** + +Replace `TvDialogInitialFocus.kt` with: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import kotlinx.coroutines.delay +import org.prairieserver.prairie.tv.ui.focus.TvFocusTargetState +import org.prairieserver.prairie.tv.ui.focus.TvObservedFocusResult +import org.prairieserver.prairie.tv.ui.focus.requestFocusUntilObserved + +internal const val TvDialogInitialFocusMaxAttempts = 40 +private const val TvDialogInitialFocusRetryDelayMillis = 60L + +internal suspend fun requestTvDialogInitialFocus( + awaitAttempt: suspend () -> Unit, + isOverlayFocused: () -> Boolean, + requestFocus: () -> Boolean, +): TvObservedFocusResult = requestFocusUntilObserved( + maxAttempts = TvDialogInitialFocusMaxAttempts, + awaitAttempt = awaitAttempt, + targetState = { TvFocusTargetState.Ready }, + requestFocus = requestFocus, + isFocused = isOverlayFocused, +) + +/** + * Bounded retry-until-observed initial focus for popup overlays. + * + * Attach the returned modifier to the overlay content root. Focus on any child + * completes acquisition; forty 60 ms attempts provide a 2.4 second ceiling. + * Leaving composition cancels the effect through structured concurrency. + */ +@Composable +internal fun rememberTvDialogInitialFocus(target: FocusRequester): Modifier { + var overlayHasFocus by remember { mutableStateOf(false) } + LaunchedEffect(target) { + requestTvDialogInitialFocus( + awaitAttempt = { delay(TvDialogInitialFocusRetryDelayMillis) }, + isOverlayFocused = { overlayHasFocus }, + requestFocus = target::requestFocus, + ) + } + return Modifier.onFocusChanged { overlayHasFocus = it.hasFocus } +} +``` + +- [ ] **Step 4: Run the dialog and policy tests and verify GREEN** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDialogInitialFocusTest' --tests '*TvObservedFocusPolicyTest' +``` + +Expected: all eight tests pass and no loop can outlive the 40-attempt budget unless composition cancellation ends it sooner. + +- [ ] **Step 5: Commit the bounded dialog behavior** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocus.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDialogInitialFocusTest.kt +git commit -m "fix(tv): bound dialog initial focus retries" +``` + +### Task 3: Propagate enabled state to TV input primitives + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvCardOverlaySettingsScreen.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansScreen.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDisabledControlWiringSourceTest.kt` + +**Interfaces:** +- Preserves every public composable signature. +- Changes primitive contracts so `enabled = false` reaches TV Material `Surface`, TV Material `Card`, or Foundation `clickable` directly. +- Changes `PinKey` to consume `enabled: Boolean` and makes every `PinKeypad` call pass it. + +- [ ] **Step 1: Add the failing primitive-wiring guard** + +Create `TvDisabledControlWiringSourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse + +class TvDisabledControlWiringSourceTest { + @Test + fun disabledStateReachesEveryInteractivePrimitive() { + val optionDialog = source("ui/components/TvOptionDialog.kt") + assertContains(optionDialog, "onClick = onClick,\n enabled = enabled,") + + val aurora = source("ui/components/TvAuroraChrome.kt") + assertContains(aurora, "enabled = enabled,\n onClick = onClick,") + + val pin = source("ui/components/TvPinEntryDialog.kt") + assertContains(pin, "enabled: Boolean,\n onClick: () -> Unit,") + assertContains(pin, "onClick = onDigitPressed") + assertContains(pin, "onClick = onBackspacePressed") + assertContains(pin, "enabled = enabled,") + + val join = source("ui/screens/watchtogether/TvJoinCodeDialog.kt") + assertContains(join, "onClick = onClick,\n enabled = enabled,") + + val overlays = source("ui/screens/settings/TvCardOverlaySettingsScreen.kt") + assertContains(overlays, "onClick = onClick,\n enabled = enabled,") + + val scans = source("ui/screens/admin/TvAdminScansScreen.kt") + assertContains(scans, "onClick = onClick,\n enabled = enabled,") + + listOf(optionDialog, aurora, pin, join, overlays, scans).forEach { text -> + assertFalse(text.contains("onClick = { if (enabled) onClick() }")) + } + } + + private fun source(relativePath: String): String = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/$relativePath", + ).readText() +} +``` + +- [ ] **Step 2: Run the wiring guard and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDisabledControlWiringSourceTest' +``` + +Expected: the test fails because the listed controls guard callbacks while leaving their primitives enabled. + +- [ ] **Step 3: Wire enabled state into each primitive** + +Make these exact changes: + +```kotlin +// TvOptionDialogRow +Surface( + onClick = onClick, + enabled = enabled, + interactionSource = interactionSource, +``` + +```kotlin +// AuroraPrimaryButton +.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = enabled, + onClick = onClick, +) +``` + +Change `PinKey` to accept `enabled` and pass it to its `Surface`: + +```kotlin +private fun PinKey( + label: String?, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null, +) { + Surface( + onClick = onClick, + enabled = enabled, + interactionSource = interactionSource, +``` + +Replace the keypad calls with direct callbacks and the shared state: + +```kotlin +PinKey( + label = digit.toString(), + enabled = enabled, + modifier = if (digit == '5') Modifier.focusRequester(fiveFocusRequester) else Modifier, + onClick = { onDigitPressed(digit) }, +) +``` + +```kotlin +PinKey(label = "0", enabled = enabled, onClick = { onDigitPressed('0') }) +PinKey( + label = null, + enabled = enabled, + icon = Icons.AutoMirrored.Filled.Backspace, + onClick = onBackspacePressed, +) +``` + +In `JoinCodeKey` and `OverlayResetRow`, replace their guarded `Surface` callbacks with: + +```kotlin +// JoinCodeKey and OverlayResetRow +Surface( + onClick = onClick, + enabled = enabled, +``` + +```kotlin +// ActionCard +Card( + onClick = onClick, + enabled = enabled, +``` + +Keep the existing disabled colors and alpha so visual behavior does not regress. Native primitive `enabled` supplies focus exclusion, disabled semantics, and activation rejection. + +- [ ] **Step 4: Run the focused guard and compile production code** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDisabledControlWiringSourceTest' :androidTvApp:compileDebugKotlinAndroid +``` + +Expected: the guard passes and the production source compiles against the TV Material enabled overloads. + +- [ ] **Step 5: Commit enabled-state correctness** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvOptionDialog.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvAuroraChrome.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvPinEntryDialog.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/watchtogether/TvJoinCodeDialog.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvCardOverlaySettingsScreen.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/admin/TvAdminScansScreen.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvDisabledControlWiringSourceTest.kt +git commit -m "fix(tv): remove disabled controls from focus" +``` + +### Task 4: Give Cascade library rows stable composition identity + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentitySourceTest.kt` + +**Interfaces:** +- Preserves Cascade selection and focus-requester maps keyed by `library.id`. +- Adds Compose identity `key(library.id)` to the eager branch and `items(libraries, key = { it.id })` to the lazy branch. + +- [ ] **Step 1: Add a failing stable-identity guard** + +Create `TvCascadeSelectorIdentitySourceTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.components + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContains + +class TvCascadeSelectorIdentitySourceTest { + private val source = File( + "src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt", + ).readText() + + @Test + fun eagerAndLazyLibraryRowsUseLibraryIdentity() { + assertContains(source, "key(library.id) {") + assertContains(source, "items(libraries, key = { it.id }) { library ->") + } +} +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCascadeSelectorIdentitySourceTest' +``` + +Expected: the test fails because both branches currently use positional composition identity. + +- [ ] **Step 3: Key both Cascade branches** + +Add: + +```kotlin +import androidx.compose.runtime.key +``` + +Wrap the eager branch's existing row body without changing it: + +```kotlin +libraries.forEach { library -> + key(library.id) { + val requester = libraryRequesters.getOrPut(library.id) { FocusRequester() } + CascadeLibraryRow( + library = library, + type = type, + isCurrent = library.id == currentScopeId, + entersPanel = entersPanel, + focusRequester = requester, + onFocusChanged = { focused -> + focusedRowId = if (focused) { + library.id + } else { + focusedRowId.takeUnless { it == library.id } + } + }, + onTopChanged = { top -> rowTops[library.id] = top }, + onMoveRight = { + anchorId = library.id + val firstPill = pills.firstOrNull() + if (firstPill != null) { + flyoutVisible = true + focusFirstPillToken++ + true + } else { + false + } + }, + onSelect = { + onCommitLibrary(library) + true + }, + ) + } +} +``` + +Replace the lazy items declaration with: + +```kotlin +items(libraries, key = { it.id }) { library -> +``` + +- [ ] **Step 4: Run the focused test and compile production code** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCascadeSelectorIdentitySourceTest' :androidTvApp:compileDebugKotlinAndroid +``` + +Expected: the guard passes and Cascade compiles with stable keys in both list-size branches. + +- [ ] **Step 5: Commit Cascade identity** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelector.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/components/TvCascadeSelectorIdentitySourceTest.kt +git commit -m "fix(tv): key Cascade rows by library" +``` + +### Task 5: Derive selector interactivity from final enabled options + +**Files:** +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt` +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt` + +**Interfaces:** +- Replaces: `selectorIsInteractive(optionCount: Int): Boolean` +- Produces: `selectorIsInteractive(options: List): Boolean` +- Preserves all `TvPlaybackSelectorRow` callback and selection contracts. + +- [ ] **Step 1: Replace the count tests with final-option tests** + +Add this import to `TvPlaybackFormattingTest.kt`: + +```kotlin +import org.prairieserver.prairie.tv.ui.components.TvSelectorOption +``` + +Replace `singleChoiceSelectorIsStatic` with: + +```kotlin +@Test +fun selectorNeedsAtLeastTwoEnabledFinalOptions() { + val onlyAction = selectorOption("auto") + val unavailable = selectorOption("unknown", enabled = false) + + assertFalse(selectorIsInteractive(emptyList())) + assertFalse(selectorIsInteractive(listOf(onlyAction, unavailable))) + assertTrue(selectorIsInteractive(listOf(onlyAction, selectorOption("off")))) +} + +@Test +fun onePhysicalSubtitleTrackStillLeavesThreeActions() { + val options = listOf( + selectorOption("subtitle:auto"), + selectorOption("subtitle:off"), + selectorOption("subtitle:track:1"), + ) + + assertTrue(selectorIsInteractive(options)) +} + +private fun selectorOption(key: String, enabled: Boolean = true) = TvSelectorOption( + key = key, + title = key, + detail = "", + selected = false, + enabled = enabled, + onSelect = {}, +) +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvPlaybackFormattingTest' +``` + +Expected: test compilation fails because `selectorIsInteractive` still accepts an integer. + +- [ ] **Step 3: Change the helper to inspect enabled final options** + +Replace the helper with: + +```kotlin +internal fun selectorIsInteractive(options: List): Boolean = + options.count(TvSelectorOption::enabled) > 1 +``` + +- [ ] **Step 4: Materialize and reuse each final selector option list** + +Immediately after `scopedVersions`, add these `editionOptions`, `versionOptions`, `audioSelectorOptions`, and `subtitleSelectorOptions` declarations: + +```kotlin +val editionOptions = editions.map { edition -> + val count = edition.versions.size + TvSelectorOption( + key = "edition:${edition.id}", + title = edition.label, + detail = "$count version${if (count == 1) "" else "s"}", + selected = currentEdition?.id == edition.id, + onSelect = { onSelectVersion(edition.versions.firstOrNull()?.fileId) }, + ) +} +val versionOptions = buildList { + add( + TvSelectorOption( + key = "version:auto", + title = "Auto", + detail = "Best match for this device", + selected = selectedVersionFileId == null, + onSelect = { onSelectVersion(null) }, + ), + ) + scopedVersions.forEach { version -> + add( + TvSelectorOption( + key = "version:${version.fileId}", + title = TvPlaybackFormatting.versionShortLabel(version), + detail = TvPlaybackFormatting.versionDetailLabel(version), + selected = selectedVersionFileId == version.fileId, + onSelect = { onSelectVersion(version.fileId) }, + ), + ) + } +} +val audioSelectorOptions = buildList { + add( + TvSelectorOption( + key = "audio:auto", + title = "Auto", + detail = "Use the file default track", + selected = isAudioSelectorOptionSelected(null, selectedAudioTrackIndex), + onSelect = { onSelectAudioTrack(null) }, + ), + ) + val formattedAudioOptions = + TvPlaybackFormatting.audioOptions(currentVersion, selectedAudioTrackIndex) + if (formattedAudioOptions.isEmpty()) { + add( + TvSelectorOption( + key = "audio:unknown", + title = "Unknown", + detail = "", + selected = false, + onSelect = {}, + enabled = false, + ), + ) + } else { + formattedAudioOptions.forEach { option -> + add( + TvSelectorOption( + key = "audio:${option.ordinal}", + title = option.title, + detail = option.detail, + selected = option.isSelected, + onSelect = { onSelectAudioTrack(option.ordinal) }, + ), + ) + } + } +} +val subtitleSelectorOptions = buildList { + add( + TvSelectorOption( + key = "subtitle:auto", + title = "Auto", + detail = "Use your subtitle preferences", + selected = selectedSubtitleTrackIndex == null, + onSelect = { onSelectSubtitleTrack(null) }, + ), + ) + add( + TvSelectorOption( + key = "subtitle:off", + title = "Off", + detail = "Start without subtitles", + selected = selectedSubtitleTrackIndex == -1, + onSelect = { onSelectSubtitleTrack(-1) }, + ), + ) + TvPlaybackFormatting.subtitleOptions( + currentVersion, + selectedSubtitleTrackIndex, + preferredLanguage = preferredSubtitleLanguage, + ).forEach { option -> + add( + TvSelectorOption( + key = "subtitle:${option.stableId}", + title = option.title, + detail = option.detail, + selected = option.isSelected, + onSelect = { onSelectSubtitleTrack(option.selectionIndex) }, + ), + ) + } +} +``` + +Pass each list to both parameters of its menu: + +```kotlin +options = editionOptions, +interactive = selectorIsInteractive(editionOptions), +``` + +```kotlin +options = versionOptions, +interactive = selectorIsInteractive(versionOptions), +``` + +```kotlin +options = audioSelectorOptions, +interactive = selectorIsInteractive(audioSelectorOptions), +``` + +```kotlin +options = subtitleSelectorOptions, +interactive = selectorIsInteractive(subtitleSelectorOptions), +``` + +This deliberately corrects Version and Audio along with Subtitles: Auto plus one physical option is actionable, while Auto plus a disabled Unknown row is not. + +- [ ] **Step 5: Run the focused tests and verify GREEN** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvPlaybackFormattingTest' +``` + +Expected: all playback-formatting tests pass, including the single-physical-subtitle regression. + +- [ ] **Step 6: Commit final-option actionability** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackSelectorRow.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/detail/TvPlaybackFormattingTest.kt +git commit -m "fix(tv): derive selectors from actionable options" +``` + +### Task 6: Verify Series A as an integrated change + +**Files:** +- Verify: all files changed in Tasks 1 through 5 + +**Interfaces:** +- Consumes all Series A production and test changes. +- Produces a green TV unit-test suite and installable debug APKs without expanding into later focus-hardening series. + +- [ ] **Step 1: Run every new or changed focused test** + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvObservedFocusPolicyTest' --tests '*TvDialogInitialFocusTest' --tests '*TvDisabledControlWiringSourceTest' --tests '*TvCascadeSelectorIdentitySourceTest' --tests '*TvPlaybackFormattingTest' +``` + +Expected: all focused tests pass. + +- [ ] **Step 2: Run the complete TV unit-test suite** + +```bash +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: `BUILD SUCCESSFUL` with no failing TV unit tests. + +- [ ] **Step 3: Assemble every TV debug APK variant** + +```bash +./gradlew :androidTvApp:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL` and debug APK artifacts under `androidTvApp/build/outputs/apk/debug/`. + +- [ ] **Step 4: Run repository hygiene checks** + +```bash +git diff --check +git status --short +``` + +Expected: `git diff --check` prints nothing; status contains no unintended files. + +- [ ] **Step 5: Perform the device focus matrix** + +On one Shield/Google TV device and one Fire TV device, verify these exact cases: + +1. Open an option dialog repeatedly from cold and warm screens; a row gains focus, D-pad works immediately, and focus is not stolen after moving to another row. +2. Leave a dialog open beyond 2.4 seconds; no repeated focus steal occurs after the attempt budget. +3. Enter PIN and join-code busy states; disabled keys are skipped by D-pad traversal and Select does not activate them. +4. Open Card Overlay Settings with defaults already selected; Reset is skipped and exposes disabled accessibility state. +5. Open Admin Scans during a state that disables an action; the action card is skipped and cannot activate. +6. Reorder or refresh libraries with Cascade open in both six-or-fewer and seven-or-more cases; the focused library retains identity. +7. Open playback selectors with one version, one audio track, and one subtitle track; Version, Audio, and Subtitles remain focusable when their final menus contain at least two enabled actions. +8. Open Audio with no tracks; Auto plus disabled Unknown is not a focusable no-op selector. + +Expected: every case matches the stated result on both device families. + +- [ ] **Step 6: Record verification without creating an empty commit** + +Run: + +```bash +git log --oneline -5 +git status --short --branch +``` + +Expected: the five task commits are present and the worktree is clean. Record device models, OS versions, and pass/fail results in the pull-request description when the branch is published. diff --git a/docs/superpowers/plans/2026-08-04-pr-164-review-remediation.md b/docs/superpowers/plans/2026-08-04-pr-164-review-remediation.md new file mode 100644 index 000000000..25b962ee0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-pr-164-review-remediation.md @@ -0,0 +1,391 @@ +# PR #164 Review Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Correct the four verified Android TV focus-state defects blocking PR #164 without broadening its navigation behavior. + +**Architecture:** Keep Calendar and Diagnostics corrections inside their existing pure policy helpers. Move Home retry lifetime into a small immutable shell state model, and extend the existing For You return-state helper with an explicit-selection reset so both flows are deterministic and unit-testable without Compose instrumentation. + +**Tech Stack:** Kotlin 2.1, Jetpack Compose for TV, Kotlin test/JUnit, Gradle, Java 21 + +## Global Constraints + +- Android TV only; do not change phone behavior. +- Preserve one-layer-per-press behavior for repeated D-pad events. +- Focus requests remain bounded and best-effort. +- Coroutine cancellation is authoritative for composable disposal. +- Explicit top-menu navigation overrides stale detail-return state. +- Do not redesign recommendation identities or perform unrelated focus refactors. +- A physical Shield D-pad smoke test remains the release gate. + +--- + +### Task 1: Restore Calendar held-Up movement below the boundary + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt:714-734` + +**Interfaces:** +- Consumes: `calendarUpFallbackAction(focusedShelfIndex, firstFocusableShelfIndex, isReturningToControls, focusedControlZone, isRepeat)` +- Produces: the existing `CalendarUpFallbackAction.MoveWithinContent` result for repeated Up below the first focusable shelf + +- [x] **Step 1: Add the failing regression test** + +Add this test to `TvCalendarFocusRoutingTest`: + +```kotlin +@Test +fun heldUpBelowFirstShelfContinuesContentMovement() { + assertEquals( + CalendarUpFallbackAction.MoveWithinContent, + calendarUpFallbackAction( + focusedShelfIndex = 4, + firstFocusableShelfIndex = 2, + isReturningToControls = false, + focusedControlZone = null, + isRepeat = true, + ), + ) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCalendarFocusRoutingTest' +``` + +Expected: `heldUpBelowFirstShelfContinuesContentMovement` fails because the current unconditional `isRepeat` branch returns `StayInContent`. + +- [x] **Step 3: Restore the content-aware repeat guard** + +Change the broad repeat branch in `calendarUpFallbackAction` to: + +```kotlin +focusedShelfIndex == null && isRepeat -> CalendarUpFallbackAction.StayInContent +``` + +Keep the first-shelf boundary branch above it unchanged so a held event cannot skip from the first shelf into controls. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the Task 1 command again. Expected: all `TvCalendarFocusRoutingTest` tests pass. + +- [x] **Step 5: Commit the Calendar correction** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarFocusRoutingTest.kt +git commit -m "fix(tv): preserve held Calendar shelf movement" +``` + +### Task 2: Retry transient Diagnostics focus-request failures + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt:18-34` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt:71-85,238-246` + +**Interfaces:** +- Consumes: `tvDiagnosticsCrashFocusRequestResult(Result)` +- Produces: `FOCUSED` for `true`; `RETRY` for `false` and caught exceptions + +- [x] **Step 1: Change the failure test to the required behavior** + +Replace `failedFocusRequestIsTerminalBecauseTheScreenWasDisposed` with: + +```kotlin +@Test +fun detachedFocusRequesterFailureIsRetryable() { + assertEquals( + TvDiagnosticsCrashFocusRequestResult.RETRY, + tvDiagnosticsCrashFocusRequestResult( + Result.failure(IllegalStateException("Focus requester is detached")), + ), + ) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDiagnosticsStateTest' +``` + +Expected: `detachedFocusRequesterFailureIsRetryable` fails because the current function returns `DISPOSED`. + +- [x] **Step 3: Remove synthetic disposal classification** + +Reduce the enum and classifier to: + +```kotlin +internal enum class TvDiagnosticsCrashFocusRequestResult { FOCUSED, RETRY } + +internal fun tvDiagnosticsCrashFocusRequestResult( + result: Result, +): TvDiagnosticsCrashFocusRequestResult = if (result.getOrDefault(false)) { + TvDiagnosticsCrashFocusRequestResult.FOCUSED +} else { + TvDiagnosticsCrashFocusRequestResult.RETRY +} +``` + +Update the `LaunchedEffect` `when` so only `FOCUSED` exits and `RETRY` continues to the next bounded frame. Disposal continues to cancel the effect through structured concurrency. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the Task 2 command again. Expected: all `TvDiagnosticsStateTest` tests pass. + +- [x] **Step 5: Commit the Diagnostics correction** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsStateTest.kt +git commit -m "fix(tv): retry detached Diagnostics focus" +``` + +### Task 3: Preserve Home’s card fallback through deferred retry + +**Files:** +- Create: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt` +- Create: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusStateTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:341-404,609-616` + +**Interfaces:** +- Produces: `HomeDetailReturnFocusState(requestId: Int, needsRetry: Boolean, fallbackPending: Boolean)` +- Produces: `beginHomeDetailReturnRetry(previousRequestId: Int, needsRetry: Boolean): HomeDetailReturnFocusState` +- Produces: `completeHomeDetailReturnRetry(state: HomeDetailReturnFocusState): HomeDetailReturnFocusState` +- Produces: `resetHomeDetailReturnFocus(): HomeDetailReturnFocusState` + +- [x] **Step 1: Add failing tests for the Home retry lifetime** + +Create `TvDetailReturnFocusStateTest.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TvDetailReturnFocusStateTest { + @Test + fun requestedHomeRetryKeepsCardFallbackPending() { + val state = beginHomeDetailReturnRetry(previousRequestId = 7, needsRetry = true) + + assertEquals(8, state.requestId) + assertTrue(state.needsRetry) + assertTrue(state.fallbackPending) + } + + @Test + fun completedHomeRetryClearsRetryAndFallback() { + val completed = completeHomeDetailReturnRetry( + HomeDetailReturnFocusState(requestId = 8, needsRetry = true, fallbackPending = true), + ) + + assertEquals(8, completed.requestId) + assertFalse(completed.needsRetry) + assertFalse(completed.fallbackPending) + } + + @Test + fun explicitHomeSelectionResetsReturnState() { + assertEquals( + HomeDetailReturnFocusState(), + resetHomeDetailReturnFocus(), + ) + } +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDetailReturnFocusStateTest' +``` + +Expected: compilation fails because the Home state type and transition functions do not exist. + +- [x] **Step 3: Add the minimal immutable state model** + +Create `TvDetailReturnFocusState.kt`: + +```kotlin +package org.prairieserver.prairie.tv.ui.shell + +internal data class HomeDetailReturnFocusState( + val requestId: Int = 0, + val needsRetry: Boolean = false, + val fallbackPending: Boolean = false, +) + +internal fun beginHomeDetailReturnRetry( + previousRequestId: Int, + needsRetry: Boolean, +): HomeDetailReturnFocusState = HomeDetailReturnFocusState( + requestId = previousRequestId + 1, + needsRetry = needsRetry, + fallbackPending = needsRetry, +) + +internal fun completeHomeDetailReturnRetry( + state: HomeDetailReturnFocusState, +): HomeDetailReturnFocusState = state.copy( + needsRetry = false, + fallbackPending = false, +) + +internal fun resetHomeDetailReturnFocus(): HomeDetailReturnFocusState = + HomeDetailReturnFocusState() +``` + +- [x] **Step 4: Wire the state model into `TvMainShell`** + +Replace `homeDetailReturnFocusRequest` and `homeDetailReturnNeedsRetry` with one remembered `HomeDetailReturnFocusState`. Include `homeDetailReturnFocusState.fallbackPending` in the Home branch of `detailReturnFallback`. After the synchronous resume request, call `beginHomeDetailReturnRetry`; after the optional deferred request, call `completeHomeDetailReturnRetry`. On explicit Home selection, assign `resetHomeDetailReturnFocus()`. + +Pass `homeDetailReturnFocusState.requestId` to both Home screen call sites. Do not change the For You flow in this task. + +- [x] **Step 5: Run focused shell and Home tests and verify GREEN** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDetailReturnFocusStateTest' --tests '*TvShellFocusStateTest' +``` + +Expected: both test classes pass. + +- [x] **Step 6: Commit the Home correction** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusStateTest.kt +git commit -m "fix(tv): retain Home detail fallback through retry" +``` + +### Task 4: Reset stale For You return state on explicit selection + +**Files:** +- Modify: `androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt:21-25,91-105` +- Modify: `androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt:600-620` + +**Interfaces:** +- Consumes: `ForYouDetailReturnState(requestId: Int, pending: Boolean)` +- Produces: `resetForExplicitForYouSelection(): ForYouDetailReturnState` + +- [x] **Step 1: Add the failing explicit-reset test** + +Add to `TvRecommendationsFocusBridgeTest`: + +```kotlin +@Test +fun explicitForYouSelectionClearsStaleReturnState() { + assertEquals( + ForYouDetailReturnState(requestId = 0, pending = false), + resetForExplicitForYouSelection(), + ) +} +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +./gradlew :androidTvApp:testDebugUnitTest --tests '*TvRecommendationsFocusBridgeTest' +``` + +Expected: compilation fails because `resetForExplicitForYouSelection` does not exist. + +- [x] **Step 3: Add and wire the explicit reset** + +Add to `TvRecommendationsFocusBridge.kt`: + +```kotlin +internal fun resetForExplicitForYouSelection(): ForYouDetailReturnState = + ForYouDetailReturnState(requestId = 0, pending = false) +``` + +In the `TvRootDestination.ForYou` branch of `onSelectRoot`, call the helper and assign both `forYouDetailReturnFocusRequest` and `forYouDetailReturnFocusPending` from the returned state before creating the top-level For You entry request. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the Task 4 command again. Expected: all `TvRecommendationsFocusBridgeTest` tests pass. + +- [x] **Step 5: Commit the For You correction** + +```bash +git add androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridgeTest.kt +git commit -m "fix(tv): reset stale For You detail return" +``` + +### Task 5: Verify the complete PR remediation + +**Files:** +- Verify only: all files changed in Tasks 1-4 + +**Interfaces:** +- Consumes: all four independently passing fixes +- Produces: a review-ready PR #164 branch with focused and full validation evidence + +- [x] **Step 1: Run all focused regression classes together** + +```bash +./gradlew :androidTvApp:testDebugUnitTest \ + --tests '*TvCalendarFocusRoutingTest' \ + --tests '*TvDiagnosticsStateTest' \ + --tests '*TvDetailReturnFocusStateTest' \ + --tests '*TvRecommendationsFocusBridgeTest' \ + --tests '*TvShellFocusStateTest' +``` + +Expected: all focused tests pass. + +- [x] **Step 2: Run the full Android TV unit suite** + +```bash +./gradlew :androidTvApp:testDebugUnitTest +``` + +Expected: zero failures. + +- [x] **Step 3: Assemble the Android TV debug APK** + +```bash +./gradlew :androidTvApp:assembleDebug +``` + +Expected: `BUILD SUCCESSFUL`. + +- [x] **Step 4: Run repository hygiene checks** + +```bash +git diff --check origin/main...HEAD +git status --short +``` + +Expected: no whitespace errors and no uncommitted files. + +- [x] **Step 5: Review the final diff against the approved scope** + +```bash +git diff --stat origin/main...HEAD +git diff origin/main...HEAD -- \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/calendar/TvCalendarScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsScreen.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/recommendations/TvRecommendationsFocusBridge.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvDetailReturnFocusState.kt \ + androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +``` + +Confirm the diff implements only the four approved corrections and their regression coverage. + +- [x] **Step 6: Record the remaining device gate** + +Report that automated validation is complete while Shield smoke checks remain required for held Calendar movement, Diagnostics initial focus, and Home/For You detail-return restoration. diff --git a/docs/superpowers/plans/2026-08-12-android-hosted-diagnostics.md b/docs/superpowers/plans/2026-08-12-android-hosted-diagnostics.md new file mode 100644 index 000000000..23eace7b7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-android-hosted-diagnostics.md @@ -0,0 +1,33 @@ +# Android Hosted Diagnostics Implementation Plan + +**Goal:** Add the Silo-operated hosted collector as the default diagnostics +destination while preserving the existing self-hosted path and privacy gates. + +**Architecture:** Destination-neutral coordination delegates hosted identity, +capability, installation, transport, status, and deletion behavior to typed +hosted collaborators. Local evidence remains account-bound; hosted wire payloads +contain no source identity. Archive encoding is separated from sanitization. + +## Tasks + +- [x] Add typed hosted capabilities, installation, create/upload/status/delete + API contracts and a dedicated public HTTP client. +- [x] Persist installation credentials in encrypted preferences and capabilities + in diagnostics DataStore. +- [x] Add destination selection, hosted-default UI copy, consent constraints, + manifest sanitization, exact-envelope retry, and intent-first erasure. +- [x] Require live collector identity before manual/timed capture. +- [x] Persist a one-way Silo account owner per source server, clear it on server + purge, and require live authenticated ownership before first hosted upload. +- [x] Model `processing` as a typed accepted state, retain local evidence, and + schedule WorkManager status polling until `ready`. +- [x] Refresh coordinator state in upload workers, retry temporary source-server + unavailability, and reconcile remote erasure outside the coordinator actor. +- [x] Extract deterministic USTAR/gzip encoding from privacy sanitization. +- [x] Add focused regression tests and update current product documentation. + +## Verification + +Run the diagnostics-focused Android shared tests, compile both app variants, run +`git diff --check`, and compare the working head with the pinned PR head before +publishing. diff --git a/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md b/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md index 06119cf3c..eb980d3e8 100644 --- a/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md +++ b/docs/superpowers/specs/2026-05-23-android-tv-parity-rework-design.md @@ -353,7 +353,7 @@ On `init`, kicks off `deviceLoginRepository.initiate()` + starts polling. Either ### C.6 — Server-setup screen -Apple's `TVServerSetupView` also exposes a setup QR. This sub-project adds the equivalent affordance to `TvServerSetupScreen` (QR encoding `silo-setup://server` or whatever Apple emits — verify against `TVServerSetupView`). User scans, phone confirms, server URL flows back via the same device-grant pattern. +Apple's `TVServerSetupView` also exposes a setup QR. This sub-project adds the equivalent affordance to `TvServerSetupScreen` (QR encoding `prairie-setup://server` or whatever Apple emits — verify against `TVServerSetupView`). User scans, phone confirms, server URL flows back via the same device-grant pattern. If verifying this turns out to require server changes beyond what's in scope, **split out** as a follow-up. The credential-entry path on `TvServerSetupScreen` remains as the always-available fallback. diff --git a/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md b/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md index 97f223a76..dc7606630 100644 --- a/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md +++ b/docs/superpowers/specs/2026-06-09-android-media-surfaces-design.md @@ -2,7 +2,7 @@ ## Context -The deployed server at `root@silo-new:/opt/prairie-server` now exposes first-class audiobook and ebook catalog contracts. Android currently has partial audiobook and book scaffolding, but several assumptions are stale: old metadata expects fields such as `file_url`, while the server now exposes media through `versions`, `audiobook`, `ebook`, and dedicated ebook reader endpoints. +The deployed server at `root@prairie-new:/opt/prairie-server` now exposes first-class audiobook and ebook catalog contracts. Android currently has partial audiobook and book scaffolding, but several assumptions are stale: old metadata expects fields such as `file_url`, while the server now exposes media through `versions`, `audiobook`, `ebook`, and dedicated ebook reader endpoints. This design adds audiobooks to Android mobile and Android TV, adds ebooks to Android mobile, and hides ebooks from Android TV. diff --git a/docs/superpowers/specs/2026-06-09-android-requests-design.md b/docs/superpowers/specs/2026-06-09-android-requests-design.md index 26619ffd3..0f1d4fa6c 100644 --- a/docs/superpowers/specs/2026-06-09-android-requests-design.md +++ b/docs/superpowers/specs/2026-06-09-android-requests-design.md @@ -6,7 +6,7 @@ Bring the server media-request system to Android clients with a shared Kotlin co ## Source Contract -The server source of truth is the current `/opt/prairie-server` working tree on `root@silo-new`, especially: +The server source of truth is the current `/opt/prairie-server` working tree on `root@prairie-new`, especially: - `internal/api/router.go` - `internal/api/handlers/requests.go` diff --git a/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md b/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md index e8804e117..7f5423767 100644 --- a/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md +++ b/docs/superpowers/specs/2026-06-12-manga-pr138-client-audit.md @@ -15,7 +15,7 @@ - Extended: `GET /catalog/items/{id}` for a manga series carries `manga: { chapters: MangaChapter[] }` — `{content_id, title, chapter_index?: float, volume?: string, read: bool, progress?: 0..1, poster_url?}`, ordered `chapter_index NULLS LAST, sort_title`, read state derived from existing ebook progress. - Chapter item detail now populates `series_id`/`series_title` with the owning manga series (back-navigation + continue-reading collapse). - **Progress is the existing ebook API unchanged** (`GET/PUT /ebooks/{content_id}/progress`, fraction 0..1, per chapter). `read = progress >= finished-threshold`. Mark read/unread reuses the watched-state mutation with `type:"ebook"` per chapter (series-level with `type:"manga"`). -- Status badge is **publication status, not user reading status**: reuses `show_status` with values `Ongoing|Completed|Hiatus|Cancelled|Upcoming` (sourced via the external `silo-plugin-manga-metadata` plugin; requires plugin-sdk v0.7.0 — treat as optional). +- Status badge is **publication status, not user reading status**: reuses `show_status` with values `Ongoing|Completed|Hiatus|Cancelled|Upcoming` (sourced via the external `prairie-plugin-manga-metadata` plugin; requires plugin-sdk v0.7.0 — treat as optional). ## Web reference UX diff --git a/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md b/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md index a0ad114da..966cd733c 100644 --- a/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md +++ b/docs/superpowers/specs/2026-06-12-oauth-signin-mobile-design.md @@ -21,7 +21,7 @@ OAuth on prairie-server is **plugin-driven generic OIDC** — there are no built Because the server completes OAuth by redirecting a **browser** to `{PublicURL}/login/oauth-complete?code=…` and offers no app-scheme/PKCE redirect, the client cannot receive the completion through a Chrome Custom Tab without a server-side App Links file per self-hosted domain (rejected: unreliable, larger scope). A **WebView gives full URL interception**, which is exactly what is needed: load the IdP authorize URL, watch each navigation, and capture the completion redirect's `code`. This is the approved mechanism. -**Known limitation:** WebView-based OAuth is fine for self-hosted OIDC (what silo uses) but Google-branded sign-in disallows embedded WebViews (`disallowed_useragent`). Documented, not worked around. +**Known limitation:** WebView-based OAuth is fine for self-hosted OIDC (what prairie uses) but Google-branded sign-in disallows embedded WebViews (`disallowed_useragent`). Documented, not worked around. ## Client design diff --git a/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md b/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md index 739b386e3..251957e01 100644 --- a/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md +++ b/docs/superpowers/specs/2026-06-15-best-of-source-borrow-map-design.md @@ -8,20 +8,20 @@ ## Purpose This document mines the reference apps in `/Users/jimcole/source` and maps their best -product behavior and engineering patterns onto silo's current subsystems. It is the +product behavior and engineering patterns onto prairie's current subsystems. It is the concrete companion to the approved best-of-source roadmap: where the roadmap says *what* to borrow and in what order, this says *exactly which pattern, from which file, applied -where in silo, at what effort, under what license, with what Android-7 caveat*. +where in prairie, at what effort, under what license, with what Android-7 caveat*. Approach (agreed): **hybrid, prioritized by gap.** Deep code-level extraction for Reading (ebook/reflow, comics/manga, PDF), Audiobook, and the Video player; survey-level (gaps only) for Watch-Together, Notifications, Requests, and Admin. Each was mined by an -agent reading the mapped reference apps against silo's current code. +agent reading the mapped reference apps against prairie's current code. ## How to read this - Effort: **S** = ≤1 day, **M** = a few days, **L** = a week+ / new subsystem. -- Every borrowed idea cites a real source file and the silo file it applies to. +- Every borrowed idea cites a real source file and the prairie file it applies to. - Correctness items overlapping the playback/reader review (2026-06-15) are flagged. - Constraints carried throughout: **Android 7 / API 24 hard floor** and **AGPLv3**. @@ -36,16 +36,16 @@ debugging), not as gating rules. The only reason to *reimplement* rather than paste, then, is **engineering fit**, and it still applies in specific cases: -- Different UI stack — silo is **Compose/KMP-first**; the Jellyfin clients, mihon, Kotatsu, +- Different UI stack — prairie is **Compose/KMP-first**; the Jellyfin clients, mihon, Kotatsu, document-viewer, etc. are XML View + RecyclerView/ViewPager. Pasting their view holders imports a parallel UI stack. - Parallel infrastructure — don't drag in a second DI/preferences system (mihon's Injekt, - others' DataStore wrappers) when silo already has its own. + others' DataStore wrappers) when prairie already has its own. - Native packaging — MuPDF/pdfium, libmobi, MPV add NDK builds, ABI/`.so` weight, and API24 risk; those are *architecture decisions*, not free copies, regardless of license. So: **copy whatever is a clean fit; reimplement only where the source's architecture -clashes with silo's.** The "what NOT to borrow" notes below now reflect engineering fit, +clashes with prairie's.** The "what NOT to borrow" notes below now reflect engineering fit, not licensing. --- @@ -121,7 +121,7 @@ They de-risk everything built on top. ### Best-in-class reference(s) and why -- **readest** (AGPL-3.0) — most directly transferable: foliate-js WebView reader whose **document model, CFI locator, and three-tier settings hierarchy** map ~1:1 onto silo's WebView-reflow architecture. Primary mine. +- **readest** (AGPL-3.0) — most directly transferable: foliate-js WebView reader whose **document model, CFI locator, and three-tier settings hierarchy** map ~1:1 onto prairie's WebView-reflow architecture. Primary mine. - **book-story** (GPL-3.0) — best **Compose-native shell and settings UX** (type-safe DataStore DSL, debounced progress persistence, nested-TOC drawer, font registry). *Do not* borrow its rendering path (it discards EPUB CSS). - **koreader** (AGPL-3.0) — **typography depth** (language-keyed hyphenation, margin presets, CSS-tweak taxonomy) as data tables/UX, not the C++ engine. - **LibreraReader** (GPL-3.0; bundles LGPL libmobi) — the concrete **MOBI/AZW native-parse** path (libmobi → EPUB conversion, then reuse the EPUB pipeline). @@ -145,12 +145,12 @@ They de-risk everything built on top. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Element-level locator (CSS-selector/text-quote anchor) alongside section+fraction | readest `utils/cfi.ts`, `services/nav/locations.ts` | `reflow/ReflowLocator.kt`, `paginator.js` (emit nearest-element selector on relocate) | M | AGPL design, reimplement | `elementFromPoint`+`querySelector` OK on API24 WebView; avoid `:has()` | | Char/byte-offset location map for accurate book progress | readest `bakeLocationsAndCfis` (SIZE_PER_LOC=2500) | replace `SectionWeights.kt` estimate with cumulative offset table | S | AGPL design | none | | Three-tier settings (global < per-book deltas < session) | readest `types/book.ts` override flags | `ReaderControls.kt` + `EbookLocalStateStore.kt` | M | AGPL design | none | -| Type-safe DataStore settings DSL | book-story `data/settings/SettingsManager.kt` | silo display-prefs persistence (absent today) | M | GPL-3.0 — attribute if copied | DataStore fine on API24 | +| Type-safe DataStore settings DSL | book-story `data/settings/SettingsManager.kt` | prairie display-prefs persistence (absent today) | M | GPL-3.0 — attribute if copied | DataStore fine on API24 | | Debounced (300ms) progress persistence | book-story `ReaderModel.updateProgress()` | `ReflowableReader` locator callback / `ReaderViewModel.kt` | S | GPL-3.0 pattern | none | | Font registry + font-family picker | book-story `ui/reader/data/ReaderData.kt` | extend `ReaderDisplaySettings`; wire `ReflowStyle.kt:23` | M | GPL-3.0 pattern | bundle static weights; variable fonts API26+ | | Nested-TOC drawer w/ auto-expand current | book-story `ui/reader/ReaderChaptersDrawer.kt` | `ReaderShell.kt` | S | GPL-3.0 pattern | none | @@ -163,7 +163,7 @@ They de-risk everything built on top. ### What NOT to borrow / risks -- Don't adopt book-story's CSS-discarding native-text parser (silo's WebView correctly preserves publisher CSS) — borrow its shell/settings/state, not its parser. (JellyBook is thin; ideas only.) +- Don't adopt book-story's CSS-discarding native-text parser (prairie's WebView correctly preserves publisher CSS) — borrow its shell/settings/state, not its parser. (JellyBook is thin; ideas only.) - Don't chase koreader's CREngine; only its Lua data tables/UX taxonomy are practical. - Full EPUB-CFI is heavy — a lighter element-selector + text-quote anchor gives ~90% of resume/bookmark stability for far less. - libmobi adds an NDK build + binary size + maintenance — justify only if MOBI/AZW is a real need; otherwise keep graceful external-open. @@ -199,7 +199,7 @@ They de-risk everything built on top. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | `Viewer` interface + per-mode impls (L2R/R2L/Vertical/Webtoon) by object | mihon `viewer/Viewer.kt:12`, `pager/PagerViewers.kt` | replace `ComicReader.kt` monolith; `ComicViewer` + manga/webtoon impls | M | Apache-2.0 — reimplement, attribute | none | | Config vs tap-navigation split (reactive config; fractional `RectF`→region) | mihon `ViewerConfig.kt`, `ViewerNavigation.kt:44-53` | new `ComicReaderConfig` from `ReaderDisplaySettings` | M | Apache-2.0 | none | @@ -209,7 +209,7 @@ They de-risk everything built on top. | `ZoomMode` (FIT_WIDTH/HEIGHT/SCREEN/ORIGINAL) + zoom/pan/double-tap via SSIV | Kotatsu `core/model/ZoomMode.kt`, `ui/pager/standard/PageHolder.kt`; mihon `ReaderPageImageView.kt` | replace `Image`/`ContentScale.Fit` (`ComicReader.kt:263-270`) with SSIV in `AndroidView` | L | GPL/Apache | SSIV region/tile decode = the A7-safe path for large/zoomed pages | | Adaptive decode: RGB_565 + region decoder + downsample by free-RAM | Kotatsu `BasePageHolder`, `core/image/BitmapDecoderCompat.kt` | extend `decodeComicPageBitmap` (`:299-314`) | M | GPL-3.0 | targets API24 OOM; `ImageDecoder` is API28+ — keep BitmapFactory fallback | | Chapter/issue stitching (prev/transition/curr/transition/next) + range preload | mihon `PagerViewerAdapter.kt:47-114` | new series-aware layer above `ReaderViewModel` | L | Apache-2.0 | needs backend series API | -| Natural/numeric page sort | (silo bug) | `listComicArchivePages` (`:343-358`) | S | n/a | none | +| Natural/numeric page sort | (prairie bug) | `listComicArchivePages` (`:343-358`) | S | n/a | none | | Double-page spread pairing (landscape) — later | komikku `PagerViewerAdapter` joinedItems | future `PageLayout` mode | L | Apache-2.0 | two bitmaps at once → watch API24 memory | | Rotation-restore guard (suppress spurious page events) | komikku `viewer/pager/Pager.kt` | pager-state restore | S | Apache-2.0 | none | @@ -218,11 +218,11 @@ They de-risk everything built on top. - Don't fork the View-based viewers wholesale (XML ViewPager/RecyclerView + SSIV); borrow the *abstractions* and reimplement in Compose, using thin `AndroidView`/SSIV wrappers only where zoom demands it. - Don't pull a native unrar/7z lib for CBR/CB7 in the first slice — keep external-only. - **API24 decode memory is the dominant risk:** mandatory RAM-gated prefetch, bounded concurrent decodes, RGB_565 + region decoding via SSIV, free-RAM-tied downsample. `ImageDecoder` paths are API28+ — keep BitmapFactory fallback. -- Don't copy mihon's Injekt/preferences plumbing; route config through silo's existing `ReaderDisplaySettings` + `EbookLocalStateStore`. +- Don't copy mihon's Injekt/preferences plumbing; route config through prairie's existing `ReaderDisplaySettings` + `EbookLocalStateStore`. ## PDF / Fixed-Document Reader -silo renders fixed documents with Android's built-in `PdfRenderer` (no native MuPDF/pdfium). The references all sit on MuPDF (GPL/AGPL) — adopt **algorithms/patterns**, not their JNI codecs. +prairie renders fixed documents with Android's built-in `PdfRenderer` (no native MuPDF/pdfium). The references all sit on MuPDF (GPL/AGPL) — adopt **algorithms/patterns**, not their JNI codecs. ### Best-in-class reference(s) and why @@ -236,7 +236,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu - **LRU page cache + recycle-on-evict** — document-viewer `DecodeServiceBase.java:385-417`; Librera `:63-82`. - **Bitmap pool/reuse** — document-viewer `BitmapManager.java:81-257` (match w/h/config before alloc, cap `maxMemory()/2`). - **OOM safety net** — document-viewer `DecodeServiceBase.java:215-228`; Librera `:291-369`: catch `OutOfMemoryError`, clear cache, recycle, abort task — never crash. -- **Native page lifecycle guards** — Librera `MuPdfPage.java` `isRecycled()` + global `TempHolder.lock`; document-viewer `MuPdfPage.java:93-113` synchronized recycle. (Validates silo's own concern from two codebases.) +- **Native page lifecycle guards** — Librera `MuPdfPage.java` `isRecycled()` + global `TempHolder.lock`; document-viewer `MuPdfPage.java:93-113` synchronized recycle. (Validates prairie's own concern from two codebases.) - **Fit modes** — document-viewer `SinglePageController.java:253-268`; koreader `readerzooming.lua:522-593` (9 modes). - **Content auto-crop** — koreader `pdfdocument.lua:155-176` + `koptinterface.lua:191-237` (validates bbox > 10% of page). `PdfRenderer` exposes no bbox → needs pixel scan. - **Prefetch/hinting** — koreader `readerhinting.lua:13-26` (pre-render ~3 pages async). @@ -257,7 +257,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Memory-derived render cap + RGB_565 | koreader `doccache.lua:15,53`; Librera `MemoryUtils.java` | `renderPdfPageBitmap` `PdfReader.kt:284` | S | pattern only | critical — `memoryClass` on API24 can be 32–64MB | | LRU page-bitmap cache (recycle on evict) | doc-viewer `DecodeServiceBase.java:385-417` | cache around `PdfPage` (`:187`) | M | GPL pattern | window 1–3 pages on low-heap | @@ -272,19 +272,19 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### What NOT to borrow / risks -- Don't import MuPDF/pdfium or `com.artifex.*`/`org.ebookdroid.droids.mupdf.*` — silo deliberately uses `PdfRenderer`; MuPDF is a large native + GPL-coupling commitment (separate engine decision, not a borrow). +- Don't import MuPDF/pdfium or `com.artifex.*`/`org.ebookdroid.droids.mupdf.*` — prairie deliberately uses `PdfRenderer`; MuPDF is a large native + GPL-coupling commitment (separate engine decision, not a borrow). - Skip quadtree tiling for v1 — `PdfRenderer.Page.render` supports a clip `Rect` + `Matrix`, so region rendering is far simpler; revisit only for >4× zoom on huge pages. - Don't blindly allow 1–32× zoom — render *clipped regions* at high zoom, never a full-page bitmap scaled up. - Avoid giant fixed thread pools (doc-viewer thumbnail executor sized 256) — bound to 1–2 render threads. - The **2×/2000px hardcode (`PdfReader.kt:294`) is the single biggest A7 liability** — replace before adding zoom. -- koreader's k2pdfopt reflow is irrelevant — silo reflows via its separate reflow engine. +- koreader's k2pdfopt reflow is irrelevant — prairie reflows via its separate reflow engine. ## Audiobook Player ### Best-in-class reference(s) and why - **Voice** (GPL-3.0) — gold standard for an *audiobook-native* on-device player: per-book speed/gain/skip-silence, chapter cue points, sleep timer with fade + shake-to-reset + end-of-chapter, `MediaLibraryService`. Borrow its **playback-service shape, sleep-timer state machine, and per-book audio-processing model**. -- **lissen-android** (GPL-3.0) — gold standard for *server-backed multi-file timeline*: one MediaItem per chapter from clipped/concatenated file segments, centralized absolute↔(chapter,offset) math, a streaming `SimpleCache` **separate** from downloads, chapter-boundary-aware sync. Borrow its **timeline/MediaSource construction and dual-cache separation** — silo's biggest gap. +- **lissen-android** (GPL-3.0) — gold standard for *server-backed multi-file timeline*: one MediaItem per chapter from clipped/concatenated file segments, centralized absolute↔(chapter,offset) math, a streaming `SimpleCache` **separate** from downloads, chapter-boundary-aware sync. Borrow its **timeline/MediaSource construction and dual-cache separation** — prairie's biggest gap. - absorb / aradia / AudioAnchor / audiobookshelf-app — secondary UX/domain references. ### What they do well @@ -311,7 +311,7 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | One-MediaItem-per-chapter playlist from clipped/concatenated segments → true multi-file timeline | lissen `PlaybackService.kt:303`, `resolveChapterToFiles:255`, `LissenMediaSourceFactory.kt:62` | new `AudiobookMediaItemBuilder` → `ContinuumPlayerFactory`; VM stops collapsing to one `selectedFileId` (`:186`) | L | GPLv3 — reimplement | `ConcatenatingMediaSource2`/`ClippingMediaSource` fine on API24 | | Centralized absolute↔(chapter,offset) math + `CHAPTER_START_MS` | lissen `CalculateChapterIndexAndPosition.kt:20`, `PlaybackSynchronizationService.kt:175` | extend `AudiobookChapters.kt:36` with reverse-map + media-index helpers | S | GPLv3 pattern | pure Kotlin | @@ -323,27 +323,27 @@ silo renders fixed documents with Android's built-in `PdfRenderer` (no native Mu | Notification next/prev → interval; in-app → chapter skip | Voice `VoicePlayer.getAvailableCommands:109`, `LibrarySessionCallback:56` | `ContinuumPlaybackService` command/button config; **gate by media type** | M | GPLv3 pattern | Media3 commands API24 | | Keep audiobooks playing on swipe-away/off-screen | Voice (never stops on task removal); lissen `START_STICKY` | branch `onTaskRemoved:172` by media type; detach `MediaController` from Compose (`AudiobookPlayerScreen.kt:87`) into app-scoped holder + mini-player | M–L | own code | FG-service mediaPlayback type API34+; on A7 keep session alive | | Chapter-boundary-aware progress sync (tighten near boundaries; `timeListened` delta; finished-state) | lissen `PlaybackSynchronizationService.kt:69-136` | extend `AudiobookProgressSyncer` | S–M | GPLv3 pattern | n/a | -| Server-synced bookmarks (reuse ebook annotation pattern) | silo ebook `EbookReaderRepository.kt:30`; audiobookshelf-app | `AudiobookBookmarksStore:13` → repository + sync; note-edit in sheet | M | own code / server | n/a | +| Server-synced bookmarks (reuse ebook annotation pattern) | prairie ebook `EbookReaderRepository.kt:30`; audiobookshelf-app | `AudiobookBookmarksStore:13` → repository + sync; note-edit in sheet | M | own code / server | n/a | ### What NOT to borrow / risks - Don't collapse the audiobook player into a music/now-playing UX — keep the book metaphor (chapter list w/ per-chapter progress, whole-book scrub, chapter-aware skip). - **Don't let the streaming cache replace public downloads** — two stores: ephemeral `SimpleCache` (`externalCacheDir`, evictable) vs user-facing `DownloadStorage` (`Music/Prairie`, `DownloadStorage.kt:420,596`); `OfflineMediaResolver` stays the authoritative "downloaded?" check. -- Don't apply Voice's notification prev/next remap globally — silo's service is shared with video; gate by media type. +- Don't apply Voice's notification prev/next remap globally — prairie's service is shared with video; gate by media type. - Don't adopt lissen's per-chapter `ConcatenatingMediaSource2` for the video path; keep `ContinuumPlayerFactory` buffer/processor config branched by media type. -- Voice/lissen are XML/Hilt-based audiobook apps — borrow their playback-service shape and timeline math; reimplement in silo's Compose/KMP style rather than pasting. Seismic (shake) is a clean direct dependency. +- Voice/lissen are XML/Hilt-based audiobook apps — borrow their playback-service shape and timeline math; reimplement in prairie's Compose/KMP style rather than pasting. Seismic (shake) is a clean direct dependency. - A7: implement the background fix with an API-level branch (`START_STICKY` + persistent `MediaSession` on API24). ## Video Player -silo is **not greenfield** here — it already ships a `VideoPlaybackBackend` contract with both Media3 and MPV implementations, a route-capability matrix, a buffer-preset enum, a real `MediaCodecList` probe, and a Compose TV remote key mapper. The references are best used as a **maturity checklist** that exposes wiring gaps and two selection bugs. +prairie is **not greenfield** here — it already ships a `VideoPlaybackBackend` contract with both Media3 and MPV implementations, a route-capability matrix, a buffer-preset enum, a real `MediaCodecList` probe, and a Compose TV remote key mapper. The references are best used as a **maturity checklist** that exposes wiring gaps and two selection bugs. ### Best-in-class references & why | Ref | License | Why | |---|---|---| -| **jellyfin-androidtv** `playback/` | GPLv2 | Cleanest backend-behind-contract: `PlayerBackend` interface, `BackendService.switchBackend()` hot-swap. Target shape for silo's backend. | -| **findroid** `player/` | GPLv3 | Canonical `dev.jdtech.mpv:libmpv` integration — the *same* dep silo uses; `MPVPlayer extends BasePlayer`. | +| **jellyfin-androidtv** `playback/` | GPLv2 | Cleanest backend-behind-contract: `PlayerBackend` interface, `BackendService.switchBackend()` hot-swap. Target shape for prairie's backend. | +| **findroid** `player/` | GPLv3 | Canonical `dev.jdtech.mpv:libmpv` integration — the *same* dep prairie uses; `MPVPlayer extends BasePlayer`. | | **AFinity** `player/mpv/` | GPLv3 | Richest libass styling surface (`sub-ass-override`, `sub-border-style`, `sub-color`, `sub-font-size`). minSdk 35 — MPV not proven on A7. | | **Wholphin** `services/PlayerFactory.kt` | GPLv2 | Runtime backend choice as a factory + libass-aware ExoPlayer path (`AssRenderersFactory`) — libass *without* MPV. | | **jellyfin-android** `TrackSelectionHelper.kt` | GPLv2 | Delivery-method-aware track selection (EMBED/EXTERNAL/ENCODE). | @@ -357,7 +357,7 @@ silo is **not greenfield** here — it already ships a `VideoPlaybackBackend` co ### Prairie current state -silo is ahead on every axis except UI surfacing and two bugs. +prairie is ahead on every axis except UI surfacing and two bugs. - **Backend contract — DONE:** `backend/VideoPlaybackBackend.kt:12`; `Media3VideoPlaybackBackend` + `MpvVideoPlaybackBackend`; factory + `VideoPlaybackBackendSelector.kt:6` Auto policy (TRANSCODE→Media3, hard-container→MPV, styled-subs→MPV). - **Capability metadata — DONE but not surfaced:** `VideoBackendCapabilities.kt` carries `subtitleRendering`, `supportsHardContainers`, `displayName`. **Gap:** `video/VideoPlayerUiState.Ready:25` has no field for backend kind/displayName/subtitle-rendering → UI can't show "libass (MPV)" vs "Media3 text". Headline ask; wiring gap. - **MPV backend — DONE, real native libs:** `dev.jdtech.mpv:libmpv:1.0.0`; `libmpv.so` + ffmpeg packaged for **armeabi-v7a** (the 32-bit A7 ABI). `MpvPlayer.kt` complete `BasePlayer` (cache→buffering, libass via fontconfig, buffered position from `demuxer-cache-time`, auth headers). Auth headers pulled with `runBlocking` (`ContinuumPlayerFactory.kt:179`) — latent ANR. @@ -369,7 +369,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ### Concrete adoptable patterns -| Idea | Source location | Where in silo | Effort | License | A7 caveat | +| Idea | Source location | Where in prairie | Effort | License | A7 caveat | |---|---|---|---|---|---| | Surface backendKind/displayName/subtitleRendering on player UI state | jellyfin-androidtv `PlayerState.kt:83` | add fields to `VideoPlayerUiState.Ready` (`:25`) from `backend.capabilities` | S | concept | none | | Fix unconditional text disable + apply `preferredTextLanguage` | jellyfin-android `TrackSelectionUtils.kt:13` | `TrackSelectionPresets.kt:69` & `:108` | S | GPLv2 — reimplement | none | @@ -380,11 +380,11 @@ silo is ahead on every axis except UI surfacing and two bugs. | Delivery-method-aware track switching | jellyfin-android `TrackSelectionHelper.kt:43,105,151` | `VideoTrackSelectionCoordinator` + `PlaybackSessionManager.changeAudio` | M | GPLv2 — pattern | none | | Explicit FF/REW + press-and-hold scrub | jellyfin-androidtv `CustomPlaybackOverlayFragment.java:453` | `TvPlayerRemoteKeyAction.kt:19` add media keys | S | GPLv2 — pattern | none | | Tighter resume (3s/15s) + server→client seek listener | jellyfin-androidtv `PlaybackController.java:59`, `PlaySessionSocketService.kt:37` | `PlaybackSessionManager.reportProgress` (`:71`) | S–M | GPLv2 — pattern | none | -| Move `runBlocking` auth fetch off player-build thread | (silo-internal) | `ContinuumPlayerFactory.kt:179` | S | n/a | avoids ANR on slower A7 | +| Move `runBlocking` auth fetch off player-build thread | (prairie-internal) | `ContinuumPlayerFactory.kt:179` | S | n/a | avoids ANR on slower A7 | ### What NOT to borrow / risks -- **Don't make MPV the default / sole path on TV/A7.** Both MPV references run minSdk 28/35 — neither validates libmpv on API24/armeabi-v7a (silo packages the `.so`, but "builds" ≠ "decodes reliably on a 2017 ARMv7 box"). Keep MPV opt-in/Auto-only (as `VideoPlaybackBackendSelector` already does); keep abiFilters tight (APK size). +- **Don't make MPV the default / sole path on TV/A7.** Both MPV references run minSdk 28/35 — neither validates libmpv on API24/armeabi-v7a (prairie packages the `.so`, but "builds" ≠ "decodes reliably on a 2017 ARMv7 box"). Keep MPV opt-in/Auto-only (as `VideoPlaybackBackendSelector` already does); keep abiFilters tight (APK size). - **Don't destabilize the Media3 path during the reading phase.** Highest-value, lowest-risk S items (text-disable fix, capability surfacing, FF/REW, off-thread auth) are all Media3-side — land those first; defer libass-on-Media3 (L) and MPV styling (M). - The Jellyfin clients (jellyfin-androidtv/android, Wholphin) are XML View-based; reimplement their ideas in Compose rather than pasting view code — engineering fit, not license. `dev.jdtech.mpv:libmpv` is already a binary dep. - Keep MPV's event-driven `STATE_BUFFERING` (push) rather than poll-only buffer; use `getBufferedPosition()` only for the scrub cushion. @@ -412,7 +412,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Notifications (survey) -**Prairie current state.** REST-source-of-truth inbox + realtime accelerator: pure `applyEvent` fold (`NotificationsRepository.kt:58`); capped-backoff reconnect (`:227`); optimistic `markRead`/`markAllRead` with revert (`:169,177`); `reset()` on profile switch (`:212`). Realtime client mints ws-ticket, decodes frames (`NotificationsRealtimeClient.kt:65`). **silo is far ahead of every reference here** (streamyfin = expo-push/badge only; jellyfin clients = in-memory app alerts; Campfire = server config) — only correctness gaps: +**Prairie current state.** REST-source-of-truth inbox + realtime accelerator: pure `applyEvent` fold (`NotificationsRepository.kt:58`); capped-backoff reconnect (`:227`); optimistic `markRead`/`markAllRead` with revert (`:169,177`); `reset()` on profile switch (`:212`). Realtime client mints ws-ticket, decodes frames (`NotificationsRealtimeClient.kt:65`). **prairie is far ahead of every reference here** (streamyfin = expo-push/badge only; jellyfin clients = in-memory app alerts; Campfire = server config) — only correctness gaps: - Unsynchronized `_state` RMW — fold all mutations through `_state.update{}` (atomic pattern); derive rows/unread from `_state`. **S** — *branch-review bug.* - No terminal on persistent auth failure — `connectRealtime` loops forever; `Closed("ticket_error_401")` folds as no-op. Stop/long-backoff on 401/403; expose a disconnected state. **M** — *branch-review bug.* - Backoff not reset on clean close — reset on successful *connect* (hello/subscribed), not on traffic. **S** — *branch-review bug.* @@ -429,7 +429,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Admin (survey) -**Prairie current state.** `AdminRepository.kt` stateless pass-through: stats, user CRUD, session list + control, app/audit logs, library scan. ViewModels: `AdminUsersViewModel` (generation-gated, `:34`), `AdminUserEditViewModel` (`:53`), `AdminStatsViewModel`. **No reference app covers admin** — silo is the only one with real CRUD + session control + logs. Correctness only: +**Prairie current state.** `AdminRepository.kt` stateless pass-through: stats, user CRUD, session list + control, app/audit logs, library scan. ViewModels: `AdminUsersViewModel` (generation-gated, `:34`), `AdminUserEditViewModel` (`:53`), `AdminStatsViewModel`. **No reference app covers admin** — prairie is the only one with real CRUD + session control + logs. Correctness only: - Cleared library-ids field revokes all access — `update` always sends `libraryIds = parseLibraryIds(text)` (`:137`); blank → `emptyList()` (`AdminUserForm.kt:48`), *sent* (not omitted) → server revokes all. Send `null` when blank, or distinguish cleared vs empty. **S** — *branch-review bug.* (Quota fields are safe — `parseQuota` returns null.) - `deleteUser` — current code removes from list **only on success** (`:59`), no optimistic resurrect path; the review's "resurrect" premise appears stale for this revision. **No change unless an optimistic variant returns** — confirm. - `AdminUserEditViewModel.load` idempotent guard (`loaded` flag, `:60`) can't switch targets within one instance — correct only if always freshly scoped per navigation; else add id-aware reload. **S.** @@ -440,7 +440,7 @@ silo is ahead on every axis except UI surfacing and two bugs. ## Provenance Ledger Lineage breadcrumbs (maintenance aid, not a license requirement). "Mode" = whether the -source's architecture is a clean fit (copy) or clashes with silo's stack (reimplement). +source's architecture is a clean fit (copy) or clashes with prairie's stack (reimplement). Update as items land. | Idea borrowed | Source app | Source license | Mode | diff --git a/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md b/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md index b0d163d3e..9baaae270 100644 --- a/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md +++ b/docs/superpowers/specs/2026-06-17-android-tv-detail-parity-design.md @@ -14,7 +14,7 @@ ## 0. Apple source map (the contract) -Branch `feature/playback-ux-redesign` at `/Users/jimcole/projects/silo/prairie-apple`, all under `iosApp/iosApp/tvOS/Screens/Detail/`: +Branch `feature/playback-ux-redesign` at `/Users/jimcole/projects/prairie/prairie-apple`, all under `iosApp/iosApp/tvOS/Screens/Detail/`: - `TVMovieDetailView.swift` — movie/episode page composition (action row, More menu, body order, focus). - `TVSeriesDetailView.swift`, `TVSeasonDetailView.swift` — series/season page composition (read these for season-chip + episode-rail placement and the next-up-driven selector). - `TVDetailHero.swift` — hero (heroHeight 980, scrim stops, editorial column, title treatments, eyebrow, source row, facts row, starring overlay, `TVHeroMetadata`). diff --git a/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md b/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md index 17d93d9e9..3f13211ce 100644 --- a/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md +++ b/docs/superpowers/specs/2026-06-17-playback-behavior-migration-design.md @@ -28,7 +28,7 @@ three planned subsystem specs: - Pure, reusable logic lives in `shared`/`android-shared` so `androidTvApp` can adopt it later and so it is unit-testable without Android dependencies. - UI (prompts, overlays) stays per-app. -- Match silo's existing patterns (`RoomSyncController`, `WatchTogetherRealtimeClient`, +- Match prairie's existing patterns (`RoomSyncController`, `WatchTogetherRealtimeClient`, `PlayerViewModel`) rather than copying continuum verbatim. - Reuse methods `PlayerViewModel` already exposes; do not duplicate playback control. diff --git a/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md b/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md new file mode 100644 index 000000000..3b8dec132 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-android-media-buffer-sizing-design.md @@ -0,0 +1,56 @@ +# Android Media-Aware Buffer Sizing Design + +## Goal + +Prevent fast network delivery from inflating Android phone and TV byte-buffer targets while preserving existing playback time thresholds, device byte floors and caps, and HTTP Range resume/retry behavior. + +The change is limited to the shared `android-shared` `PrairieLoadControl`. It does not alter Prairie Server, Apple clients, production proxy configuration, or the progressive data-source retry path introduced for issue #80. + +## Bitrate Selection + +For each selected audio or video track: + +1. Use a positive `Format.averageBitrate`. +2. If average bitrate is absent or invalid, use a positive `Format.peakBitrate`. +3. Do not treat `Format.bitrate` as an independent input because Media3 defines it as peak bitrate when available, otherwise average bitrate. + +Sum the selected tracks' known media bitrates. If any selected track supplies valid media metadata, that media sum is the sizing estimate and all `ExoTrackSelection.latestBitrateEstimate` values are ignored. This prevents delivery capacity on a fast LAN from being mistaken for encoded media consumption. + +Only when no selected track has valid average or peak metadata may the largest positive `latestBitrateEstimate` be used as a last-resort estimate. The maximum is used rather than a sum because adaptive selections commonly share one bandwidth estimate. If neither metadata nor a positive network estimate exists, retain `DefaultLoadControl`'s target-buffer calculation. + +## Buffer Calculation + +The selected estimate continues through the existing calculation: + +- enough bytes for `minBufferMs`; +- the existing 15 percent container/protocol overhead; +- the existing 16 MiB minimum target; +- the existing device-specific maximum target. + +Startup, rebuffer, and back-buffer time thresholds are unchanged. + +## Adaptation and Reset + +Media3's `LoadControl` boundary exposes buffered duration and allocator bytes, but not a reliable encoded-byte consumption rate. Allocator growth is retained buffer, not consumption, and `latestBitrateEstimate` represents delivery capacity. Using either as an observed-consumption proxy would recreate the bug under a different name. + +This implementation therefore remains deliberately stateless. Track or session changes invoke target calculation with the new selections, naturally discarding the previous estimate. Upward adaptation and decay are deferred until a reliable encoded-consumption signal is available at this boundary. + +## Verification + +Focused tests cover: + +- average bitrate taking precedence over peak bitrate; +- peak bitrate when average is absent; +- invalid or absent metadata; +- multi-track media-rate summation; +- network estimate used only when all media metadata is absent; +- network capacity not inflating a metadata-derived target; +- unchanged byte floors, caps, and unknown-bitrate fallback. + +The existing progressive Range-resume integration test protects issue #80 behavior. Full shared tests and phone/TV debug and release compilation verify the common load-control wiring. A local short-timeout canary may be used to exercise retry behavior without changing production proxy settings; inability to produce genuine socket backpressure with a small local fixture will be recorded as a validation limitation rather than replaced with a misleading proxy. + +## Alternatives Rejected + +- Metadata-only sizing with no network fallback conflicts with the approved last-resort behavior for metadata-poor media. +- Using allocator growth or bandwidth estimates as observed consumption is not a valid encoded-consumption measurement. +- Adding transport instrumentation or duplicating private `DefaultLoadControl` loading logic would be disproportionate and risks changing issue #80 behavior. diff --git a/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md b/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md new file mode 100644 index 000000000..b69f2e7f6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-android-tv-navigation-remediation-design.md @@ -0,0 +1,214 @@ +# Android TV Navigation Remediation Design + +## Purpose + +Fix two Android TV regressions reported against v1.0.0 and reproduced on the +current client against `lib.strm.cafe`: + +1. The For You filter band cannot transfer D-pad focus into recommendation + rows. +2. Home navigation is sluggish during the first traversal after process start, + then becomes responsive after the same content is warm. + +The change is Android TV focused. Shared startup hydration may change where it +directly removes duplicate Android phone/TV work, but server APIs, server +configuration, content ordering, recommendation generation, and playback are +out of scope. + +## Verified Causes + +### For You focus + +`TvRecommendationsScreen` initially focuses the Watchlist pill but does not +provide a Down target or directional handler for any filter pill. Its +`TvMediaRow` instances also receive neither a row-container focus requester nor +a first-card focus requester. Compose's geometric search therefore leaves focus +on Watchlist, while Watchlist and Favorites work because their catalog grids +provide a different focus topology. + +### Cold Home navigation + +Several independent eager paths overlap after process start: + +- `warmAuthenticatedStartup` fetches the aggregate Home response and then + unconditionally fetches every section again with unbounded concurrency. +- `HomeViewModel` independently fetches the aggregate Home response while the + startup warmup is running. +- `TvSkylineSectionFeed` starts hero artwork and full-detail requests for as + many as sixteen cards on page entry. +- Raw focus movement starts neighbor artwork and network-first detail requests + before the existing 150 ms rested-focus decision. + +Servers with more libraries and rows amplify the first two costs, but server +size is not itself an error. The client must remain responsive for a +production-sized Home response. + +Archived PR 108 commits `49a70045` and `65c4b316` document earlier intent to +remove the page-entry fan-out and hydrate only unresolved Home sections with a +four-request bound. They are historical references, not patches to apply +blindly: current main has newer Home response handling and must retain it. + +## Design + +### 1. Explicit For You focus bridge + +`TvRecommendationsScreen` will own stable focus requesters for: + +- the filter pill that should receive Up from recommendation content; +- the first nonempty recommendation row container; and +- the first card in that row. + +Every filter pill routes Down according to the visible content: + +- For You: request the first row container, wait one frame for the row + `focusRestorer` boundary, then request its first card. +- Watchlist and Favorites: preserve their existing catalog-grid behavior. +- Loading, error, and empty states: do not target an absent row; their existing + actionable control remains reachable. + +The first For You row routes Up back to the selected filter pill. Initial entry +continues to focus Watchlist, preserving current product behavior. + +The handoff will reuse the established `TvMediaRow` requester contract rather +than introducing a second focus-navigation architecture. + +### 2. Deliberate Home-to-menu focus boundary + +Rapid or held Up input must traverse Home rows one at a time and stop on the +first content row. Reaching that row as part of the same repeated-key sequence +must not immediately move focus into the top menu. A new Up press after the +remote key has been released may enter the selected top-menu item. + +The Skyline row band will continue to own off-screen row relocation. It will +serialize that relocation so overlapping key-repeat events cannot start +competing scroll jobs or consume more row transitions than completed focus +moves. The shell remains the only owner of the final content-to-menu handoff. + +### 3. Unify For You saved-list presentation + +Watchlist and Favorites chosen from the For You top-menu selector will open the +existing `TvRecommendationsScreen` with the matching saved-list pill selected. +They will therefore use the same filter band, inline grid, spacing, focus +behavior, and Back destination as choosing those pills after entering For You. + +Watchlist and Favorites chosen from the profile menu remain standalone utility +pages. This preserves their established account-navigation role and avoids +turning every saved-list deep link into a For You route. No repository, server, +or personal-data behavior changes; both presentations continue to use the +existing `WatchlistViewModel` and `FavoritesViewModel`. + +### 4. Resolve Home once, hydrate only missing sections + +Extract a small, platform-neutral Home hydration operation that: + +- accepts aggregate `ResolvedSection` values; +- preserves sections whose items are already inline; +- fetches only sections that are empty while reporting a nonzero total; +- accepts either nested response items or top-level response items; +- limits fallback requests to four concurrently; +- reports whether the snapshot was fully resolved so a partial result cannot + replace a good cache. + +`HomeViewModel` and startup warmup will share this operation. Startup warmup +will no longer refetch every inline section. + +The activity warmup remains best-effort and non-blocking. This change does not +make splash dismissal wait for Home. + +### 5. Remove page-entry detail fan-out + +`TvSkylineSectionFeed` will not eagerly fetch full detail or hero-sized artwork +for the first sixteen cards merely because rows entered composition. Startup +artwork warmup already has a bounded, paint-order budget; the Skyline will seed +the first marquee from aggregate section data and enrich around actual user +focus. + +Neighbor enrichment remains speculative but will be driven by the rested +focused identity rather than every intermediate D-pad position. It will: + +- operate on a small neighbor window; +- preserve request deduplication for the page lifetime; +- use cached item detail before network; +- cap network detail concurrency; +- cancel obsolete work when rested focus changes. + +Opening an item-detail screen keeps its existing network-first freshness +semantics. Cache-first behavior applies only to speculative marquee enrichment. + +### 6. Cache and dispatcher boundaries + +Room remains the profile/server-scoped source for offline Home and item-detail +snapshots. The fix will not alter cache schema or migration state. + +Home JSON encoding and Room access continue on the existing IO-owned startup +scope or repository suspending boundary. If tests show serialization executing +on the main dispatcher, the repository will explicitly move serialization to a +background dispatcher; otherwise no dispatcher abstraction will be added. + +## Error and Lifecycle Behavior + +- Failed speculative image/detail requests remain non-fatal and do not block + focus. +- A failed fallback section fetch leaves the prior complete Home cache intact. +- Switching server or profile continues to select the corresponding scoped + Room data. +- Leaving the Home composition cancels its speculative jobs. +- A process restart may refresh content, but it must not re-download cached + detail solely for prefetch. +- No tokens, origins, diagnostics, or production settings change. + +## Verification + +Behavioral tests will cover: + +- Down from each For You filter pill, Up return, repeated movement, and + loading/error/empty states; +- rapid and held Up sequences stopping on Home's first content row, followed + by a fresh Up press entering the selected top-menu item; +- For You selector Watchlist/Favorites opening the same inline presentation + and selected pill as in-page selection, while profile-menu routes remain + standalone; +- inline Home sections causing zero per-section fallback requests; +- missing sections being hydrated correctly with at most four concurrent + requests; +- partial hydration preserving cache safety; +- Skyline page entry producing no full-detail burst; +- rapid focus movement starting work only for the rested identity; +- cached detail avoiding network and bounded fallback when cache misses. + +Verification will include focused shared and Android TV unit tests, Android TV +debug/release compilation, and a controlled Shield run against +`lib.strm.cafe`. The device gate will compare cold-process and immediate warm +traversals using frame-jank percentiles and sanitized request counts. Success +requires: + +- For You Down enters its first visible recommendation card reliably; +- no page-entry sixteen-detail burst; +- no per-section N+1 when aggregate sections are inline; +- materially lower cold first-traversal request count and jank without + worsening warm traversal; +- no crash, ANR, authentication change, or server mutation. + +## Alternatives Rejected + +- **Server-side global row caps:** reduces content and masks client-side + duplicate work. +- **Longer splash or input suppression:** hides latency instead of removing it. +- **Blind cherry-pick of archived commits:** risks discarding newer response + compatibility and focus-restoration behavior. +- **Disabling all prefetch:** avoids the burst but makes every settled focus pay + full network and image latency. +- **Timeout or animation tuning:** does not address the measured cold request + fan-out. +- **Making all Watchlist/Favorites routes inline:** would change profile-menu + and deep-link semantics to solve a mismatch limited to the For You selector. +- **Debouncing all Up input:** would make ordinary row traversal feel laggy; + only the asynchronous row relocation and content/menu boundary need + sequencing. + +## Scope Boundaries + +This remediation does not change recommendation ranking, Home row composition, +server endpoints, production configuration, phone UI navigation, playback, +database schema, or authentication. It does not attempt a general Compose focus +framework or a complete image-loading redesign. diff --git a/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md b/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md index e6cc24e8c..d262a90b8 100644 --- a/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md +++ b/docs/superpowers/specs/2026-07-27-pr108-slice-f-watch-together-design.md @@ -93,7 +93,7 @@ already-applied command. ## Credentials -The Silo auth plugin already attaches same-origin `Authorization`, +The Prairie auth plugin already attaches same-origin `Authorization`, `X-Profile-Id`, and `X-Profile-Token` headers to websocket handshakes. Slice F removes the redundant access JWT from the websocket URL and verifies the header/query boundary with a real handshake test. diff --git a/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md b/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md new file mode 100644 index 000000000..a4ecd289c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-watch-together-user-menu-entry-design.md @@ -0,0 +1,214 @@ +# Watch Together User-Menu Entry — Design + +**Date:** 2026-07-27 + +**Status:** Approved design, awaiting written-spec review + +**Clients:** Android phone and Android TV + +## Goal + +Expose Watch Together from each client's user/profile menu without requiring a +title first. The new entry is a lightweight launch surface for hosting an empty +vote room, joining by code, or resuming the room already owned by the current +app session. The existing title-detail entry remains available and continues to +host with that title preselected. + +This product decision supersedes the older “hidden from user menus” direction +for Watch Together only. It does not expose rich administration or change any +other menu policy. + +## User experience + +Both profile menus add a **Watch Together** row in their content/action group. +When **Requests** is present, Requests remains first and Watch Together appears +immediately after it. When Requests is absent, Watch Together remains in the +same content/action group immediately before the divider that precedes settings +and account actions. + +Selecting the row closes the profile menu and opens a transient, dedicated +Watch Together entry surface: + +1. **Resume current room** appears first only when the current authenticated + app session owns a non-terminal room snapshot. +2. **Host a room** creates a room in vote mode with no selected content, then + opens the existing lobby. +3. **Join by code** opens the existing code-entry flow. A successful join uses + the existing room destination decision: a room with playable selected + content goes to the synchronized player; an unselected room goes to the + lobby. The existing host-alone rule may keep a host in the lobby to share + the invite. + +The owner of a top-level empty vote room is a full room participant. The owner +may suggest titles, vote, apply the existing host override to any suggestion in +the room (that is, any room-owned suggestion), and close the room for everyone. +These are the existing voting and host-authority capabilities exposed by the +current lobby and repository; the menu entry adds no new role, permission, +protocol message, or server behavior. + +On phone, the dedicated surface follows the existing modal-sheet idiom. On TV, +it follows the existing focused popup/dialog idiom. It is not a new persistent +Watch Together home or a replacement for the lobby. When Resume is available, +it receives initial TV focus; otherwise Host receives initial focus. Back, +dismissal, and busy-state input blocking match the existing entry surfaces. + +Resuming uses the same destination decision as joining. It does not create, +join, or reconnect through a second path; the selected destination adopts the +existing room through `RoomSession`. + +## Existing title-detail behavior + +Movie, episode, and existing playable-series detail affordances remain intact. +Their **Watch Together** action continues to open the title-bound entry surface: + +- **Host a room** creates the room and sets the current title/file selection. +- **Host a vote room** and **Join by code** retain their current behavior. +- Existing feature-policy and media-type gates remain unchanged. + +The new menu entry never invents a content ID, opens a title picker, or changes +the meaning of the detail action. + +## Architecture and state ownership + +This is an additional presentation entry point over the existing Watch +Together system: + +- `WatchTogetherRepository` remains the only owner of REST room operations, + room credentials, auth-scope validation, room snapshots, websocket state, + voting, suggestions, and server errors. +- `RoomSession` remains the process-scoped connection owner and the only path + for adopting, replacing, leaving, or closing a room connection. +- The existing phone and TV lobby, player, websocket, voting, and routing + surfaces remain authoritative. +- The entry controllers reuse the existing create/join state machines. The + title-free host action is explicitly + `CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)` and must not + call `setSelection`. +- Phone and TV may keep platform-specific composables and navigation types, but + they must derive equivalent entry actions and destination decisions from the + same room snapshot semantics. No second repository, room cache, websocket + client, or parallel “menu room” state is introduced. + +“Current room” means the valid, non-terminal room represented by the existing +process-scoped repository/session state for the current server and profile. +Identity transitions and room termination already clear that state. This +feature does not persist room credentials, discover rooms after process death, +or add a server-side “my active room” lookup. A successful create or join may +replace the current room through the existing generation/lease and +`RoomSession` replacement rules. + +## Host ownership and continuity + +Current server host semantics remain authoritative. The room creator remains +the host; the clients do not automatically transfer ownership or elect a new +host. + +Normal navigation and backgrounding preserve the process-scoped room while the +app process and authenticated profile remain alive. A temporary transport loss +uses the existing server grace period and repository reconnect behavior; a +successful reconnect within that behavior resumes the same room and host +authority. + +Logout, profile or server switch, and process death clear the client's local, +profile-scoped room state. After the host disconnects, the server may close the +room when its existing host-disconnect timeout expires. The clients do not +extend that timeout, transfer the host role, or reclaim a room after the server +has closed it. + +Accordingly, **Resume current room** is intentionally limited to the same +running app process, server, and authenticated profile. It is not account-level +room recovery. + +## Routing and lifecycle + +- Empty vote-room creation always routes to the existing lobby with its + `roomId`; the lobby establishes the existing session adoption and websocket + flow. +- Join and Resume route to the existing player only when the shared destination + rules say the snapshot is ready for playback; otherwise they route to the + existing lobby. +- Repeated taps while an operation is busy are ignored. The entry surface + cannot be dismissed while its create/join operation is in flight, matching + current behavior. +- Opening or dismissing the entry surface does not reset a current room. +- TV closes the profile dropdown before showing the popup so the dropdown and + popup never compete for D-pad focus. Dismissing the popup returns through the + shell's established focus-restoration path. +- Phone and TV consume one-shot navigation results before navigating, preventing + recomposition from launching the lobby or player twice. + +## Authentication, transport, and errors + +The menu action is available only in the authenticated profile shell and uses +the current server/profile scope. All calls continue through the repository and +existing network clients, preserving auth-scope transition barriers, cleartext +consent, room-token handling, reconnect behavior, and credential redaction. +Room credentials must not be copied into UI state, logs, or new route +parameters. + +Create and join failures remain on the entry surface using the existing +user-facing error mapping. A failed operation does not navigate or discard a +previously active room. Lobby/player websocket and terminal-room errors remain +owned by those existing surfaces. No new fallback transport or retry policy is +added. + +## Test strategy + +Focused automated coverage must establish: + +- **Phone menu visibility:** Watch Together is present in the authenticated + profile menu, invokes the entry surface, appears immediately after Requests + when Requests is present (or immediately before the settings/account divider + otherwise), and does not disturb account actions. +- **TV menu visibility and focus:** the row is present in the profile dropdown; + it follows Requests when Requests is present and otherwise ends the same + content/action group; opening it closes the dropdown; Resume is initially + focused when present, otherwise Host is; Back restores focus without leaking + focus behind the popup. +- **Empty host flow:** both clients issue one vote-mode create request, never + call `setSelection`, and navigate to the existing lobby with the returned + room ID. +- **Owner authority regression:** in that lobby, the owner remains able to + suggest, vote, exercise the existing host override on any room-owned + suggestion, and close the room for everyone, without a new protocol or + permission path. +- **Join routing:** code normalization/validation and errors remain intact; + selected rooms route to the synchronized player and unselected rooms route to + the lobby on both clients. +- **Resume routing:** Resume appears only for valid current-session state, + routes through existing snapshot rules without a create/join call, and + disappears after room termination or an identity change. +- **Host continuity:** navigation/backgrounding retains the same process-scoped + room; a temporary disconnect follows existing grace/reconnect behavior; and + logout, profile/server switch, or process death removes Resume and + local room state without transferring host ownership. +- **Parity:** the phone and TV entry surfaces expose the same action set and + state-dependent behavior, with platform-appropriate presentation. +- **Regression:** title-detail Watch Together remains visible under its current + policy/media gates and still hosts with the selected content/file rather than + creating an empty room. + +Existing repository, `RoomSession`, lobby, websocket, voting, player, auth, +cleartext-consent, and replacement-race tests remain part of the verification +gate. Device checks should cover phone touch interaction and TV D-pad +focus/back behavior; they do not replace the automated routing tests. + +## Out of scope + +- A title picker before room creation. +- A new full Watch Together home, room browser, or room history. +- Automatic host transfer, ownership election, or original-host reclaim. +- Cross-process room restoration or a new server endpoint. +- Changes to room protocol, websocket ownership, voting rules, player sync, + cleartext policy, or authentication. +- Removal or redesign of the title-detail Watch Together action. +- Changes to non-Android clients. + +## Acceptance criteria + +The feature is complete when an authenticated phone or TV user can open Watch +Together from the profile menu, host an empty vote room into the existing +lobby, join through the existing code flow, and resume current in-process room +state when available; both clients behave equivalently, TV focus is +deterministic, title-detail hosting still preselects its title, and no parallel +room/session architecture or credential exposure is introduced. diff --git a/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md b/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md new file mode 100644 index 000000000..ce2915649 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-active-header-focus-editorial-hero-design.md @@ -0,0 +1,207 @@ +# Android Active Header Focus and Editorial Hero Design + +**Date:** 2026-07-28 +**Status:** Approved for implementation planning +**Scope:** Android TV header focus; Android phone and TV browsing heroes + +## Context + +External testing of the TV navigation work in PR #126 confirmed that cold +navigation and For You behavior improved. It also exposed two related +presentation defects: + +1. Pressing Up from the first content row can focus Search instead of the + currently active top-menu destination. Pressing Back from the same page + correctly focuses the active destination. +2. TV browsing heroes prioritize technical stream badges such as resolution, + HDR, and audio format ahead of editorial information. Phone library heroes + already avoid those technical fields, but their generic type/year/rating + chips omit runtime, content classification, and genre. + +Issue #78 asks for richer title metadata, but its current wording refers to the +player. This change applies the approved behavior to Android phone and TV +browsing heroes only and does not change either player's or item-detail +surface. + +## Goals + +- A fresh Up press from the first content row focuses the active top-menu + destination on Home, each library section, For You, and Calendar. +- Search receives this focus only when Search is the active route. +- Preserve the existing held-Up boundary: a held key stops on the first + content row and requires a fresh Up press before entering the menu. +- Phone and TV browsing heroes describe the title using consistent editorial + metadata instead of delivery characteristics or generic media-type labels. +- Keep TV focus behavior inside the existing shared TV shell. +- Keep hero presentation inside the existing TV marquee model and phone + featured-carousel metadata helper. + +## Non-goals + +- No server, API, database, or payload changes. +- No player-overlay, playback-settings, or item-detail redesign. +- No phone Home hero: Android phone Home intentionally renders rows without a + billboard, so phone changes apply only to the existing Library Recommended + featured carousel. +- No changes to stream selection, transcoding, subtitle behavior, or technical + metadata availability outside the browsing hero. +- No new user preference or display toggle. +- No Apple-client changes. + +## Focus Behavior + +The shell remains the single owner of content-to-menu focus transitions. When +the active content feed reports that a fresh Up press has reached its first +row, the shell requests the menu destination derived from the current route: + +- Home → Home +- Movies, Series, Music, or Audiobooks → the matching library-type pill +- For You → For You +- Calendar → Calendar +- Search → Search + +The request must target the active destination explicitly and complete through +the existing menu focus-request mechanism. It must not depend on Compose +geometric focus search or on the physical proximity of Search to the content +card. The same mapping is used by Back-to-menu behavior so the two entry paths +cannot drift. + +Repeated Up events at the first content row remain consumed. Off-screen +previous-row relocation and ordinary row-to-row Up movement are unchanged. +Panel preview, profile menu, Left/Right menu traversal, and Down-to-content +behavior are unchanged. + +## Shared Browsing Hero Metadata + +The TV browsing marquee stops rendering resolution, HDR, and audio-format +badges. Technical overlay data remains in the model for other consumers but is +not converted into hero badges. The phone featured carousel continues to avoid +technical delivery data. + +Both Android clients use the following ordered editorial fields when present: + +### Movies and other non-episode titles + +1. Release year +2. Runtime +3. IMDb rating +4. Primary genre + +Content classification, such as PG-13, remains as the only badge adjacent to +that ordered metadata line. + +### Episodes + +1. Season and episode token, such as `S2 E7` +2. Episode name +3. Runtime +4. Air date when available from existing enrichment +5. Rating when present + +Content classification, such as TV-MA, remains as the only badge adjacent to +that ordered metadata line. + +The series name remains the episode hero title, with the episode name in the +metadata line. Missing values are omitted without placeholders or redundant +separators. Existing synopsis, cast enrichment, artwork, cache-first loading, +and crossfade behavior remain unchanged. + +The implementation may keep air date and cast on the existing quieter detail +line if the current payload/enrichment boundary does not expose air date early +enough for the primary metadata line. It must not add another detail request or +delay first paint to rearrange those fields. + +### TV presentation + +TV retains its existing badge-plus-metadata-line layout. Content +classification is its only hero badge; the remaining fields form the ordered +single metadata line. The existing episode series title, episode-name +placement, synopsis, and quieter air-date/cast enrichment remain unchanged. + +### Phone presentation + +Phone applies the editorial fields to the existing featured carousel used on +Library Recommended pages: + +- Remove the generic `Movie` or `Episode` type chip. +- Preserve the existing series eyebrow and title treatment, so an episode + continues to show the series name and episode name without duplication. +- Present the ordered metadata as compact chips using the existing chip visual + style. +- Allow chips to wrap onto a second line on narrow phones rather than clipping + or forcing horizontal scrolling. +- Do not add a hero to phone Home or change item-card overlays. + +Phone does not perform detail enrichment in the carousel. Air date remains +absent when it is not carried by the existing section payload; no new request +is introduced to obtain it. + +## Data Flow and Boundaries + +- `TvMainShell` derives the active root destination from the current route. +- `TvShellFocusState` carries the explicit menu-focus request. +- `TvTopMenuBar` resolves that destination to its existing `FocusRequester`. +- `TvSkylineSectionFeed` retains ownership of row traversal and the held-Up + boundary, but does not choose a menu target. +- `TvMarqueeContent.from` converts the existing `SectionItem` payload into + ordered editorial metadata. +- Existing detail enrichment may continue to add air-date/cast information + without blocking or re-fetching on focus. +- `FeaturedCarousel.metadataChips` converts the same existing `SectionItem` + fields into ordered phone chips. +- The phone carousel layout owns responsive wrapping without changing its + paging, play, More Info, or backdrop behavior. + +No parallel focus coordinator, marquee data source, or phone detail fetch is +introduced. + +## Error and Edge Handling + +- If the active route has no top-menu destination, preserve its existing + route-specific behavior rather than silently selecting Home. +- If a requested library pill is temporarily absent, use the existing safe + requester fallback and do not crash. +- Invalid, non-finite, zero, negative, blank, or unavailable metadata is + omitted. +- Valid ratings and runtimes keep the existing formatting and rounding rules. +- Removing technical badges must not create an empty visual row; the row is + omitted when no editorial badge or metadata value exists. +- Phone wrapping is bounded to two lines of metadata chips; it must not cover + the carousel actions or change the carousel's page height. + +## Verification + +Focused tests should cover: + +- route-to-menu target mapping for every root destination and Search; +- the held-Up first-row boundary remains unchanged; +- TV movie metadata ordering and omission of resolution/HDR/audio; +- TV episode metadata ordering, series/episode naming, runtime, rating, and + content-classification handling; +- phone movie and episode chip ordering, removal of the generic type chip, + runtime/content-classification inclusion, and no technical delivery fields; +- absent, zero, negative, NaN, or infinite ratings and runtimes without empty + chips or dangling separators. + +Regression verification should include the complete Android phone and TV unit +suites, supply-chain checks, and both minified release assemblies. A TV +emulator or external-device smoke should verify: + +- Up from the first row lands on the active pill across at least Home, a + library section, For You, and Calendar; +- Search is selected only on the Search route; +- held Up stops at the first content row; +- representative movie and episode heroes contain editorial metadata and no + resolution/HDR/audio badges. + +A phone emulator smoke should verify that representative movie and episode +featured-carousel pages show the approved chips, wrap without overlapping +actions on a narrow viewport, and retain Play, More Info, paging, and artwork. + +## Rollout + +Implement this as a focused follow-up on PR #126 while it remains open. The +shared hydration/navigation-performance commits already in PR #126 are its +accepted baseline and remain unchanged; this follow-up does not need to split +or reclassify them. Update both tester APKs after automated verification. Do +not merge or deploy as part of implementation. diff --git a/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md b/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md new file mode 100644 index 000000000..0182f812c --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-phone-library-chrome-inset-design.md @@ -0,0 +1,95 @@ +# Android Phone Library Chrome Inset Design + +Date: 2026-07-28 + +## Goal + +Match the current iOS Libraries tab behavior on Android phone: the library +selector, actions, and Recommended / Browse / Collections tabs remain fixed at +the top, while scrollable library content occupies only the space below that +chrome. Library rows, posters, and hero content must not scroll visibly behind +the menu controls. + +This change is phone-only. It does not alter Android TV, standalone Browse or +Collections routes, Home, server APIs, navigation semantics, or the profile +menu's actions. + +## Current Behavior and Root Cause + +`LibrariesScreen` draws its tab content full-screen and then draws +`LibrariesFloatingChrome` afterward in the same root `Box`. The chrome uses a +partially transparent gradient. Recommended and Collections reserve initial +top runway inside their scroll containers, but that runway scrolls away; later +content therefore remains visible beneath the selector and tab controls. +Browse already uses fixed outer padding and does not exhibit the same underlap. + +The iOS implementation uses a top `safeAreaInset`, which reduces the space +offered to every library tab's scroll view. Its content therefore starts below +the shared chrome and cannot pass behind it. + +## Considered Approaches + +1. **Reserved top chrome slot — selected.** Keep the existing custom Android + chrome and full-screen backdrop, but place the chrome and tab-content + viewport in a vertical layout. The chrome consumes its measured height and + the content viewport receives the remaining height. This directly matches + the layout semantics of iOS `safeAreaInset` without replacing existing + controls. +2. **Stronger translucent scrim.** Leave content underneath but obscure it more + once scrolling starts. This reduces visual noise but does not fix the + reported behavior and remains inconsistent across tabs. +3. **Material `Scaffold.topBar`.** This also reserves layout space, but would + introduce a larger structural and visual migration for a custom chrome that + already works. It is unnecessary for this focused correction. + +## Layout Design + +The root retains its full-screen background and optional Recommended hero +backdrop. Above that background, a vertical foreground layout owns: + +1. `LibrariesFloatingChrome`, including the status-bar inset, library selector, + action buttons, profile popup anchor, subtab selector, and its bottom space. +2. A clipped, weighted content viewport containing loading, error, empty, + Recommended, Browse, or Collections content. + +Because the chrome participates in measurement rather than overlaying the +viewport, no tab needs a hard-coded `LibrariesChromeContentHeight` runway. +Remove the duplicate status-bar/chrome top padding from Recommended, Browse, +and Collections content. Each tab must still preserve its own internal spacing +and the existing bottom-chrome inset so its last item remains reachable. + +The optional hero artwork may continue painting behind the entire screen, +including behind the chrome. Only interactive and editorial scroll content is +confined below the chrome. This preserves the visual relationship between the +hero and header without allowing text or cards to pass under menu controls. + +## Interaction and State + +- Library switching, tab switching, search, Requests, Watch Together, + settings, profile/server switching, and sign-out behavior remain unchanged. +- Recommended retains its scroll position and hero selection behavior. +- Browse and Collections retain their filters, pagination, grids, and empty / + error states. +- The profile popup remains anchored to the profile button. Opening or closing + it must not change the content viewport or reset scroll position. +- System status-bar and display-cutout insets are consumed exactly once by the + chrome. + +## Verification + +Tests and validation must cover: + +- Structural/source coverage that all three canonical library subtabs share + one reserved chrome/content boundary. +- Recommended and Collections no longer contain scrollable top runway used to + clear the overlay. +- Browse no longer applies a second chrome/status-bar top inset. +- Loading, error, and empty states render below the chrome. +- Existing phone hero metadata and menu-order tests remain green. +- Phone release assembly succeeds. +- On a dedicated phone emulator when available: scroll Recommended, + Browse, and Collections; confirm content disappears at the chrome boundary, + the fixed chrome remains usable, and the profile popup does not move or + expose scrolling content beneath its anchor. + +Physical devices are excluded unless separately authorized. diff --git a/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md b/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md new file mode 100644 index 000000000..60aa34ab7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-player-system-brightness-design.md @@ -0,0 +1,53 @@ +# Android Player System Brightness Design + +## Decision + +The Android phone video player will stop overriding window brightness. Android +system and adaptive brightness remain authoritative while playback is open. + +## Current problem + +`PlayerGestureHandler` assigns a vertical drag beginning in the left 88 dp edge +to brightness control. The first drag converts the system-managed window value +into a fixed per-window brightness and later drags continue changing that +override. While the player is visible, Android's normal brightness control +appears ineffective because the window override wins. + +## Behavior + +- Remove the left-edge brightness drag mode and all writes to + `WindowManager.LayoutParams.screenBrightness`. +- A vertical drag beginning in the left edge performs no brightness action. +- Preserve the right-edge volume gesture. +- Preserve center swipe-down dismissal, double-tap seeking, pinch aspect-mode + changes, control toggling, and temporary fast-forward. +- Preserve `FLAG_KEEP_SCREEN_ON` while playing or buffering; it prevents sleep + and is independent of brightness. +- Do not change Android TV behavior, system settings, permissions, or adaptive + brightness. + +## Implementation + +Remove the `Brightness` member from `VerticalDragMode`, the +`adjustBrightness` helper, and their now-unused Android window imports. Retain +the left/right edge boundary only for routing the right edge to volume and the +center region to dismissal. The left edge resolves to `None`, so it cannot +accidentally dismiss playback. + +## Verification + +Automated coverage will prove that the mobile gesture implementation contains +no window-brightness mutation while retaining volume and dismissal routing. +Focused mobile player tests and the phone release assembly must pass. A Pixel +smoke check will confirm that Android's brightness control remains effective +during playback and that right-edge volume and center dismissal still work. +The Shield will not be installed or modified. + +## Success criteria + +- Opening and using the phone player never creates a per-window brightness + override. +- Android system/adaptive brightness continues controlling the display during + playback. +- Existing non-brightness player gestures and keep-screen-awake behavior do not + regress. diff --git a/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md b/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md new file mode 100644 index 000000000..095a042b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-android-subtitle-aspect-reconciliation-and-phone-sizing-design.md @@ -0,0 +1,161 @@ +# Android Subtitle Aspect Reconciliation and Phone Sizing Design + +## Context + +PR #127 makes the shared Android subtitle canvas follow the displayed video +area in Fit, Fill, and Stretch modes. On a physical Pixel running the PR head, +changing Fill to Fit reproduced a remaining defect: subtitle cues stayed +vertically displaced and were clipped below the display. Depending on the cue +and dialogue gap, subtitles appeared enabled but absent. Playback, subtitle +selection, cue delivery, and video decoding remained healthy, and subsequent +captures showed the same track rendering normally in Fill mode. + +The same device validation also showed that the Android default subtitle size +is too small on a phone. The shared default is `Large`, but Android maps it to +`32 / 720` of the subtitle canvas, below Media3's default fractional size and +smaller than the shared model's nominal 56-point Large value. + +## Goals + +- Keep subtitle cues fully visible and correctly centered after every supported + aspect-mode transition. +- Make every phone subtitle-size preset legible at normal handheld viewing + distance while preserving the relative steps between presets. +- Preserve existing Android TV subtitle sizing. +- Preserve subtitle selection, cue styling, authored positioning, libass/ASS, + bitmap subtitle, letterbox, and title-safe behavior. + +## Non-goals + +- Changing subtitle tracks, server subtitle processing, or playback protocols. +- Changing the shared preset names or persisted subtitle appearance schema. +- Changing Android TV's existing font-size scale. +- Reimplementing Media3's aspect-ratio measurement algorithm. +- Adding unbounded frame callbacks, polling, delays, or timeout-based layout + workarounds. +- Reworking Android TV's existing subtitle remount transaction architecture. + +## Design + +### Stable post-layout reconciliation + +`SubtitleVideoRectSync` remains the single owner of subtitle-view geometry. An +aspect change may expose old `exo_content_frame` bounds during the immediate +Compose `AndroidView.update` callback. Mobile Fit and Stretch must not convert +those transitional bounds into fixed pixel dimensions: they set the subtitle +child to `MATCH_PARENT` with zero margins, allowing the Media3 content frame to +remeasure the subtitle child automatically. + +Mobile Fill maps to Media3 Zoom and still needs a parent-local visible crop +rectangle because its content frame extends beyond the viewport. That mode +continues to reconcile from the measured `exo_content_frame`. + +If the snapshot changes during that traversal, one further pre-draw +reconciliation is scheduled. The operation is generation-bound and capped at +two post-layout passes for each explicit sync request. A newer request replaces +the older generation, repeated requests coalesce, and detach/dispose cancels +pending work. No callback remains installed after the rectangle is stable or +the bound is reached. At the bound, the latest measured rectangle remains +applied; the permanent content-frame layout listener still handles any later +real layout change without spinning. + +The sync continues using Media3's measured `exo_content_frame` instead of +duplicating its aspect calculations. Fixed geometry remains expressed in the +subtitle view's parent-local coordinate space. Television title-safe and +letterbox insets retain their existing fixed-rectangle behavior; the +`MATCH_PARENT` shortcut applies only when both insets are absent. + +### Phone-only subtitle scaling + +Font-size conversion will accept an explicit Android presentation class: +`Phone` or `Television`. Phone uses a 1.125 multiplier over the current +fractions: + +| Preset | Phone | Television | +| --- | ---: | ---: | +| Small | 22.5 / 720 | 20 / 720 | +| Medium | 29.25 / 720 | 26 / 720 | +| Large | 36 / 720 | 32 / 720 | +| XLarge | 45 / 720 | 40 / 720 | +| XXLarge | 54 / 720 | 48 / 720 | + +The phone and TV dependency-injection modules construct `SubtitleManager` with +their fixed presentation class. The persisted preset remains unchanged, so an +existing `Large` preference becomes more legible on phone without a migration +and retains its current appearance on TV. + +Fractional sizing remains relative to the active subtitle canvas. It therefore +continues to respond naturally to orientation and displayed-video bounds. + +### Initial subtitle restore settlement + +On phone, restoring a persisted mounted subtitle must not treat Media3 +`Player.STATE_READY` as proof that its text-track catalog has settled. Media3 +can report ready while publishing an intermediate non-empty text-track +snapshot; failing the restore against that first snapshot produces a transient +error even though the requested track appears moments later. + +The phone player will follow the existing TV settlement rule: the first +non-empty text-track snapshot is provisional, a changed snapshot restarts +settlement, and only a repeated identical non-empty snapshot may prove that a +requested track is missing. A successful identity match still commits +immediately. The existing bounded mobile mount timeout remains the terminal +fallback when no stable success arrives. + +Android TV already implements this rule through +`TvSubtitleSnapshotSettlementTracker` and `SubtitleRemountReselection`; its +production path remains unchanged and receives focused regression coverage. + +## Correctness and lifecycle constraints + +- Immediate synchronization remains available for already-stable layouts. +- Reconciliation reads the current player, resize mode, video size, and content + frame on every pass; it must not apply a rectangle captured for an older + mode. +- At most one pre-draw listener exists per `PlayerView`. +- Detaching the view removes listeners and prevents late mutation. +- A replaced player cannot receive or influence later reconciliation. +- Existing cue forwarding and libass overlay attachment remain unchanged. + +## Testing + +Unit and mounted Robolectric coverage will prove: + +- Fill to Fit and Stretch to Fit settle to the final parent-local rectangle + without retaining a cropped top/left margin. +- Fit to Fill and rapid Fit/Fill/Stretch changes use the latest mode. +- A changed content-frame snapshot receives the bounded second pass. +- Stable geometry uses no extra pass, repeated explicit syncs coalesce, and + detach cancels pending work. +- Every phone preset is exactly 1.125 times its TV fraction. +- The default `Large` preset resolves to `36 / 720` on phone and `32 / 720` on + TV. +- Phone and TV construction paths select their intended presentation class. +- Mobile Fit and Stretch apply `MATCH_PARENT` dimensions and zero margins when + no title-safe or letterbox inset is configured. +- A stale Zoom crop followed immediately by Fit cannot retain its top/left + offsets, even before the Media3 parent completes its new layout. +- A restored phone subtitle cannot fail on the first non-empty Media3 + text-track snapshot, and a changed snapshot must stabilize again before it is + terminal. +- A matching restored phone subtitle commits as soon as it appears; a track + that never appears still fails through the existing bounded timeout. +- TV's first-snapshot and changed-snapshot settlement regressions remain green. + +Focused shared, phone, and TV subtitle tests will run first, followed by the +full unit suite and phone/TV release assemblies. Physical validation will use +the Pixel only and exercise Fit, Fill, Stretch, rapid transitions, multi-line +cues, and cue gaps. The Shield will not be installed or modified without +separate authorization. + +## Success criteria + +- The reproduced Fill-to-Fit cue is fully visible immediately after the sheet + closes and remains visible across subsequent cues. +- No supported aspect transition leaves stale subtitle margins or dimensions. +- Default phone subtitles sit between the original undersized build and the + rejected 1.25× build while all phone presets remain ordered and selectable. +- Restarting playback with a persisted subtitle does not show a transient mount + error while Media3 is still publishing text tracks. +- TV output, persistence, selection, styling, and subtitle formats show no + regression in automated verification. diff --git a/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md b/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md new file mode 100644 index 000000000..e3d00df42 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-pr-126-coderabbit-remediation-design.md @@ -0,0 +1,73 @@ +# PR #126 CodeRabbit Remediation Design + +## Goal + +Resolve every substantiated CodeRabbit finding on PR #126 without changing +deliberate request-sharing behavior or adding unrelated release scope. + +## Scope and approach + +The correction remains on `fix/tv-for-you-cold-navigation` and follows the +existing phone, TV, shared-repository, and Watch Together boundaries. Behavioral +changes are covered by focused regression tests before production changes. +Documentation and test-only cleanup remain separate from behavioral assertions +so failures identify the responsible correction. + +The implementation will: + +- Forward the shell Up fallback from the Alphabet library tab to its alphabet + rail, matching the Browse tab. +- Advance the For You entry request with a `null` selection whenever the + top-level For You destination is explicitly selected, so Watchlist or + Favorites state is not retained. +- Make the initial TV marquee seed sensitive to section identity while + preserving the rule that settled real focus always wins over a page-entry + seed. A refresh may update a stale page-entry identity, but must not replace + focused content. +- Accept IMDb ratings only when finite and within `(0, 10]` on both phone and + TV. Invalid rating and invalid duration cases will be tested independently. +- Make Watch Together delivery-key nullability explicit at the latch and player + reporting call sites without changing attach, cadence, or delivery semantics. +- Remove machine-specific paths from the committed SDD report and correct the + stale plan references and wording identified by CodeRabbit. +- Apply small behavior-preserving helper extractions only where they directly + address a review comment and reduce duplicated validation or test setup. +- Keep the existing shared in-flight recommendation request behavior. A + superseded caller may discard its result, but it must not cancel work shared + with another caller. + +## Finding disposition + +The Calendar fallback finding is already corrected on the current PR head and +will receive verification rather than another code change. The claimed +`RoomDeliveryLatch` compilation failure is disproven by both local compilation +and hosted Unit Tests; explicit null binding will nevertheless make the +invariant visible and remove the ambiguity that prompted the comment. + +Generic requests to split the PR or increase repository-wide docstring coverage +are not defects in the changed behavior and are outside this remediation. + +## Testing + +Focused tests will cover: + +- Alphabet and Browse fallback forwarding parity. +- Explicit For You root selection after Watchlist and Favorites entry. +- Marquee reseeding for a changed row identity, including protection of + real-focused content. +- Phone and TV rating upper bounds. +- Mixed valid-duration/invalid-rating and invalid-duration/valid-rating cases. +- Nullable and mismatched Watch Together delivery keys. + +After focused RED/GREEN cycles, the relevant phone, TV, shared, and +Android-shared unit suites will run, followed by the repository supply-chain +policy checks and phone/TV release compilation used by this branch. No APK will +be installed or deployed as part of this remediation. + +## Completion criteria + +The branch must be clean, all focused and full verification commands must pass, +an independent reviewer must report no unresolved critical or important issue, +and PR #126 must accurately reflect the added correction commit and current +check state. Proven false positives will be documented rather than addressed by +semantic changes. diff --git a/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md b/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md new file mode 100644 index 000000000..ecdec20bf --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-subtitle-aspect-recenter-design.md @@ -0,0 +1,81 @@ +# Subtitle Aspect-Mode Recentring Design + +## Problem + +On Android phone, changing video gravity from Fit to Fill or Stretch can leave +the subtitle layer using the previous fitted-video geometry. The visible video +then fills the player while subtitles remain positioned against stale bounds, +so ordinary centred subtitles appear off-centre. + +The phone and TV players both use `SubtitleManager` to align Media3, libass, and +bitmap subtitle rendering with the visible video viewport. Phone is the +confirmed reproduction. TV must receive the same shared correction because it +uses the same geometry owner, while its existing player wiring must be verified +independently. + +## Intended Behaviour + +- The subtitle canvas follows the final visible video viewport after every + aspect-mode change. +- Fit aligns the canvas with the fitted video rectangle. +- Fill and Stretch align the canvas with the full visible player viewport. +- Switching modes repeatedly cannot retain geometry from an earlier mode. +- Ordinary centred SRT/WebVTT cues remain centred in the new canvas. +- Authored ASS/SSA and PGS positions remain relative to the canvas. The client + does not rewrite individual cue positions or force every cue to centre. +- Existing letterbox detection, title-safe insets, subtitle appearance, and + transactional subtitle selection remain unchanged. + +## Design + +`SubtitleManager` remains the single geometry owner. Aspect-mode consumers +continue setting `PlayerView.resizeMode` and requesting a subtitle-bound sync. +The synchronizer must resolve bounds from the current resize mode and the +post-layout content frame, and it must schedule one bounded post-layout +reconciliation when a resize request can still expose the previous frame. + +The reconciliation is idempotent: it computes the desired rectangle, compares +it with the current subtitle layout parameters, and writes only when dimensions +or offsets differ. It does not introduce polling, arbitrary delays, or a second +subtitle renderer. + +The shared correction applies to both phone and TV. Platform screens retain +their existing aspect-mode mappings: + +- Phone Fill maps to Media3 Zoom; Stretch maps to Media3 Fill. +- TV Zoom and Stretch retain their existing mappings. + +## Lifecycle and Safety + +Any posted reconciliation is owned by the existing `PlayerView` synchronizer. +It is cancelled or made inert when the view detaches or the synchronizer is +disposed. A stale callback must not update a detached or replacement player +view. + +The change must not alter playback state, track selection, subtitle timing, +network requests, or persisted settings. + +## Verification + +Automated regression coverage will prove: + +- Fit computes fitted-video bounds. +- Fit to Fill and Fit to Stretch settle on full-viewport bounds. +- repeated mode switching does not retain stale offsets or dimensions; +- authored cue coordinates are not rewritten; +- phone and TV aspect-mode update paths both request shared subtitle + reconciliation; +- disposal prevents a delayed reconciliation from mutating a detached view. + +Focused shared, phone, and TV unit tests will run before both release variants +are assembled. On-device phone verification will switch among Fit, Fill, and +Stretch with a centred text subtitle and confirm visual recentring. TV will be +verified through focused tests and compilation; no Shield installation is +required unless separately requested. + +## Out of Scope + +- Changing subtitle appearance, size, vertical presets, or delay. +- Repositioning authored ASS/SSA or bitmap cues. +- Server, protocol, transcoding, or subtitle-format changes. +- Replacing Media3 or libass subtitle rendering. diff --git a/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md b/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md new file mode 100644 index 000000000..d16d616cd --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-phone-director-credit-and-review-hardening-design.md @@ -0,0 +1,71 @@ +# Phone Director Credit and Review Hardening Design + +**Date:** 2026-07-29 +**Target:** PR #129 (`feat/tv-detail-director-credit`) +**Base behavior:** PR #128 is already on `main`; PR #129 adds the TV movie-director credit. + +## Goal + +Bring PR #129's movie-director credit to Android phone with exact phone/TV parity, and close the concrete review gaps found while auditing PRs #128 and #129. The change must remain Android-client-only and must not alter server APIs, catalog models, or production purge behavior. + +## User-visible behavior + +For movie detail pages on both phone and TV, show one muted, single-line credit: + +`Directed by Name One, Name Two` + +The credit appears directly below the synopsis and optional description translation, and directly above the existing facts row. It is hidden for series, seasons, episodes, audiobooks, and other item types. + +Director selection uses crew entries whose trimmed job is exactly `Director`, case-insensitively. Names are trimmed, blank names are removed, duplicate names are removed while preserving server order, and at most three names are shown. Jobs such as `Director of Photography` do not qualify. The existing title-detail layout, cast/crew section, actions, and navigation remain unchanged. + +## Architecture + +Move the pure director-selection and text-formatting rule to a small Android-shared presentation helper used by both `androidApp` and `androidTvApp`. This gives the two Android clients one rule without moving display policy into the shared catalog model. The phone and TV hero composables retain platform-specific typography and layout, but receive the same nullable formatted string. + +The phone `DetailHero` gains an optional director-credit parameter. `MovieDetailContent` supplies the shared helper result; series and other phone detail paths do not. TV replaces its local extraction implementation with the shared helper and preserves its existing rendering. + +## Review hardening + +### PR #129 director coverage + +Focused pure tests will cover: + +- movie-only behavior; +- exact, case-insensitive `Director` job matching; +- trimmed names; +- blank-name removal; +- stable de-duplication; +- the three-name cap; and +- no credit when no qualifying director exists. + +Phone and TV source/wiring tests will ensure both movie hero paths use the shared credit and that the existing TV and phone placement remains between synopsis/translation and facts. + +### PR #128 runtime coverage + +Existing phone and TV hero-metadata tests will be extended to prove that: + +- positive catalog `runtime` minutes take precedence over playback `durationSeconds`; and +- absent or invalid catalog runtime falls back to `durationSeconds`. + +This adds regression coverage only; PR #128's shipped production behavior is not redesigned. + +### Hosted purger-test race + +The failed hosted Unit tests check is a baseline test-harness race, not a PR #129 production regression. The test currently asserts that the removed server's row still exists after the second purge may already have deleted it. + +The test will gain explicit deferred gates around the second purge's row deletion. It will wait until the second pass is demonstrably selected, assert the row still exists while deletion is held, release deletion, then assert the row is removed. This makes the intended snapshot/second-pass ordering deterministic without widening a timeout or changing `OrphanedServerDataPurger` production semantics. + +## Verification + +Verification will include the focused director, phone runtime, TV runtime, and purger tests; the relevant module unit-test tasks; Android phone and TV compilation; supply-chain policy checks required by the repository; and a diff audit confirming no server/protocol or production purge changes. + +PR #129 will be updated but not merged. + +## Non-goals + +- Director credits for series, seasons, episodes, or audiobooks. +- Changes to server crew metadata or API contracts. +- Changes to the full cast/crew section. +- New navigation or detail-page structure. +- Production purge behavior changes. +- Timeout increases or retries that conceal the hosted test race. diff --git a/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md b/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md new file mode 100644 index 000000000..c91f682f6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-remove-tv-detail-starring-overlay-design.md @@ -0,0 +1,85 @@ +# Remove Android TV Detail Starring Overlay + +**Date:** 2026-07-29 +**Status:** Approved + +## Purpose + +Remove the floating `Starring …` credit from the upper-right of the Android TV +item-detail hero. The credit duplicates the full cast and crew section lower on +the same page, competes with backdrop artwork, and remains difficult to make +consistently legible across arbitrary imagery. + +The resulting hero should preserve a clearer hierarchy: + +1. title and primary metadata; +2. synopsis and optional translation; +3. movie-only `Directed by …` credit; +4. facts and actions. + +## Scope + +- Stop deriving and passing `starringText` into `TvDetailHero`. +- Remove the `starringText` parameter and its upper-right text overlay from + `TvDetailHero`. +- Remove the now-unused `TvDetailMetadata.starringText` helper and its focused + unit coverage. +- Preserve `TvCastCrewSection` on the TV detail page as the complete cast and + crew presentation. +- Preserve the movie-only `Directed by …` credit on Android TV and phone. +- Preserve the existing title-detail content, actions, focus behavior, hero + gradients, synopsis, translation, and fact tokens. + +## Client Impact + +This is intentionally an Android TV-only visual simplification. Android phone +does not have the floating upper-right starring overlay, so no phone production +UI changes are required. Both clients retain their existing cast and crew +content and the shared movie director-credit behavior. + +No server, API, model, persistence, navigation, playback, or Apple-client +changes are included. + +## Behavior + +For every supported TV item type, the upper-right hero area is left to the +backdrop artwork. Cast data remains available by scrolling to the existing cast +and crew section. Empty or absent cast data behaves exactly as before outside +the removed overlay. + +No replacement shadow, glyph halo, localized vignette, panel, or inline +`Starring …` row is introduced. This avoids adding visual machinery for +duplicated metadata. + +## Implementation Boundary + +The change should remain within the TV detail presentation and its focused +metadata tests: + +- `androidTvApp/.../detail/TvItemDetailScreen.kt` +- `androidTvApp/.../detail/TvDetailHero.kt` +- `androidTvApp/.../detail/TvDetailMetadata.kt` +- `androidTvApp/.../detail/TvDetailMetadataTest.kt` + +If source-level tests directly assert the removed parameter or call site, update +them narrowly. Do not refactor unrelated hero layout or metadata formatting. + +## Verification + +- Focused TV detail metadata/source tests confirm the starring helper and hero + wiring are gone while director-credit ordering remains intact. +- Android TV unit tests pass. +- Android TV debug and release compilation succeeds. +- A TV/emulator detail-page smoke check confirms: + - no floating upper-right starring credit; + - cast and crew remains available below; + - `Directed by …` remains between synopsis/translation and facts for movies; + - hero focus, actions, and scrolling are unchanged. + +## Acceptance Criteria + +- No `Starring …` overlay appears in the Android TV detail hero. +- The existing cast and crew section is unchanged and remains reachable. +- The movie director credit remains unchanged on phone and TV. +- No substitute contrast treatment or actor-credit placement is added. +- No phone, server, protocol, playback, or persistence behavior changes. diff --git a/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md b/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md new file mode 100644 index 000000000..71ea522e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-section-cache-profile-switch-test-race-design.md @@ -0,0 +1,47 @@ +# Section Cache Profile-Switch Test Race Design + +**Date:** 2026-07-29 +**Status:** Approved + +## Problem + +The post-merge `main` workflow failed +`SectionRepositoryCacheTest.homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile` +with `expected Old, got New`. + +The shared `gatedRepository` test helper currently: + +1. increments the request counter; +2. completes `requestEntered`, which can resume the waiting test coroutine; +3. derives the response body from the mutable counter. + +The resumed coroutine can start request two between steps 2 and 3. Request one +then observes the second request's counter value and receives the wrong fixture. +This is a test-harness scheduling race; the failed branch and merge commit have +identical Git trees, and PR #130 did not change shared production or test code. + +## Design + +Capture each request's response body immediately after `onRequest()` and before +completing `requestEntered`. Only then expose the request to the waiting test and +block on `releaseResponse`. + +This assigns the fixture deterministically to the request that incremented the +counter while preserving the helper's existing request-entry and release gates. + +## Scope + +- Modify only `gatedRepository` in + `shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt`. +- Do not change `SectionRepository`, identity-transition behavior, production + code, timeouts, worker counts, or application binaries. +- Do not weaken or remove the profile-isolation assertions. + +## Verification + +- Use the hosted failure as the RED evidence: request one received `New`. +- Run the exact failed test repeatedly under `--max-workers=2 --rerun-tasks + --no-daemon`. +- Run the complete `SectionRepositoryCacheTest` class. +- Run the complete shared debug unit suite under the hosted two-worker shape. +- Confirm the final diff is test-only and whitespace-clean. diff --git a/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md b/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md new file mode 100644 index 000000000..c30f01ab1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-specials-first-season-order-design.md @@ -0,0 +1,69 @@ +# Android Specials-First Season Order Design + +## Goal + +Match the web client’s season-selector presentation on Android phone and TV: + +`Specials, Season 1, Season 2, …` + +The visible label remains **Specials**. Android must not relabel it as +“Season 0.” + +## Scope + +- Android phone and Android TV series-detail season selectors. +- The existing shared `List.sortedForDisplay()` ordering contract. +- Initial season selection when opening a series. +- Focused unit tests for ordering and selection behavior. + +The Prairie server, web client, Apple clients, API schema, and playback sequencing +are unchanged. + +## Ordering Contract + +A season is treated as Specials when either: + +- `isSpecials` is `true`; or +- `seasonNumber` is `0`. + +Specials sorts before every regular season. Regular seasons sort by +`seasonNumber` ascending. Existing deterministic title and content-ID +tie-breakers remain in place. + +Recognizing Season 0 independently of `isSpecials` protects the UI when reading +older cached responses or a response that omitted the optional semantic flag. + +## Initial Selection + +Display order and automatic selection are separate: + +- A requested/deep-linked season remains selected, including Specials. +- On an ordinary series opening, select the first regular season. +- If the series contains only Specials, select Specials. + +This prevents the reordered selector from making a series open on bonus +material by default while still placing Specials first visually. + +## Implementation Shape + +Update the existing shared season comparator rather than reordering separately +inside phone and TV composables. Keep the phone and TV view models responsible +for choosing the initial season, using the same “first regular, otherwise +first” rule after applying the shared display order. + +No new repository, model, route, or server behavior is introduced. + +## Verification + +Focused tests cover: + +- Season 0 before Seasons 1 and 2. +- `isSpecials = true` before regular seasons even with a nonzero number. +- Season 0 recognized when `isSpecials` is false or absent. +- Regular seasons remain ascending and deterministically ordered. +- Phone and TV initially select the first regular season. +- A requested Specials season remains selected. +- Specials-only series still select Specials. + +Run the affected shared, phone-detail, and TV-detail unit tests, followed by +phone and TV debug compilation. diff --git a/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md b/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md new file mode 100644 index 000000000..2b262c006 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-playback-buffer-architecture-design.md @@ -0,0 +1,157 @@ +# Playback Buffer Architecture — Design + +**Date:** 2026-07-30 +**Status:** Approved in design conversation; this document is its record +**Scope:** `android-shared` playback buffering (phone + TV share it) + +## Why + +Two problems, one of which masks the other. + +**1. The buffer fills, the socket idles, the proxy kills the connection.** +`DefaultLoadControl` loads until `maxBufferMs`, then stops reading the socket +until the buffer drains below `minBufferMs`. The connection therefore sits idle +for roughly `max − min` of playback time. Today's hardcoded policy is +`min 50s / max 120s` — a **70-second idle window** against a proxy +`send_timeout` that defaults to 60s. The connection is dropped whenever the +buffer fills on a long direct-play file, and the client only discovers it when +it comes back for more data. This is arithmetic, not a race. + +**2. The byte cap silently overrides the time target.** +`prioritizeTimeOverSizeThresholds` is `false`, so whichever limit binds first +wins. With device-class caps of 48/96/160 MiB, a 40–80 Mbps remux gets roughly +10–34 seconds of buffer while the configuration claims 50. Nothing surfaces the +discrepancy. + +These interact: the premature byte cap has been *shortening the idle window*, +partly hiding problem 1. Raising the caps without fixing the window would make +dropped connections dramatically more common. + +Additionally, the three-mode `PlaybackBufferMode` enum is dead code — +`PrairiePlayerFactory` hardcodes `Balanced`, so `QuickStart`, `SmoothPlayback` and +the `fromWire` parsing are unreachable. + +## Decisions + +Taken in conversation with Jim: + +- **Automatic, from measured conditions.** No user setting, no server-driven + wire value. The player derives the policy from what it can observe. +- **Start fast, then deepen.** Begin on a small cushion and fill in the + background; users judge a player on time-to-first-frame. +- **Depth and idle window are independent.** Extend the buffer as far as memory + and throughput allow, while holding the idle window fixed. + +## Architecture + +### The invariant + +`maxBufferMs` stops being a free parameter: + +```text +maxBufferMs = minBufferMs + MAX_LOAD_IDLE_MS +``` + +`MAX_LOAD_IDLE_MS = 15_000`, which is a 30s wall-clock budget scaled *down* by +the slowest selectable playback rate (`30_000 * 0.5`). The 30s budget sits well under the assumed +60s upstream proxy `send_timeout`, but the invariant is expressed in *media* +time while a proxy measures *wall clock*, and `DefaultLoadControl` scales +`minBufferUs` only for speeds above 1.0. Audiobooks offer 0.5x and share this +load control, so a 30s media window would stretch to 60s of wall clock — +exactly the timeout. The assumed timeout and the slowest rate are both named +constants with their reasoning beside them, so a deployment behind a 30s proxy +has an obvious dial rather than a mystery. + +This makes the failure structurally unrepresentable: no matter how deep the +buffer grows, the socket cannot idle long enough to be dropped. Depth is +`minBufferMs`; the window is the gap. + +### Depth is governed by memory and throughput + +Depth grows toward a ceiling, bounded by: + +- **Memory budget** — bytes needed = target seconds × observed bitrate, + clamped to a fraction of the app heap. When the budget cannot fund the target + seconds, the *target seconds are reduced explicitly* to what fits. + **20s is the policy's requested floor, not a guarantee:** it is where depth + starts when nothing constrains it, but a known bitrate high enough that the + budget funds less than 20s yields the smaller, honest number. Raising it back + to 20s would only mean claiming a depth the memory cannot hold — which is the + silent overrun this work exists to remove. `maxBufferMs` follows `min` down, + so the idle window only ever shrinks. +- **Delivery throughput** — the bandwidth meter already reports delivery rate. + Delivery ≫ media bitrate means the source can outrun playback (direct file, + or a fast/GPU transcode) and depth may extend. Delivery ≈ bitrate means the + producer is realtime-bound and the buffer cannot grow regardless of target. +- **Ceiling: 180s.** Beyond this we are mostly pre-fetching content the user may + seek away from — wasted bandwidth, and wasted allowance on mobile data. + +### Transcode needs no special case + +Investigated and deliberately dropped. Two findings: + +1. A deep target does **not** make the client wait on the encoder — ExoPlayer + simply receives more slowly. If the encoder is realtime-bound the buffer + never reaches `max`, so the socket never idles and problem 1 cannot occur on + transcoded streams at all. A deep target on a slow encoder is inert. +2. The **server already bounds it**. `TranscodeThrottler` + (`internal/playback/throttle.go` in prairie-server) pauses ffmpeg once it is + `transcode_throttle_seconds` ahead of the client's fetch position — default + **300s**, clamped to a 60s minimum, gated by `enable_transcode_throttle`. + That is the real ceiling for transcoded content, and it is the server's to + enforce. + +So throughput-driven depth handles transcode without the client knowing what it +is talking to: a GPU-transcoding server behaves like direct play, a CPU-bound +one degrades gracefully, and nothing breaks when stream nodes are enabled later. + +Note that HLS delivery (remux or transcode) fetches discrete segments, so each +request is short-lived and the idle-window problem does not arise there. The +invariant is harmless in that case and load-bearing for `ORIGINAL_HTTP` +progressive direct play — which is exactly where the reported drops occur. + +### Numbers + +| | Start | After stall | Depth (min) | Idle window | +|---|---|---|---|---| +| All delivery | 2s | 5s | 20s requested floor → 180s ceiling, memory/throughput governed | 15s media (30s wall clock at 0.5x) | + +Start drops 3s → 2s. Stall recovery drops 10s → 5s: after a stall the user is +watching a spinner, and ten seconds is a long time to withhold the picture for +insurance. + +## Components + +Three units, each independently testable: + +- **`PlaybackBufferPolicy`** — the value type, plus a pure + `forConditions(deviceProfile, ...)` replacing `forMode(...)`. Where the + numbers live. `PlaybackBufferMode` and its `fromWire` parsing are deleted. +- **`PrairieLoadControl`** — keeps bitrate-aware byte sizing; gains the + seconds-fit-to-budget reduction and enforces the idle-window invariant when + constructing its `DefaultLoadControl` parameters. +- **`PrairiePlayerFactory`** — stops naming a mode; passes observed conditions. + +## Testing + +Pure functions, following the existing `PlaybackBufferPolicyTest` / +`PrairieLoadControlTest` pattern. Per this repo's guidelines, focused tests on +high-risk behaviour only: + +- The idle-window invariant holds for every reachable policy — including after + the memory budget has forced depth down. +- Seconds-fit-to-budget reduction on a low-RAM device with a 60 Mbps stream: + the reported depth is the honest sub-floor number the budget funds, not the + 20s the policy asked for. +- The 180s ceiling holding when memory would allow more. +- A regression pinning `max − min == MAX_LOAD_IDLE_MS`, since that is the property that + prevents the dropped connections. + +## Out of scope + +- Any user-facing or server-driven buffer setting. +- LAN-vs-remote branching: no such signal exists in the player today. +- HLS-vs-progressive policy split: the throughput signal covers what matters. +- Changing `proxy_send_timeout` on openresty. That would help only servers Jim + controls; the client-side invariant holds against any proxy, including + users' own reverse proxies and CDNs. diff --git a/docs/superpowers/specs/2026-07-31-fire-tv-playback-selection-ux-design.md b/docs/superpowers/specs/2026-07-31-fire-tv-playback-selection-ux-design.md new file mode 100644 index 000000000..0149b87a4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-fire-tv-playback-selection-ux-design.md @@ -0,0 +1,134 @@ +# Fire TV Playback Selection UX Design + +**Date:** 2026-07-31 +**Status:** Approved for implementation planning +**Baseline:** `Silo-Server/prairie-android` `main` at `3b2044c8` + +## Problem + +The current Android TV release has three related playback-selection defects: + +1. The Version, Audio, Subtitles, and Edition menus on item detail pages do not show a legible TV focus state. Version and Subtitles are the reported cases. +2. Moving from one episode to the next does not carry the viewer's active source-resolution intent or subtitle choice. This affects automatic/explicit Up Next and the refreshed next-up controls on series and season detail pages. +3. Long in-player option lists, most visibly Subtitle Track, can move focus below the clipped viewport without scrolling the focused row into view. + +These are Android TV client defects. They require no server, API, database, or profile-preference changes. + +## Verified Causes + +### Detail selector contrast + +`TvAnchoredSelectorMenu` embeds phone Material 3 `DropdownMenu` and `DropdownMenuItem` components inside the TV Material theme. The rows have explicit idle foreground colors but no TV-focused container/content treatment. Selection adds only a checkmark and semantics. The component itself documents this missing TV focus grammar. + +### Episode-to-episode selection continuity + +Durable track selections are correctly scoped to `(server, profile, contentId, fileId)`. A different episode necessarily has different content and file identities. The detail refresh path clears the old next-up state and restores only state already saved for the new episode. The player Up Next path carries only a resolution-shaped quality string; it drops subtitle intent and cannot distinguish versions sharing a resolution. The target episode's `lastFileId` may also override the carried quality. + +Raw file IDs and subtitle indexes must not cross episode boundaries. Android's current `FileVersion` model also lacks a stable edition identity. + +### In-player picker scrolling + +`HudPickerDialog` eagerly composes all rows in a clipped `Column.verticalScroll`, preserving a complete modal focus graph, but assumes focus movement will relocate that scroll container. Fire TV may focus a clipped child without scrolling it onscreen. A previous `LazyColumn` implementation performed explicit scrolling but was removed because lazy composition caused focus-boundary leakage. + +## Apple Comparison + +Current `Silo-Server/silo-apple` `main` at `e7de923a` has the same episode-continuity gap on both iOS and tvOS. `PlayerViewModel.playNextEpisodeNow()` starts the next episode with file, audio, and subtitle overrides all `nil`, then applies the new item's stored/profile preferences. Series and season detail also reset next-up selections when identity changes. + +Apple is therefore not the continuity behavior to copy. Its native tvOS menu and picker controls do not share Android's contrast or scrolling implementation defects. + +## Chosen Design + +### 1. TV-native focused selector rows + +Keep the existing anchored detail menu and selection callbacks. Replace the implicit phone-menu focus appearance with an explicit row visual-state policy: + +- Focused: existing TV `FocusedContainer` background, `FocusedContent` text/icons, and a visible focused border. +- Selected but not focused: restrained selected fill/border plus the existing checkmark. +- Idle: current dark surface and high-contrast foreground. +- Disabled: current disabled semantics and visibly muted content. + +The same treatment applies to every `TvAnchoredSelectorMenu` consumer so Audio and Edition do not retain the latent defect. + +### 2. Session-scoped semantic episode handoff + +Represent the outgoing viewer intent without reusing episode-local IDs: + +- Source intent: normalized resolution plus available codec, HDR/Dolby Vision, and container characteristics. This is a preference, not an exact file identity. +- Subtitle intent: + - `Auto` + - explicit `Off` + - explicit semantic track fingerprint: normalized language, forced/SDH flags, source kind, and codec/format where available. + +When the next episode's watch detail is available, resolve the intent deterministically: + +1. Preserve `Off` exactly. +2. For an explicit subtitle, select the best semantic match. Prefer language and accessibility/forced meaning over incidental index or filename. If no valid match exists, return to the normal profile `Auto` behavior. +3. For source selection, prefer the closest semantic version match. Resolution is primary; codec/HDR/container break ties. Never transfer a raw file ID. If no meaningful match exists, use the existing automatic version policy. +4. A carried explicit session choice takes precedence over the target episode's stale `lastFileId` for that transition. With no carried choice, existing target-episode state and automatic behavior remain unchanged. + +Carry this handoff through both TV paths: + +- Player Up Next request and navigation route into the next player. +- Series/season next-up identity refresh while the detail screen remains alive. + +The handoff is process/session scoped. It does not rewrite the per-episode durable preference key, create a series-wide preference, or alter server profile settings. Once the target episode resolves and the viewer changes a selection, existing per-item persistence continues normally. + +Audio continuity is not added in this change because it was not reported and materially expands matching semantics. Existing audio behavior remains unchanged. + +### 3. Explicit focused-row relocation in the HUD picker + +Keep the eager `Column` so every modal row remains in the focus graph. Give each option row a `BringIntoViewRequester`; when it gains focus, request that the row be brought into the clipped viewport. This covers initial programmatic focus and every D-pad transition without restoring the previous lazy-list focus-boundary regression. + +The shared correction applies to Subtitle Track and all other long HUD pickers, including delay lists. + +## State and Lifecycle Rules + +- The semantic handoff belongs to a single active TV browsing/playback flow. +- It is discarded when the next episode consumes it, the user exits the flow, or process state is lost. +- Profile/server changes do not inherit it. +- Explicit `Off` is distinct from `Auto` throughout routing and resolution. +- Watch Together authority and its auto-advance suppression are unchanged. +- Playback session shutdown ordering is unchanged. + +## Testing + +### Focus contrast + +- Unit-test a pure selector-row visual-state resolver for focused, selected, idle, and disabled states. +- Verify focused foreground/background meet the established TV inverted-focus policy. +- Manually D-pad through Version, Subtitle, Audio, and Edition menus where present. + +### Episode handoff + +- E1 explicit resolution/source intent resolves to the closest E2 version. +- A previously watched E2 `lastFileId` does not override an active carried choice. +- Same-resolution candidates use codec/HDR/container tie-breakers deterministically. +- No meaningful source match falls back to existing automatic selection. +- Explicit subtitle language/forced/SDH/source/format resolves to the closest E2 track. +- Missing subtitle match falls back to profile Auto. +- Explicit Off remains Off. +- Auto remains Auto. +- Raw file IDs and track indexes are never transferred. +- Both Player Up Next and series/season next-up refresh use the same resolver. +- Existing same-episode persistence tests remain green. + +### Picker scrolling + +- A picker opened with an offscreen selected/focused row brings it onscreen. +- Repeated D-pad Down/Up keeps the focused row visible across viewport boundaries. +- Focus remains trapped within the modal at the first and last rows. +- A short list does not move unnecessarily. + +## Verification + +Run focused Android TV unit tests for the new policies and affected existing suites, then the complete Android TV unit suite, supply-chain verification required by the repository, and Android TV debug/release compilation. Perform a Fire TV or TV-emulator D-pad smoke covering detail selector contrast, long subtitle-list scrolling, and E1-to-E2 continuity when an appropriate device/test fixture is available. No device installation is authorized by this design. + +## Out of Scope + +- Server/API/schema changes. +- Series-wide durable playback preferences. +- Stable edition identity additions to the Android model. +- Cross-episode audio-track continuity. +- Phone behavior changes. +- Replacing the entire anchored selector popup architecture. +- Installing a build on a physical device. diff --git a/docs/superpowers/specs/2026-08-02-android-tv-auth-ime-relocation-design.md b/docs/superpowers/specs/2026-08-02-android-tv-auth-ime-relocation-design.md new file mode 100644 index 000000000..8e185c67e --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-android-tv-auth-ime-relocation-design.md @@ -0,0 +1,88 @@ +# Android TV Auth IME Relocation Design + +**Date:** 2026-08-02 +**Status:** Approved for implementation planning +**Baseline:** `Silo-Server/prairie-android` `main` at `981c7a42` + +## Problem + +On Nvidia Shield, the platform keyboard can cover or visually crowd the focused server-address field even though the TV activity uses `adjustResize`, edge-to-edge IME inset dispatch, `imePadding()`, and `BringIntoViewRequester`. The current relocation request runs when focus changes, before the keyboard has finished opening and resizing the Compose viewport. The server screen also nests a scrollable manual-entry card inside a scrollable page, so relocation can be consumed by the wrong container. + +The same timing and scroll-ownership problem exists on the username, password, invite, and profile fields used by the remaining TV authentication forms. A server-screen-only correction would leave equivalent failures on the next screen. + +## Chosen Design + +Create one shared Android TV authentication-form IME relocation behavior and apply it to: + +- server connection; +- login; +- initial server setup; +- signup; and +- profile creation/editing forms that use the TV soft keyboard. + +Each screen keeps its current keyboard-closed composition, styling, focus order, and D-pad behavior. The correction changes only scrolling while the IME is visible. + +### Single scroll owner + +Each affected screen has one outer vertical scroll container responsible for moving content around the IME. A child card or form must not own a competing vertical scroll container for the same fields. Fixed visual card sizing may remain where it does not clip content, but relocation always propagates to the outer screen container. + +### Focused-field context + +Each editable field associates a `BringIntoViewRequester` with a small context wrapper containing its visible label and field. The requested region includes 32dp of bottom clearance. This prevents the field from being positioned flush against the keyboard and keeps enough context visible to identify username, password, server address, or another active value. + +The requested region is intentionally local. The screen does not attempt to keep its full hero, progress indicator, cards, or submit controls above the keyboard. + +### IME-aware relocation timing + +A shared composable helper observes both field focus and `WindowInsets.ime` visibility/size. It requests relocation when: + +1. a field gains focus while the IME is already visible; or +2. the IME becomes visible or changes size while that field is focused. + +The helper waits until the resized layout has been measured before requesting relocation. This makes the result depend on current keyboard geometry rather than the pre-keyboard viewport. Repeated equivalent inset updates are coalesced so they do not produce visible scroll jitter. + +When the keyboard closes, the screen returns to its normal top position. No alternate compact screen or hidden content state is introduced. + +## Component Boundaries + +- The shared helper owns IME/focus observation and post-layout relocation only. +- Each screen owns its scroll state, field labels, keyboard actions, validation, and focus traversal. +- Each field or field wrapper owns its requester and declares the contextual region to reveal. +- `MainTvActivity` retains its current edge-to-edge inset configuration, and the manifest retains `adjustResize`. + +The helper has no dependency on authentication view models or field values and can be tested independently from server and account logic. + +## State and Error Handling + +- Relocation failures caused by disposal or rapid navigation are ignored; they must not affect authentication state. +- A field disabled during submission does not trigger new relocation work. +- Validation errors remain in the existing form and may scroll normally when focus moves to their associated field. +- Hardware-keyboard use leaves the normal layout unchanged because the software IME inset is not visible. +- Keyboard dismissal, Back navigation, and screen transitions cancel pending relocation work through Compose lifecycle cancellation. + +## Testing + +### Automated + +- Test the shared visibility/relocation trigger policy for focus-before-IME, IME-before-focus, IME size changes, keyboard closure, duplicate inset updates, and disposal. +- Add source or Compose tests confirming each affected TV auth form uses the shared helper and a single outer vertical scroll owner. +- Keep existing focus-order, validation, and authentication tests green. +- Build the Android TV debug APK. + +### Shield validation + +At the Shield's 4K output resolution with the installed TV keyboard: + +- Open the server-address keyboard and confirm the label and complete field remain visible with clearance. +- Continue to login and verify both username and password while moving focus with the D-pad. +- Exercise setup, signup, and profile forms when reachable. +- Confirm closing the keyboard restores the original screen composition. +- Confirm no field jumps or oscillates while typing and no app screen is redesigned. + +## Out of Scope + +- A compact or dedicated text-entry screen. +- Authentication visual redesign. +- Phone-client behavior. +- Replacing the platform keyboard. +- Server, API, validation, or credential-storage changes. diff --git a/docs/superpowers/specs/2026-08-02-tv-mounted-srt-fast-switch-design.md b/docs/superpowers/specs/2026-08-02-tv-mounted-srt-fast-switch-design.md new file mode 100644 index 000000000..931dea906 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-tv-mounted-srt-fast-switch-design.md @@ -0,0 +1,61 @@ +# TV Mounted SRT Fast Switching Design + +## Goal + +Make an ordinary Android TV switch to an already-mounted SRT subtitle track complete without interrupting or rebuffering video, while retaining the existing transactional server-replan path whenever local selection cannot safely satisfy the request. + +## Current Behavior and Root Cause + +At playback mount, Silo converts every mountable sidecar in `subtitleUrls` into a Media3 `SubtitleConfiguration` and attaches the full list to the active `MediaItem`. The selected SRT therefore commonly already exists in `Player.currentTracks`. + +The subtitle transaction adapter nevertheless limits its local fast path to `Embedded`, `Downloaded`, and `LocalMedia3` identities. A mounted `ServerSidecar` skips that path, stages a new server playback request, adopts the replacement session, changes `transportMountNonce`, and causes the screen to call `setMediaItem` and `prepare` again. Preserving position makes the switch correct, but re-preparing the video produces the visible buffering delay. + +## Considered Approaches + +1. **Resolve any locally selectable identity before replanning (chosen).** Ask the existing mounted-track resolver whether the exact requested identity is present, then use the established local mount-confirmation transaction. This reuses typed identity matching and preserves the server fallback. +2. **Treat every `ServerSidecar` as local.** This is simpler but unsafe: catalog rows can be absent from the current Media3 snapshot, unsupported, or require a different server rendering route. +3. **Rebuild the MediaItem locally without a server request.** This avoids session staging but still calls `setMediaItem` and `prepare`, so it retains the user-visible video interruption. + +## Approved Behavior + +- Selecting a `ServerSidecar` that resolves exactly against the current mounted Media3 subtitle tracks uses the local transaction path. +- The player changes only the text-track override. The active video `MediaItem`, stream URL, playback session, position, and buffer remain untouched. +- The selection is committed only after the normal player-boundary confirmation reports that the requested track became selected. +- Persistence and committed/pending UI state continue to use the existing subtitle transaction machinery. +- Selecting Off remains local and does not reprepare playback. +- Embedded, downloaded, and local Media3 identities retain their current behavior. +- A sidecar that is not currently mounted, cannot be resolved exactly, requires server burn-in or conversion, or is combined with an audio, quality, or output-route mutation continues through the existing server-replan path. +- If local selection fails or times out, existing rollback and error behavior remains authoritative; this change does not silently commit an unconfirmed selection. + +## Architecture and Data Flow + +The transaction adapter's local-selection eligibility will be based on two facts: + +1. The identity requires confirmation at the player boundary rather than server burn-in. +2. The injected `isLocallyMountable(identity)` resolver finds the requested typed identity in the current Media3 snapshot. + +`ServerSidecar` becomes eligible for that check. The adapter then calls the existing `beginLocalSelection` flow, publishes the pending mount identity, and waits for the existing remount/reselection observer to resolve and select the mounted track. The backend applies a Media3 text-track override; it does not invoke the session manager or media mounter. + +If the resolver returns false, the adapter follows its unchanged staged-request path. This keeps the optimization capability-driven rather than assuming that every server sidecar is locally usable. + +## Error Handling and State + +The current local-mount deadline, selection acknowledgement, rollback, persistence, and supersession rules remain unchanged. The optimization does not create a second transaction mechanism. It only allows an already-mounted `ServerSidecar` to enter the mechanism currently used by other locally selectable subtitle identities. + +The exact typed resolver remains the guard against choosing a same-language or same-label track with a different identity. + +## Testing + +- Add a regression test proving that a mounted `ServerSidecar` enters local mount confirmation and does not stage a server request. +- Add a fallback test proving that an unmounted `ServerSidecar` still stages the server request. +- Retain coverage that audio, quality, and output-route mutations prevent the local shortcut. +- Run focused transaction and TV player tests, then the full Gradle test suite and TV debug assembly. +- On the Shield, verify that switching between already-mounted SRT tracks does not enter player buffering and does not replace the current media item. + +## Out of Scope + +- Preloading subtitle files that are absent from the active `MediaItem`. +- Avoiding a reprepare after downloading or generating a brand-new subtitle. +- Changing server burn-in or subtitle conversion decisions. +- Changing phone playback behavior. +- Refactoring the broader subtitle transaction architecture. diff --git a/docs/superpowers/specs/2026-08-02-tv-player-transport-accessibility-design.md b/docs/superpowers/specs/2026-08-02-tv-player-transport-accessibility-design.md new file mode 100644 index 000000000..6db345444 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-tv-player-transport-accessibility-design.md @@ -0,0 +1,49 @@ +# TV Player Transport Accessibility Design + +## Goal + +Make the Android TV playback transport the first destination of D-pad Down and improve control legibility without changing the transport row's visual language. + +## Current Problems + +- With playback controls hidden, D-pad Down opens the information/settings HUD because `TvPlayerRemoteKeyAction` deliberately maps that input to `OpenHud`. +- Transport buttons are 33dp circles. Secondary glyphs, including Closed Captioning, are only 12.5dp; Play/Pause is 15dp. On a television these controls are difficult to distinguish. + +## Interaction Design + +- D-pad Down while playback controls are hidden reveals the idle overlay and focuses Play/Pause. +- D-pad Down while the idle overlay is visible continues routing focus into the Play/Pause control. +- D-pad Down from the scrubber continues moving focus to Play/Pause. +- The remote Menu and Settings keys continue opening the information/settings HUD. +- Selecting the captions button continues opening the existing quick subtitle picker. +- Left/Right transport navigation, Up-to-scrubber navigation, playback actions, auto-hide behavior, and Back behavior remain unchanged. + +## Visual Design + +- Preserve circular controls, grouping, icon-only presentation, borders, colors, and white/black focus inversion. +- Increase every transport button from 33dp to 44dp. +- Increase Play/Pause from 15dp to 22dp. +- Increase all secondary glyphs from 12.5dp to 20dp. +- Keep the existing 5dp inter-button gap and left/right group layout. The row has sufficient horizontal room; changing the gap or adding labels would add unnecessary visual impact. + +## Implementation Boundaries + +- Change hidden-overlay Down mapping at the shared remote-key action boundary so the key-dispatch bridge and Compose overlay agree. +- Keep the transport dimensions centralized in `TvPlayerTransportVisualPolicy.kt`, with + `TvPlayerTransportCluster.kt` consuming that policy rather than special-casing captions. +- Do not alter the HUD, subtitle picker, subtitle-selection behavior, or player state model. + +## Testing + +- Update remote-key unit tests to require `FocusTransport` for D-pad Down regardless of whether the idle overlay is already visible. +- Retain coverage proving Menu and Settings keys open the HUD. +- Add a small, behavior-oriented sizing policy that tests the primary and secondary transport dimensions used by the composable. +- Run the Android TV unit suite and assemble the debug APK. +- Install the ARM64 debug APK on the Shield without launching it. + +## Success Criteria + +- From unobstructed playback, one D-pad Down press reveals the controls with Play/Pause focused. +- Captions, Settings, Close, skip, and Play/Pause glyphs are visually distinguishable at normal TV viewing distance. +- No labels or additional chrome appear. +- Existing transport actions and focus movement remain functional. diff --git a/docs/superpowers/specs/2026-08-02-tv-subtitle-picker-and-sizing-design.md b/docs/superpowers/specs/2026-08-02-tv-subtitle-picker-and-sizing-design.md new file mode 100644 index 000000000..3cbc8bbd8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-tv-subtitle-picker-and-sizing-design.md @@ -0,0 +1,64 @@ +# TV Subtitle Picker Dismissal and Sizing Design + +## Goal + +Make subtitle selection from the Android TV CC quick picker return immediately to unobstructed playback, and make plain-text television subtitles consistently readable from couch distance. + +## Current Behavior and Root Cause + +The CC quick picker uses the shared subtitle presentation, applies the selected subtitle identity, and deliberately keeps the picker open with `closeOnSelect = false`. Its ordinary dismiss path restores the playback controls, so using that same path after selection would still leave chrome covering the video. + +Plain-text subtitles use Media3 fractional sizing. The television preset ladder is expressed relative to subtitle-view height, so apparent text size depends on the displayed video/surface geometry. On the Shield, the active Large preset is visibly smaller than the player's 20sp title footer. Wholphin avoids this variability by applying a fixed SP subtitle size and defaults to 24sp. + +## Approved Behavior + +### CC Quick Picker + +- Selecting any row, including Off, first forwards the selected `SubtitleIdentity` through the existing subtitle transaction path. +- The selection then closes the CC quick picker and hides the playback controls, returning to unobstructed video. +- Pressing Back remains distinct: it closes only the quick picker and restores/leaves the playback controls visible. +- The Settings HUD subtitle-track picker is unchanged. +- Subtitle transaction, pending/applying, remount, failure, and committed-selection behavior are unchanged. + +### Television Plain-Text Subtitle Sizes + +Television playback uses fixed SP sizes for Media3-rendered plain-text subtitles: + +| Preset | TV size | +| --- | ---: | +| Small | 18sp | +| Medium | 22sp | +| Large | 26sp | +| X-Large | 32sp | +| XX-Large | 40sp | + +Large is intentionally slightly larger than the 20sp semi-bold player title footer and slightly larger than Wholphin's 24sp default. + +Phone sizing remains unchanged. ASS/SSA subtitles rendered by libass continue preserving authored typesetting and font sizes. + +## Architecture + +The quick picker receives a selection-specific callback rather than reusing its Back/dismiss callback. The selection callback performs the existing subtitle selection and applies a small, testable chrome outcome: picker hidden and controls hidden. The ordinary dismiss callback continues to hide the picker while keeping controls visible. + +Subtitle sizing is represented by a pure Android subtitle text-size policy that distinguishes fixed SP from fractional sizing. `SubtitleManager.applyAppearance` consumes that policy: phone presentation retains the current fractional values, while television presentation calls Media3's fixed-SP API with the approved ladder. This keeps platform rendering details in `SubtitleManager` and exact preset values in a unit-testable policy. + +## Error Handling and State + +Picker dismissal occurs when the user commits a valid row, not when asynchronous subtitle materialization completes. Existing transaction state remains authoritative if selection later reports a failure. Invalid or missing stable IDs do not select or dismiss anything. + +No persistence format changes are required: stored presets remain the existing `SubtitleFontSizePreset` enum values. Existing users therefore receive the new television rendering for their current preset without migration. + +## Testing + +- A focused quick-picker policy test requires selection to hide both the picker and playback controls, while Back keeps controls visible. +- Subtitle appearance tests require the exact television fixed-SP ladder. +- Existing phone tests continue requiring the current fractional ladder. +- Existing subtitle HUD/presentation and transaction tests must remain green. +- Final verification runs the full Android test suite and assembles the ARM64 TV debug APK. + +## Out of Scope + +- Changing the Settings HUD subtitle picker or closing the Settings HUD after track selection. +- Changing subtitle selection, search, download, translation, remount, or failure behavior. +- Overriding ASS/SSA authored styling. +- Changing phone subtitle sizes or serialized subtitle preferences. diff --git a/docs/superpowers/specs/2026-08-03-apk-only-release-publish-design.md b/docs/superpowers/specs/2026-08-03-apk-only-release-publish-design.md new file mode 100644 index 000000000..6516819e6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-apk-only-release-publish-design.md @@ -0,0 +1,45 @@ +# APK-only GitHub Release Publishing Design + +## Problem + +The `v1.0.0-rc.1+4` release run built and uploaded both signed APK artifact sets, but GitHub Actions skipped the `publish-release` job. APK-only tags intentionally skip the `play` job. Although the `apks` matrix has an explicit condition that accepts a skipped Play job, `publish-release` has no explicit status condition. GitHub therefore propagates the skipped dependency through the job chain and applies its implicit success gate. + +Run `30814079342` demonstrates the failure: `play` was skipped, both `apks` matrix jobs succeeded, and `publish-release` was skipped without executing any steps. + +## Design + +Add a job-level condition to `publish-release`: + +```yaml +if: >- + ${{ !cancelled() && + needs.setup.result == 'success' && + needs.apks.result == 'success' }} +``` + +The explicit status function disables the implicit success gate that propagates skipped ancestors. Requiring successful `setup` and `apks` results preserves the existing safety boundary: a setup, test, Play, signing, or APK-build failure cannot publish a GitHub release. `!cancelled()` prevents a cancelled workflow from publishing artifacts. + +No release naming, prerelease classification, Play behavior, asset naming, or `Latest` behavior changes. + +## Regression Protection + +Add a focused shell self-test for the release workflow. It will extract the `publish-release` job header and require: + +- `needs: [setup, apks]`; +- an explicit `if` condition; +- cancellation protection; +- successful `setup` and `apks` result checks. + +The release workflow's unit-test job will run this self-test before Gradle tests so future edits cannot silently restore the transitive-skip bug. Existing supply-chain checks and workflow syntax validation will also run. + +## Validation + +Validation will cover: + +1. The new regression test fails against the current workflow. +2. The minimal condition change makes it pass. +3. Shell syntax checks pass for the new script. +4. Existing supply-chain policy self-tests pass. +5. The workflow parses and passes `actionlint`. + +The fix will not trigger a release; it will be delivered through a pull request from an isolated branch based on `upstream/main`. diff --git a/docs/superpowers/specs/2026-08-03-for-you-late-focus-relocation-design.md b/docs/superpowers/specs/2026-08-03-for-you-late-focus-relocation-design.md new file mode 100644 index 000000000..4addfe904 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-for-you-late-focus-relocation-design.md @@ -0,0 +1,45 @@ +# For You Late Focus-Relocation Recovery Design + +## Problem + +When focus enters the first recommendation row while the For You list is +already at item zero, the current correction effect exits immediately. Compose +can run the row's focus-driven bring-into-view relocation afterward and move the +list below its intended top anchor. Moving down and back up re-enters the row and +re-arms the correction, which is why the screen then recovers. + +## Design + +Keep a scroll-position observer active only while the first recommendation row +owns focus. The observer remains suspended while the list is correctly anchored +and reacts only when the list position changes. If a later focus-relocation pass +moves the list away from item zero, wait for that relocation to settle, confirm +the row still owns focus and the list is still displaced, then animate back to +item zero. Leaving the row cancels the observer through the existing +focus-keyed `LaunchedEffect`. + +The observer and correction loop will be extracted behind a small suspend +helper that accepts position events and scroll callbacks. This keeps the +timing policy testable without a Compose UI harness while production continues +to obtain positions from `snapshotFlow` over the real `LazyListState`. + +## Alternatives Rejected + +- A fixed number of settling polls adds arbitrary timing and can still miss a + slower Fire TV relocation. +- Changing the shared bring-into-view policy affects every recommendation row + and risks wider D-pad navigation regressions. +- Continuous polling for the entire focus lifetime wakes unnecessarily even + when the list does not move; an event-driven observer has no such activity. + +## Verification + +Add a coroutine regression test that emits an initially correct top position, +then emits a delayed displaced position while focus remains in the first row. +The test must fail against the current early-exit behavior and pass only when +the delayed displacement triggers one top-anchor correction. Also cover that a +top-only sequence is a no-op and that focus loss prevents a pending correction. + +Run the focused test, the complete Android TV unit suite, release-workflow and +supply-chain checks, and assemble the Android TV debug APK with the full RC +display version. diff --git a/docs/superpowers/specs/2026-08-03-shield-focus-restoration-design.md b/docs/superpowers/specs/2026-08-03-shield-focus-restoration-design.md new file mode 100644 index 000000000..69939434a --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-shield-focus-restoration-design.md @@ -0,0 +1,113 @@ +# Shield Focus Restoration Design + +## Purpose + +Fix the remaining Android TV focus failures reported on Shield Pro without changing Watchlist placement, behavior, navigation, or rendering: + +- Returning from a For You item detail must preserve the feed position and restore D-pad focus to the card that launched the detail screen. +- The For You top chrome must use the same solid visual foundation already visible in Watchlist and Favorites, without editing those saved-list views. +- Calendar must allow Up navigation from the weekday row through its controls and back to the selected Calendar tab. +- The Diagnostics page's crash-report choices must reliably receive and move focus with a remote. + +This branch is based on the head of PR #162, so the event-driven first-row top-anchor correction remains part of the resulting change. + +## Non-goals + +- Do not promote Watchlist to a top-level tab. +- Do not alter Watchlist or Favorites behavior, navigation, layout, or rendering. +- Do not redesign For You, Calendar, Diagnostics, or the global shell. +- Do not retain every nested route in composition or replace Navigation Compose. +- Do not add touch or keyboard-specific interaction models unrelated to TV D-pad navigation. + +## Root Causes + +### For You detail return + +The For You vertical `LazyListState` is retained, but the shell restores only the generic content focus group after the outer detail route disposes and recreates the shell. Unlike Home, For You does not attach the shell's return requester to the exact launch card. The generic restorer therefore has no durable descendant target and can select a filter, a different card, or no usable row focus after recreation. A subsequent focus-driven bring-into-view pass can make the retained list appear to have lost its position. + +### Calendar exit + +The shell suppresses the top menu while Calendar performs its initial content handoff. Calendar clears that suppression only when one imperative filter request reports success. If that request misses during route composition but Android's default search still focuses a weekday, the screen looks usable while the menu remains suppressed indefinitely. The shell also treats every control as the same focus zone, so Up from a weekday has no deterministic intermediate target. + +### Diagnostics crash-report controls + +The Diagnostics page performs one immediate request to the first consent action and discards the result. A request that races route layout is never retried. The consent actions also rely on geometric focus search, so there is no deterministic Up/Down path through the crash-report choices when the page enters without a focused descendant. + +### For You top chrome + +Watchlist and Favorites render on an opaque full-page saved-list surface. For You relies on the shell gradient and its scrolling feed, producing visibly different top chrome. The inconsistency belongs to For You; changing the saved-list views would expand the visual impact unnecessarily. + +## Design + +### 1. Exact For You return target + +For You will maintain a saveable return target containing stable section and content identities, plus the most recent row/card indices as fallbacks. `TvMediaRow` already supports an indexed item-focus callback and an exact-card restore requester; the screen will use those existing interfaces instead of creating a second card component. + +When a recommendation card gains focus, For You records its section ID, content ID, row index, and card index. When that card opens detail, the screen marks the recorded target as pending before delegating navigation to the shell. While pending, the matching `TvMediaRow` attaches a dedicated return `FocusRequester` to the exact card and uses it as that row's restorer fallback. + +The shell will distinguish a For You detail return from Home and generic content returns. During resume it will enter the content group using the For You return requester as the fallback, following the existing Home pattern. A screen-level post-composition handoff will ensure the saved vertical row is composed before retrying the exact card request. + +Resolution order on return: + +1. The same section and content ID. +2. The same section and the closest valid card index. +3. The closest surviving recommendation row's first card. +4. The For You filter pill if the recommendation feed is now empty. + +The existing saved vertical list state and each row's saved horizontal list state remain authoritative. Focus restoration must not reset either list to zero. PR #162's first-row anchor watcher remains limited to the case where the first recommendation row itself has focus. + +### 2. Calendar focus zones and suppression acknowledgement + +Calendar will explicitly identify focus in two control zones: filter segments and week-strip controls. The shell's Up fallback will route based on the active zone: + +- From a poster shelf, preserve the existing return-to-week-strip behavior. +- From the weekday/week-strip zone, request the active filter segment. +- From the filter zone, request the selected Calendar tab in the top menu. +- Preserve the existing held-key repeat guard so one long press cannot skip multiple layers. + +Any successful focus gain inside Calendar's controls will acknowledge that Calendar content owns focus. This acknowledgement clears `calendarFocusHandoffPending` even when the original imperative filter request failed. The shell can then accept the next Up request. The imperative initial request remains useful, but it is no longer the sole authority allowed to release menu suppression. + +### 3. Diagnostics crash-report focus routing + +The crash-report consent actions will receive stable requesters. On page entry, Diagnostics will target the currently selected consent mode, rather than always targeting `Ask`, after at least one layout frame. A bounded retry handles route-transition timing; success ends the retry immediately. + +Up and Down will route explicitly through the enabled crash-report actions in visual order. The last consent choice routes Down to Debug logging when that action is enabled; disabled actions are skipped. At the upper boundary, focus remains on the first crash-report choice instead of escaping to a non-focusable status block. Navigation from the end of the crash-report section into the existing Capture actions remains available through normal focus search. + +No consent values, upload behavior, report data, or diagnostics visuals change. + +### 4. For You-only top underlay + +For You will paint an opaque background under the top-menu region before drawing its feed. The color will be the existing TV theme background, matching the solid foundation visible in Watchlist and Favorites. This underlay is conditional on the recommendations selection only. Watchlist and Favorites remain byte-for-byte unchanged. + +The global shell gradient remains in place for other routes. No button, typography, spacing, or focus styling changes. + +## State and Failure Handling + +- Return targets use stable IDs first because recommendation refreshes can reorder rows and cards. +- Missing or filtered content follows the explicit fallback order and never loops indefinitely. +- Focus retries are bounded and frame-based; they stop on success, disposal, or target removal. +- A failed Calendar initial request cannot leave the menu permanently suppressed once any Calendar control receives focus. +- Disabled Diagnostics actions are never requested as focus destinations. +- Repeated D-pad key events retain the existing one-layer-per-press policy. + +## Testing + +Add focused unit tests for pure routing and resolution logic: + +- For You exact target, reordered target, missing-card fallback, missing-row fallback, and empty-feed fallback. +- Calendar shelf-to-week-strip, week-strip-to-filter, filter-to-menu, and repeat-event behavior. +- Diagnostics selected-consent entry, Up/Down ordering, disabled Debug logging, and boundary behavior. +- Preserve PR #162's delayed first-row relocation tests. + +Run the complete Android TV unit-test task and assemble the TV debug APK. On Shield Pro, manually verify: + +1. Scroll several For You rows, open a non-first card, return, and confirm the same card and both scroll axes are restored. +2. Repeat after a recommendations refresh or reorder and confirm the stable-ID/fallback behavior. +3. Focus the first For You row and confirm a delayed bring-into-view cannot displace it from the top. +4. Move from a Calendar shelf to weekdays, Up to filters, then Up to the Calendar top-menu tab. +5. Enter Diagnostics and confirm the selected crash-report consent choice is focused; traverse every enabled choice with Up/Down. +6. Compare For You top chrome with Watchlist/Favorites and confirm only For You changed. + +## Delivery + +The implementation stays on the isolated `fix/shield-focus-restoration` branch. It will not modify or rewrite the separate unpushed episode-selector branch. Installation on the Shield and opening the app remain separate explicit delivery steps after the build passes. diff --git a/docs/superpowers/specs/2026-08-04-pr-164-review-remediation-design.md b/docs/superpowers/specs/2026-08-04-pr-164-review-remediation-design.md new file mode 100644 index 000000000..d824aa357 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-pr-164-review-remediation-design.md @@ -0,0 +1,75 @@ +# PR #164 Review Remediation Design + +## Goal + +Make PR #164 safe to merge by correcting four verified Android TV focus-state defects without broadening the feature or changing unrelated navigation behavior. + +## Scope + +The remediation covers: + +1. Calendar held-Up navigation from a non-boundary shelf. +2. Diagnostics initial-focus retries when a `FocusRequester` is temporarily detached. +3. Home detail-return fallback lifetime across its deferred retry. +4. Stale For You detail-return state after an explicit top-menu selection. + +The following review observations are intentionally outside this change: + +- Diagnostics Up at the first crash-report option remains a consumed boundary because the STATUS section above it has no focusable control. +- Keyless recommendation section kinds remain stable singleton identities under the current server contract. +- Legacy-server duplicate fallback identities are a separate compatibility-hardening concern. +- The unused `shouldFallbackForYouReturnToFilter` predicate is cleanup rather than a behavioral blocker. + +## Design + +### Calendar repeat routing + +`calendarUpFallbackAction` will distinguish content from control boundaries. A repeated Up event with no focused shelf may remain within the current control layer, and a repeated Up event at the first focusable shelf may remain in content. A repeated Up event from any deeper shelf must return `MoveWithinContent`, matching the pre-PR behavior and allowing held D-pad movement to continue one shelf at a time. + +The existing action enum and event pipeline remain unchanged. The correction is limited to the predicate ordering and a regression test combining `isRepeat = true` with a non-null, non-boundary shelf index. + +### Diagnostics focus retry + +`FocusRequester.requestFocus()` returning `false` or throwing while the target is not attached will both map to `RETRY`. The existing six-frame bound prevents an infinite loop. Actual screen disposal is represented by cancellation of the `LaunchedEffect`, so no synthetic `DISPOSED` result is needed for caught focus-request exceptions. + +The result enum may retain `DISPOSED` only if another production path still produces it; otherwise it will be removed with the now-unreachable branch. A regression test will require a failed `Result` to map to `RETRY`. + +### Home detail-return fallback lifetime + +Home will gain an explicit pending lifetime for its card-specific detail-return fallback, parallel in purpose to the For You pending state but limited to the existing one-frame Home retry flow. The fallback must remain the Home launch-card requester through the synchronous resume attempt and, when needed, through the deferred frame retry. It will be cleared after the retry flow finishes, or immediately when no retry is required. + +Explicit Home selection will continue clearing the Home return token and retry state. The state transition logic will be extracted or represented by a small pure helper only where needed to make the lifetime regression test deterministic; no generalized focus coordinator will be introduced. + +### For You explicit-selection reset + +Selecting the For You root from the top menu will clear both `forYouDetailReturnFocusRequest` and `forYouDetailReturnFocusPending` before issuing the normal top-level entry request. This prevents an interrupted detail-return request from suppressing or redirecting the explicit first-content focus handoff. + +The reset will apply to explicit root selection only. Returning naturally from item detail will preserve the pending request until the recommendation screen consumes the matching request ID. + +## Error and lifecycle behavior + +- Focus requests remain best-effort and bounded; failures do not escape the composing coroutine. +- Coroutine cancellation remains authoritative for disposal. +- A stale completion ID cannot consume a newer For You request. +- Explicit menu navigation takes precedence over stale detail-return state. +- No persisted server, profile, or media state changes. + +## Testing + +Each production change will follow a separate red-green cycle: + +1. Add a Calendar test proving repeated Up from a deeper shelf returns `MoveWithinContent`. +2. Change the Diagnostics failure test to require `RETRY` and confirm it fails before implementation. +3. Add a Home state-transition test proving the launch-card fallback remains active through a requested retry and clears afterward. +4. Add a For You explicit-selection reset test proving both request ID and pending state clear together. + +After the focused tests pass, run: + +- `./gradlew :androidTvApp:testDebugUnitTest --tests '*TvCalendarFocusRoutingTest'` +- `./gradlew :androidTvApp:testDebugUnitTest --tests '*TvDiagnosticsStateTest'` +- the focused shell/recommendation state tests introduced or updated by this remediation +- `./gradlew :androidTvApp:testDebugUnitTest` +- `./gradlew :androidTvApp:assembleDebug` +- `git diff --check` + +A physical Shield D-pad smoke test remains the release gate for Calendar held-Up movement, Diagnostics initial focus, and Home/For You detail-return restoration. diff --git a/docs/superpowers/specs/2026-08-04-whole-application-focus-hardening-design.md b/docs/superpowers/specs/2026-08-04-whole-application-focus-hardening-design.md new file mode 100644 index 000000000..c67c7b361 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-whole-application-focus-hardening-design.md @@ -0,0 +1,275 @@ +# Whole-Application Focus Hardening Design + +## Goal + +Make focus behavior deterministic across the entire Silo Android application by removing recurring failure modes in TV D-pad navigation, modal ownership, asynchronous focus acquisition, detail-return restoration, dynamic-list identity, disabled controls, and phone/TV IME traversal. + +This work is a follow-up series stacked on the focused PR #164 remediation. PR #164 retains its four scoped corrections; this series addresses the whole-application audit findings without turning #164 into a focus mega-PR. + +## Audit basis + +The read-only audit covered every production file containing Compose focus, key-event, D-pad, requester, restorer, or IME behavior. Three independent domains were inspected: + +- global TV shell and content navigation; +- auth, forms, dialogs, settings, admin, profiles, and phone IME surfaces; +- playback, detail, audiobook, casting, and media controls. + +The audit found no Critical issues. It found 15 Important findings plus three Minor findings, including one duplicated stable-key finding. These consolidate into six recurring causes: + +1. A focus request executing without exception is treated as focus acquisition. +2. Numeric positions are persisted where stable content identity is required. +3. In-window overlays are visually modal but do not own focus or restore their opener. +4. Visual disabled state is not propagated to focus eligibility and accessibility semantics. +5. Focus transitions are not recomputed when asynchronous eligibility changes. +6. IME actions and cleanup are partially implemented or intercepted incorrectly. + +## Design principles + +- Observed focus is authoritative. `requestFocus()` returning or not throwing is never sufficient when correctness depends on the destination actually owning focus. +- Retries are bounded, lifecycle-cancelled, and keyed to stable target identity. +- Explicit navigation overrides stale restoration state. +- Modal UI owns focus for its full visible lifetime and returns it to the exact opener. +- Dynamic content restores by stable identity, never solely by a saved index. +- Disabled means disabled in rendering, input, focus search, and semantics. +- Key handling consumes matching phases consistently and treats held repeats as a state-machine input rather than accidental repeated taps. +- Empty, loading, error, and all-disabled states always expose a deterministic escape or action target. +- Shared helpers encode repeated policy, but screen-specific resolution remains close to the screen. This avoids a global focus coordinator with hidden cross-route coupling. + +## Architecture + +### 1. Bounded observed-focus policy + +Add a small Android TV focus-policy unit under `androidTvApp/.../ui/focus/`. It will model: + +- target state: not ready, ready, or disposed; +- request result: rejected, accepted-but-unobserved, or observed focused; +- bounded attempts separated by frames; +- lifecycle cancellation as the disposal authority; +- deterministic exhaustion fallback owned by the caller. + +The policy accepts functions for target readiness, request execution, observed-focus state, and frame advancement. It contains no Compose nodes or screen state, so its retry/exhaustion behavior is covered by JVM tests. Callers still own requesters and `onFocusChanged` state. + +Existing specialized flows that already observe focus correctly, such as For You restoration, remain intact unless they can adopt the helper without losing their row/card preparation semantics. + +Migrations include: + +- server-list initial focus; +- Collections and Collection Detail initial focus; +- Pair Device eligibility transitions; +- detail hero/body and cast-return handoffs; +- dialog initial focus; +- AI Translate and other valid empty-state dialogs. + +### 2. Modal focus ownership contract + +Every in-window overlay must implement the same contract: + +1. Capture the opener's stable requester or focus identity. +2. Make covered background controls ineligible with `canFocus = false` while visible. +3. Attach a requester to the first eligible modal action or a dedicated focusable Close/scroll target. +4. Acquire observed modal focus with the bounded policy. +5. Cancel directional exit at modal boundaries when the UX is a trap. +6. On dismissal, wait until the overlay's focus nodes/window release ownership, then restore the exact opener with a bounded request. + +This contract will be applied to Browse Filters and audiobook overlays. Existing Popup/Dialog implementations will be audited for opener restoration but will not be rewritten solely for consistency. + +AI Translate's empty-source branch will provide a focusable Close action and will not run an infinite retry loop. Audiobook About will provide a focusable dismiss/scroll target. The covered audiobook player subtree will be non-focusable while any panel is active. + +### 3. Stable focus identity and detail-return contracts + +Shell responsibility and screen responsibility remain separate: + +- The shell arms the generic outer-detail return handshake for every media-detail callback. +- The originating screen records the stable item identity needed to recover the exact launch target. +- The screen resolves that identity against fresh data after return, scrolls the target into composition, attaches the requester, and performs a bounded observed-focus request. +- If the identity disappeared, the screen uses an explicitly tested nearest/first eligible fallback. + +Identity shapes vary by surface: + +- row feeds: section ID plus content ID; +- flat grids/lists: content ID; +- profile selection: profile ID; +- cascade selector: library ID; +- Calendar: day/shelf identity plus content ID where needed. + +Saved numeric indices may remain as fallback coordinates, but never as the primary identity after disposal or refresh. + +This covers Search, Browse, Calendar, libraries, Skyline feeds, Watchlist, Favorites, History, people, Collections, Requests/My Requests, and other content routes currently bypassing the shell wrapper. + +### 4. Enabled-state correctness + +Reusable TV controls will propagate `enabled` to their actual clickable/focusable primitive rather than only guarding callbacks or changing alpha. Disabled controls must: + +- be skipped by D-pad focus search; +- expose disabled semantics; +- reject activation at the primitive; +- never be selected as an initial-focus target. + +Affected reusable primitives and call sites include option rows, Aurora buttons, PIN/join-code keys, card-overlay reset, admin scan actions, and busy/invalid auth actions. Tests will target reusable primitives first, then representative high-risk call sites. + +Selectors derive interactivity from their final actionable option model. A subtitle selector with Auto, Off, and one physical track remains interactive. A genuinely noninteractive selector must not become a focusable no-op. + +### 5. Asynchronous eligibility state machines + +Initial focus must be keyed to the stable identity of the first currently eligible action, not only screen entry or an unrelated completion field. + +- Pair Device recomputes its target across loading, resolved, error, approving, and completed states. +- Server List retries rejected requests until bounded success/exhaustion. +- Collections latch only observed acquisition and notify the shell only after acquisition. +- Profile selection distinguishes first materialization from refresh and restores the focused profile ID or nearest survivor. +- Calendar freezes Up movement while an offscreen control handoff is already in progress. + +Pure target-selection and transition functions will carry most unit coverage. Compose/device tests verify attachment and observed ownership. + +### 6. IME and form traversal + +Phone fields will not install a no-op `KeyboardActions.onAny`. Each field family will either: + +- leave `ImeAction.Next` to default focus traversal when no callback is supplied; or +- explicitly call `FocusManager.moveFocus(FocusDirection.Next)`. + +Go/Done actions continue invoking their supplied callback once. + +Create Collection will share the TV text-input lifecycle policy: keyboard show only after field focus, `imePadding`, hide on explicit completion/dismissal, and hide again on disposal as a safety net. + +## Finding coverage + +The series must address every verified audit finding: + +- generic detail-return bypass across content routes; +- Browse filter focus trap and opener restoration; +- Skyline/library index-based restoration; +- Calendar repeat leakage during an in-flight offscreen handoff; +- phone `ImeAction.Next` interception; +- visually disabled but focusable TV controls; +- server-list false-return retry termination; +- Collections failed-attempt latching; +- Pair Device missing loading-to-ready handoff; +- Create Collection TV IME cleanup; +- Cascade rows missing stable keys; +- unbounded dialog initial-focus retry; +- profile refresh stealing focus; +- audiobook More/About overlay ownership; +- detail handoffs conflating execution, Boolean acceptance, and observed focus; +- AI Translate empty state without a target; +- single-subtitle selector dead focus stop. + +The duplicate Cascade finding is implemented once. Runtime-only hypotheses remain verification scenarios unless device evidence promotes them to defects. + +## Delivery series + +### Series A — Focus foundations and enabled controls + +- Add and test bounded observed-focus policy. +- Bound dialog initial-focus behavior. +- Propagate enabled state through reusable TV primitives and representative call sites. +- Add stable Cascade keys. +- Correct single-subtitle selector interactivity. + +This series creates the primitives needed by later migrations without changing shell restoration. + +### Series B — Async screens and IME + +- Fix Server List, Collections, Pair Device, and Profile Selection transitions. +- Correct phone Next traversal. +- Add Create Collection TV IME cleanup. +- Finish Calendar in-flight repeat freezing. + +### Series C — Modal ownership + +- Make Browse Filters a true modal focus scope with opener restoration. +- Make audiobook panels own focus and disable the covered player. +- Add AI Translate empty-state Close focus and bounded acquisition. + +### Series D — Stable content restoration + +- Route all media-detail openings through shell handoff. +- Introduce per-surface stable return targets and resolvers. +- Migrate Skyline, library grids, Search, Calendar, personal lists, people, Collections, and Requests. +- Cover reorder, insertion, removal, offscreen placement, recreation, and fallback. + +Because this is the largest series, implementation plans may split it into feed, grid, and heterogeneous-screen tasks while retaining one shared contract. + +### Series E — Detail and playback handoffs + +- Require observed focus for cast return. +- Correct false-return handling after hero scroll. +- Verify player HUD/overlay transitions against the shared policy where applicable. + +### Series F — Integrated verification + +- Run all focused JVM tests after every task. +- Run module and full repository tests at series boundaries. +- Assemble phone and TV debug artifacts. +- Run formatting/diff hygiene checks. +- Execute the device matrix below before release. + +## Testing strategy + +All behavioral changes use red-green TDD. + +### Pure JVM tests + +- bounded retry: false, exception, accepted-but-unobserved, observed, exhausted, stale identity, cancellation; +- stable target resolution across reorder, insertion, cross-row move, removal, and empty data; +- async eligible-target transitions; +- Calendar in-flight repeat actions; +- selector actionability from final options; +- profile survivor fallback; +- IME action policy. + +### Compose focus tests + +- disabled primitives expose disabled semantics and are skipped; +- modal boundary traversal cannot reach covered content; +- dismissal restores the opener; +- async attachment succeeds after initial rejection; +- dynamic keyed rows retain identity after reorder; +- empty/error/all-disabled branches retain a focusable escape. + +Where local JVM Compose tests cannot faithfully model platform focus windows, add instrumented tests and retain a physical-device gate. + +### Regression matrix + +- every direction at first/last targets; +- press versus held repeat; +- loading, populated, empty, error, and disabled states; +- warm recomposition, screen disposal/recreation, and process-saved state; +- refresh/reorder/removal while behind detail or while an overlay is open; +- Back, outside dismiss, selection dismiss, and successful completion; +- keyboard visible/hidden and resize/pan behavior; +- explicit root reselection versus natural detail return. + +## Device validation + +Run on at least one Shield/Google TV device and one Fire TV device: + +- full top-menu and content-route D-pad sweep; +- native held-key repeat races; +- exact Back restoration from offscreen items on every content family; +- refresh/reorder/removal during detail return; +- Browse and audiobook modal boundary traversal; +- player HUD, subtitle search, AI Translate, and popup key routing; +- Gboard/Leanback/Fire TV keyboard traversal and cleanup; +- TalkBack/Switch Access disabled-state and traversal checks where supported. + +Device failures are converted into reproducible tests or explicit platform-specific guards before release. + +## Non-goals + +- No global singleton focus coordinator. +- No navigation redesign or visual redesign. +- No unrelated media, networking, or settings refactor. +- No assumption that one platform's spatial focus behavior proves another's. +- No merging of the follow-up series into PR #164. + +## Completion criteria + +- Every verified audit finding maps to an implemented task and regression test. +- All new focus requests that affect correctness either observe acquisition or have a documented reason not to. +- Dynamic restoration uses stable identity. +- Modal surfaces own and restore focus deterministically. +- Disabled controls agree across visuals, input, focus, and semantics. +- Phone and TV IME flows pass their traversal/cleanup tests. +- Full automated suites and both debug builds succeed. +- Required Shield and Fire TV scenarios pass or are documented as release blockers. diff --git a/docs/superpowers/specs/2026-08-12-android-hosted-diagnostics-design.md b/docs/superpowers/specs/2026-08-12-android-hosted-diagnostics-design.md new file mode 100644 index 000000000..e8ba96645 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-android-hosted-diagnostics-design.md @@ -0,0 +1,82 @@ +# Android Hosted Diagnostics Design + +## Goal + +Offer a Silo-operated diagnostics destination on phone and TV without adding a +third-party observability SDK or weakening the existing local review, consent, +identity, retention, and erasure boundaries. Hosted is the device default; +self-hosted ingest remains selectable for deployments that operate it. + +## Destination policy + +Hosted reports target `diagnostics.siloserver.org` and the compile-time collector +identity `silo-public-diagnostics-v1`. Hosted consent is manual/Ask-only: the +client never turns a hosted report into an unattended crash upload. The hosted +collector advertises schema, size, notice, and 30-day remote-retention policy. +Local unsent evidence retains the existing seven-day limit. + +Self-hosted reports retain the originating server's status, account binding, +profile attribution, consent modes, and upload authorization. A report's +destination is frozen at capture and cannot be changed during upload. + +## Identity and capture boundary + +The hosted manifest omits source server, account, and profile identifiers. Local +sidecar state binds evidence to a one-way owner derived from the active local +server ID and authenticated Silo account ID. This owner is persisted per local +server, survives ordinary access/refresh-token rotation, and is erased on +persistent account sign-out, server removal, or account replacement. Clearing a +temporary TV authentication overlay does not erase the persistent account it +was layered over. + +Immediately before a manual or timed capture starts, the client fetches hosted +capabilities and requires the exact collector ID. Cached capabilities are useful +for presenting retained evidence, but cannot authorize a new live capture. The +collector check uses the public collector only; it does not send Silo account +credentials to that service. + +Immediately before the first hosted create/upload, the client performs a live, +authenticated `getCurrentUser` lookup against the active Silo server. Its +one-way owner must match the report binding before transport starts and again at +the final identity check. JWT claims and the persisted owner support local/offline +display only; neither substitutes for first-upload account attestation. + +## Transport lifecycle + +The client persists the exact sanitized hosted envelope before create, then +replays it for ambiguous retries. A validated `processing` receipt is an accepted +remote state, not a failure: local evidence is retained, the reference is shown, +and WorkManager polls status until `ready` or rejection. Only `ready` records sent +history and removes the local report. WorkManager refreshes coordinator identity +state before each attempt and retries source-server unavailability. If the +collector rejects the active pseudonymous installation credential, the client +registers a replacement while retaining one encrypted fallback credential for +status and erasure of already-submitted reports; exact-value redaction covers +both credentials. + +Local retention or quota eviction of an accepted but still-processing report +keeps only its UUID and binding as remote-erasure authority. It does not promote +the processing reference into READY history; only a validated READY response +may do that. + +Remote erasure is intent-first. Local deletion persists the hosted report ID, +hides/removes local evidence, and reconciles remote DELETE asynchronously so a +slow collector cannot block diagnostics UI or identity commands. A +network-constrained WorkManager job drains these durable intents after process +death. Collector failures remain queued because an ownership-hiding response +cannot prove that the remote report was erased. + +## Bundle boundaries + +Privacy sanitization and manifest policy remain in `FileDiagnosticsBundleBuilder`. +Deterministic USTAR/gzip encoding and hashing live in +`DiagnosticsArchiveEncoder`, keeping archive mechanics independent of hosted +privacy admission rules. Hosted envelopes omit opaque tombstones and apply the +existing identifier, URL, device, stack, and log allowlists before encoding. + +## Verification + +Tests cover live collector refusal, live account refusal, stable ownership across +token rotation, owner erasure with server purge, processing-state polling, +worker retry behavior, non-blocking remote deletion, deterministic archives, and +the existing diagnostics privacy/integration contract. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8c05458b5..d64f9ab67 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -3,7 +3,7 @@ require "fileutils" require "tmpdir" # Root-level Fastfile — Android lane for Google Play releases. Mirrors the -# structure of prairie-apple's Fastfile: +# structure of silo-apple's Fastfile: # # android beta — build both App Bundles, upload to a Play testing track # @@ -38,14 +38,14 @@ TV_AAB = File.expand_path("../androidTvApp/build/outputs/bundle/release/androidT # Loads the Play publisher service-account key. # # CI path: raw JSON from the PLAY_SERVICE_ACCOUNT_JSON env var. -# Local path: read ~/.playstore/prairie-play-publisher.json from disk. +# Local path: read ~/.playstore/silo-play-publisher.json from disk. # -# Same CI/local duality as prairie-apple's setup_api_key. +# Same CI/local duality as silo-apple's setup_api_key. def play_json_key_data raw = ENV["PLAY_SERVICE_ACCOUNT_JSON"].to_s return raw unless raw.strip.empty? - key_path = File.expand_path("~/.playstore/prairie-play-publisher.json") + key_path = File.expand_path("~/.playstore/silo-play-publisher.json") UI.user_error!( "Play service-account key not found at #{key_path}. " \ "Either place the JSON key file there or set PLAY_SERVICE_ACCOUNT_JSON." @@ -62,7 +62,7 @@ end # TestFlight model, folded into the versionCode because Play has no separate # build-number field. The value also flows into Gradle -P properties, so the # strict format doubles as the last gate against shell/property injection -# (same threat model as prairie-apple's marketing_version_xcarg). +# (same threat model as silo-apple's marketing_version_xcarg). def resolve_release_version version = ENV["PRAIRIE_VERSION_NAME"].to_s.strip UI.user_error!("PRAIRIE_VERSION_NAME must be set (e.g. 1.4.0)") if version.empty? @@ -126,7 +126,7 @@ end def resolve_release_keystore b64 = ENV["PRAIRIE_RELEASE_KEYSTORE_B64"].to_s unless b64.strip.empty? - keystore_path = File.join(Dir.mktmpdir("prairie-release-keystore"), "prairie-release.jks") + keystore_path = File.join(Dir.mktmpdir("silo-release-keystore"), "silo-release.jks") File.binwrite(keystore_path, Base64.decode64(b64)) return keystore_path end @@ -180,7 +180,7 @@ end # Play's "What's new" note (500-char limit), written to the supply metadata # changelog that applies to every version code in the release (default.txt). # Set PLAY_RELEASE_NOTES for an explicit note; falls back to the released -# commit's subject — same pattern as prairie-apple's TESTFLIGHT_CHANGELOG. +# commit's subject — same pattern as silo-apple's TESTFLIGHT_CHANGELOG. def write_play_changelog notes = ENV["PLAY_RELEASE_NOTES"].to_s.strip notes = sh("git log -1 --pretty=format:%s", log: false).strip if notes.empty? @@ -206,7 +206,7 @@ platform :android do write_play_changelog UI.message( - "Releasing Prairie #{version} build #{build} to the #{track} track " \ + "Releasing Silo #{version} build #{build} to the #{track} track " \ "(base code #{base_code} → phone #{base_code * 2}, TV #{base_code * 2 + 1})" ) @@ -218,8 +218,15 @@ platform :android do gradle( tasks: [":androidApp:bundleRelease", ":androidTvApp:bundleRelease"], properties: { - "prairieVersionName" => version, - "prairieVersionCode" => base_code, + "siloVersionName" => version, + "siloVersionCode" => base_code, + # The build counter as its own value: it is already folded into + # base_code, but the app reports it verbatim to the server. + "siloBuildNumber" => build, + # The track these bundles are actually uploaded to, so a beta-track + # tester and a production user are distinguishable in admin Activity + # instead of both reporting a generic "release". + "siloReleaseChannel" => track, }, system_properties: { "org.gradle.jvmargs" => "-Xmx4g -Dfile.encoding=UTF-8", @@ -255,6 +262,6 @@ platform :android do timeout: 900, ) - UI.success("Uploaded Prairie #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") + UI.success("Uploaded Silo #{version} build #{build} (phone #{base_code * 2} + TV #{base_code * 2 + 1}) to the #{track} track.") end end diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3e9ff5d3..df47ca38d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,13 @@ lifecycle-kmp = "2.9.6" navigation = "2.9.8" activity-compose = "1.12.4" tv-compose = "1.0.1" +haze = "1.6.10" desugar-jdk-libs = "2.1.5" robolectric = "4.16.1" +# Compose UI test artifacts: pin to the androidx.compose.ui the app already +# resolves at runtime, so the Robolectric harness exercises the same runtime +# the app ships rather than dragging in a newer one. +compose-ui-test = "1.9.2" ksp = "2.1.20-2.0.1" room = "2.8.4" androidx-test-core = "1.6.1" @@ -40,6 +45,7 @@ jsoup = "1.22.2" # Google Cast (Chromecast, phone app only) + its MediaRouter dependency. play-services-cast-framework = "21.5.0" androidx-mediarouter = "1.7.0" +androidx-window = "1.4.0" kover = "0.9.1" [libraries] @@ -48,6 +54,8 @@ kover = "0.9.1" bouncycastle-prov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } bouncycastle-tls = { module = "org.bouncycastle:bctls-jdk18on", version.ref = "bouncycastle" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-ui-test" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose-ui-test" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } @@ -90,6 +98,10 @@ navigation-compose = { module = "androidx.navigation:navigation-compose", versio coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +# Haze — backdrop blur for the phone app's floating tab bar (real blur on +# API 31+, tinted scrim fallback below). +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } + # Compose for TV — tv-foundation was merged into tv-material in 1.0.0 stable. tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-compose" } @@ -135,6 +147,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } # cast-capability playback session; the raw phone stream is never cast. play-services-cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "play-services-cast-framework" } androidx-mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "androidx-mediarouter" } +androidx-window = { module = "androidx.window:window", version.ref = "androidx-window" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 8420b37d4..6431158a1 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -21,6 +21,24 @@ + + + + + + + + + + + + + + + + + + @@ -34,6 +52,19 @@ + + + + + + + + + + + + + @@ -50,6 +81,24 @@ + + + + + + + + + + + + + + + + + + @@ -332,6 +381,11 @@ + + + + + @@ -539,6 +593,14 @@ + + + + + + + + @@ -578,6 +640,16 @@ + + + + + + + + + + @@ -713,6 +785,11 @@ + + + + + @@ -751,6 +828,11 @@ + + + + + @@ -808,6 +890,11 @@ + + + + + @@ -830,6 +917,9 @@ + + + @@ -882,6 +972,9 @@ + + + @@ -947,6 +1040,9 @@ + + + @@ -988,6 +1084,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1025,6 +1161,9 @@ + + + @@ -1085,6 +1224,9 @@ + + + @@ -1155,6 +1297,9 @@ + + + @@ -1293,6 +1438,16 @@ + + + + + + + + + + @@ -1527,6 +1682,11 @@ + + + + + @@ -1654,11 +1814,21 @@ + + + + + + + + + + @@ -1688,6 +1858,11 @@ + + + + + @@ -1730,6 +1905,16 @@ + + + + + + + + + + @@ -1777,6 +1962,16 @@ + + + + + + + + + + @@ -1811,11 +2006,21 @@ + + + + + + + + + + @@ -1837,6 +2042,11 @@ + + + + + @@ -1863,6 +2073,11 @@ + + + + + @@ -1881,6 +2096,11 @@ + + + + + @@ -1918,11 +2138,21 @@ + + + + + + + + + + @@ -1944,6 +2174,11 @@ + + + + + @@ -1994,6 +2229,21 @@ + + + + + + + + + + + + + + + @@ -2013,6 +2263,11 @@ + + + + + @@ -2061,6 +2316,16 @@ + + + + + + + + + + @@ -2082,6 +2347,21 @@ + + + + + + + + + + + + + + + @@ -2469,6 +2749,9 @@ + + + @@ -2622,6 +2905,11 @@ + + + + + @@ -2645,6 +2933,11 @@ + + + + + @@ -2666,6 +2959,14 @@ + + + + + + + + @@ -2684,6 +2985,11 @@ + + + + + @@ -2705,6 +3011,14 @@ + + + + + + + + @@ -2739,6 +3053,11 @@ + + + + + @@ -2757,6 +3076,14 @@ + + + + + + + + @@ -2821,6 +3148,11 @@ + + + + + @@ -2885,6 +3217,14 @@ + + + + + + + + @@ -2893,6 +3233,14 @@ + + + + + + + + @@ -2925,6 +3273,14 @@ + + + + + + + + @@ -2962,6 +3318,19 @@ + + + + + + + + + + + + + @@ -2983,6 +3352,14 @@ + + + + + + + + @@ -3039,6 +3416,11 @@ + + + + + @@ -3063,6 +3445,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -3943,6 +4346,14 @@ + + + + + + + + @@ -4758,6 +5169,14 @@ + + + + + + + + @@ -4994,6 +5413,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6518,6 +6961,22 @@ + + + + + + + + + + + + + + + + @@ -7739,6 +8198,7 @@ + diff --git a/libass-bridge/src/main/java/org/prairieserver/prairie/libass/LibassBridge.java b/libass-bridge/src/main/java/org/prairieserver/prairie/libass/LibassBridge.java index 5849c6b0c..48ad93509 100644 --- a/libass-bridge/src/main/java/org/prairieserver/prairie/libass/LibassBridge.java +++ b/libass-bridge/src/main/java/org/prairieserver/prairie/libass/LibassBridge.java @@ -296,6 +296,35 @@ public void attachTo(SubtitleView host) { syncOverlayFrameSizeLater(); } + /** + * Keeps the libass overlay on the PICTURE when its host canvas reaches past + * it. + * + * The overlay is normally MATCH_PARENT of the SubtitleView, and libass + * scales the script to the frame it is handed + * ({@link #syncOverlayFrameSize()}). Silo's screen-anchored "Bottom" + * subtitle preset extends that canvas down into the letterbox bar, which + * an overlay that simply followed it would answer by stretching the + * author's typesetting into the bar as well. ASS keeps its authored + * placement on every preset, so the overlay stays the picture's height and + * the canvas grows underneath it. The host lays children out from its top, + * which the canvas shares with the picture, so the height is the whole + * correction. + * + * @param heightPx the picture's height inside the host, or any + * non-positive value to restore the full host. + */ + public void constrainOverlayHeight(int heightPx) { + AssSubtitleView overlay = overlayRef.get(); + if (overlay == null) return; + ViewGroup.LayoutParams params = overlay.getLayoutParams(); + if (params == null) return; + int target = heightPx > 0 ? heightPx : ViewGroup.LayoutParams.MATCH_PARENT; + if (params.height == target) return; + params.height = target; + overlay.setLayoutParams(params); + } + /** Removes the view that still points at the retiring handler. */ private void retireOverlay() { AssSubtitleView overlay = overlayRef.get(); diff --git a/scripts/test-release-workflow.sh b/scripts/test-release-workflow.sh new file mode 100755 index 000000000..9da85f981 --- /dev/null +++ b/scripts/test-release-workflow.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +workflow_file="${repo_root}/.github/workflows/release.yml" +failures=0 + +publish_job="$({ + awk ' + /^ publish-release:[[:space:]]*$/ { + in_publish_job = 1 + } + in_publish_job && + /^ [[:alnum:]_-]+:[[:space:]]*$/ && + $0 !~ /^ publish-release:/ { + exit + } + in_publish_job { + print + } + ' "${workflow_file}" +} | sed '/^[[:space:]]*#/d')" +publish_job_header="$( + awk '/^ steps:[[:space:]]*$/ { exit } { print }' <<< "${publish_job}" +)" + +expect_publish_job_header_to_contain() { + local description="$1" + local expected="$2" + + if ! grep -Fq "${expected}" <<< "${publish_job_header}"; then + printf 'FAIL: publish-release must %s\n' "${description}" >&2 + failures=$((failures + 1)) + fi +} + +expect_publish_job_header_to_contain \ + "depend on setup and APK artifacts" \ + "needs: [setup, apks]" +expect_publish_job_header_to_contain \ + "define an explicit job condition" \ + "if: >-" +expect_publish_job_header_to_contain \ + "stop when the workflow is cancelled" \ + "!cancelled()" +expect_publish_job_header_to_contain \ + "require successful setup" \ + "needs.setup.result == 'success'" +expect_publish_job_header_to_contain \ + "require successful APK builds" \ + "needs.apks.result == 'success'" + +if ((failures > 0)); then + printf '%d release workflow self-test(s) failed\n' "${failures}" >&2 + exit 1 +fi + +printf 'All release workflow self-tests passed\n' diff --git a/settings.gradle.kts b/settings.gradle.kts index f79c976a7..94bae7c1d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,3 +20,4 @@ include(":android-shared") include(":androidApp") include(":androidTvApp") include(":baselineprofile") +include(":baselineprofile-tv") diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt new file mode 100644 index 000000000..1e802b81f --- /dev/null +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/model/settings/LanguagePresentation.android.kt @@ -0,0 +1,28 @@ +package org.prairieserver.prairie.model.settings + +import java.util.Locale + +internal actual fun localizedLanguageName(tag: String): String { + val locale = Locale.forLanguageTag(tag.replace('_', '-')) + return locale.getDisplayName(Locale.getDefault()).takeIf { it.isNotBlank() } + ?: tag +} + +internal actual fun canonicalLanguageIdentity(tag: String): String { + val parts = tag.replace('_', '-').split('-').toMutableList() + if (parts.isEmpty()) return tag.lowercase(Locale.ROOT) + + val primary = parts.first().lowercase(Locale.ROOT) + val canonicalPrimary = if (primary.length == 3) { + Locale.getISOLanguages().firstOrNull { twoLetter -> + runCatching { + Locale.forLanguageTag(twoLetter).isO3Language.equals(primary, ignoreCase = true) + } + .getOrDefault(false) + } ?: primary + } else { + primary + } + parts[0] = canonicalPrimary + return parts.joinToString("-").lowercase(Locale.ROOT) +} diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt index ddb110e71..f2e7ce59d 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt @@ -34,6 +34,8 @@ import kotlinx.serialization.json.Json class AndroidServerRegistry( private val prefs: SharedPreferences, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + private val commitEditor: (SharedPreferences.Editor) -> Boolean = SharedPreferences.Editor::commit, + private val afterServerRemovalCommit: () -> Unit = {}, ) : ServerRegistry { private val mutex = Mutex() @@ -120,7 +122,15 @@ class AndroidServerRegistry( } override suspend fun remove(serverId: String) { - identityTransitions.changing(IdentityTransitionKind.SERVER_REMOVE) { + identityTransitions.changing( + kind = IdentityTransitionKind.SERVER_REMOVE, + target = { + IdentityTransitionTarget( + serverId = serverId, + affectsCurrentIdentity = _activeServerId.value == serverId, + ) + }, + ) { mutex.withLock { // Wipe every key in this server's "." namespace before // dropping the entry, otherwise they'd linger encrypted on disk @@ -133,13 +143,18 @@ class AndroidServerRegistry( prefs.all.keys .filter { it.startsWith(scopePrefix) } .forEach { editor.remove(it) } - editor.apply() val updated = _entries.value.filter { it.id != serverId } val newActive = if (_activeServerId.value == serverId) { updated.maxByOrNull { it.lastUsedAtEpochMs }?.id } else _activeServerId.value - persistAndApplyLocked(updated, newActive) + val resolvedActive = newActive?.takeIf { id -> updated.any { it.id == id } } + ?: updated.maxByOrNull { it.lastUsedAtEpochMs }?.id + val state = RegistryState(entries = updated, activeServerId = resolvedActive) + editor.putString(KEY_REGISTRY_STATE, json.encodeToString(state)) + check(commitEditor(editor)) { "unable to durably remove server" } + applyStateLocked(state) + afterServerRemovalCommit() } } } @@ -167,6 +182,68 @@ class AndroidServerRegistry( } } + /** + * Commits registry selection and its matching account credentials in the + * one SharedPreferences transaction available to both owners. In-memory + * registry state is published only after the synchronous disk commit. + */ + internal suspend fun commitAccountReplacement( + serverId: String, + profileId: String?, + profileToken: String?, + accessToken: String, + refreshToken: String, + expiryEpochMs: Long, + lifetimeMs: Long, + ) { + mutex.withLock { + check(_entries.value.any { it.id == serverId }) { "account replacement target is not registered" } + val updated = _entries.value.map { entry -> + if (entry.id == serverId) { + entry.copy( + profileId = profileId, + lastUsedAtEpochMs = System.currentTimeMillis(), + ) + } else { + entry + } + } + val state = RegistryState(entries = updated, activeServerId = serverId) + val editor = prefs.edit() + .putString(KEY_REGISTRY_STATE, json.encodeToString(state)) + .putString(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN), accessToken) + .putString(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN), refreshToken) + .putLong(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_EXPIRY), expiryEpochMs) + .putLong(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_LIFETIME), lifetimeMs) + val profileIdKey = serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_PROFILE_ID) + val profileTokenKey = serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_PROFILE_TOKEN) + if (profileId == null) editor.remove(profileIdKey) else editor.putString(profileIdKey, profileId) + if (profileToken == null) editor.remove(profileTokenKey) else editor.putString(profileTokenKey, profileToken) + check(commitEditor(editor)) { "unable to durably replace account session" } + applyStateLocked(state) + } + } + + internal suspend fun commitAccountSignOut(serverId: String) { + mutex.withLock { + check(_entries.value.any { it.id == serverId }) { "sign-out target is not registered" } + val updated = _entries.value.map { entry -> + if (entry.id == serverId) entry.copy(profileId = null) else entry + } + val state = RegistryState(entries = updated, activeServerId = _activeServerId.value) + val editor = prefs.edit() + .putString(KEY_REGISTRY_STATE, json.encodeToString(state)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_REFRESH_TOKEN)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_EXPIRY)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_TOKEN_LIFETIME)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_PROFILE_ID)) + .remove(serverScopedKey(serverId, EncryptedTokenManagerImpl.KEY_PROFILE_TOKEN)) + check(commitEditor(editor)) { "unable to durably sign out account" } + applyStateLocked(state) + } + } + override suspend fun touchActive() { mutex.withLock { val activeId = _activeServerId.value ?: return diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt index 6b562d164..850972b77 100644 --- a/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerImpl.kt @@ -34,6 +34,8 @@ class EncryptedTokenManagerImpl( private val prefs: SharedPreferences, private val registry: ServerRegistry, private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + private val afterAccountSessionCommit: suspend () -> Unit = {}, + private val afterAccountSignOutCommit: suspend () -> Unit = {}, ) : TokenManager { private val mutex = Mutex() @@ -46,6 +48,9 @@ class EncryptedTokenManagerImpl( private var accessToken: String? = null private var refreshToken: String? = null private var tokenExpiryEpochMs: Long? = null + + /** Lifetime the server gave the active access token, for the half-life clamp. */ + private var tokenLifetimeMs: Long? = null private var profileId: String? = null private var profileToken: String? = null private var temporaryScope: TemporaryAuthScope? = null @@ -102,6 +107,34 @@ class EncryptedTokenManagerImpl( temporaryScope?.refreshToken ?: refreshToken } + /** + * Answers for whichever identity is actually installed: a remote-playback + * overlay carries its own deadline, and falling through to the saved + * account's would refresh credentials this request is not spending. + */ + override suspend fun accessTokenExpiresWithin(marginMs: Long): Boolean = mutex.withLock { + ensureCacheMatchesRegistryLocked() + temporaryScope?.let { overlay -> + // NOT expiresAtEpochMs — that is when the temporary SESSION ends, + // which can be hours past the access token it was issued with. + // Unknown until a refresh has told us, and unknown means leave it + // to the reactive 401 rather than refresh on a guess. + val overlayExpiry = overlay.accessTokenExpiresAtEpochMs ?: return@withLock false + return@withLock shouldRefreshProactively( + remainingMs = overlayExpiry - System.currentTimeMillis(), + lifetimeMs = overlay.accessTokenLifetimeMs, + marginMs = marginMs, + ) + } + if (accessToken == null) return@withLock false + val expiry = tokenExpiryEpochMs ?: return@withLock false + shouldRefreshProactively( + remainingMs = expiry - System.currentTimeMillis(), + lifetimeMs = tokenLifetimeMs, + marginMs = marginMs, + ) + } + /** * The registry observer flushes the cache asynchronously; between a * direct `ServerRegistry.switchTo()` and that collector running there is @@ -136,34 +169,98 @@ class EncryptedTokenManagerImpl( } } + override suspend fun replaceAccountSession( + serverId: String?, + serverUrl: String?, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String?, + profileToken: String?, + ) { + val targetServerId = serverId + ?: serverUrl?.let { registry.addOrUpdate(it) } + ?: registry.activeServerId.value + ?: error("account replacement requires a registered server") + val androidRegistry = registry as? AndroidServerRegistry + ?: error("persistent account replacement requires AndroidServerRegistry") + tokenWriteMutex.withLock { + identityTransitions.changing( + kind = IdentityTransitionKind.ACCOUNT_REPLACE, + target = { + check(mutex.withLock { temporaryScope == null }) { + "cannot replace the account inside a temporary auth scope" + } + IdentityTransitionTarget(serverId = targetServerId) + }, + ) { + mutex.withLock { + val lifetimeMs = expiresIn * 1_000L + val expiryEpochMs = System.currentTimeMillis() + lifetimeMs + // Registry selection and token/profile slots share the same + // encrypted preferences file, so commit them atomically and + // synchronously before publishing the cache. + androidRegistry.commitAccountReplacement( + serverId = targetServerId, + profileId = profileId, + profileToken = profileToken, + accessToken = accessToken, + refreshToken = refreshToken, + expiryEpochMs = expiryEpochMs, + lifetimeMs = lifetimeMs, + ) + activeServerId = targetServerId + this.profileId = profileId + this.profileToken = profileToken + this.accessToken = accessToken + this.refreshToken = refreshToken + tokenExpiryEpochMs = expiryEpochMs + tokenLifetimeMs = lifetimeMs + persistentCredentialEpoch += 1 + afterAccountSessionCommit() + } + } + } + } + private suspend fun saveActiveTokensLocked(accessToken: String, refreshToken: String, expiresIn: Long) { mutex.withLock { ensureCacheMatchesRegistryLocked() temporaryScope?.let { scope -> + // The SESSION deadline (expiresAtEpochMs) is untouched: a + // refresh renews the access token, not the temporary session. temporaryScope = scope.copy( accessToken = accessToken, refreshToken = refreshToken, - expiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L, + accessTokenExpiresAtEpochMs = + System.currentTimeMillis() + expiresIn * 1000L, + accessTokenLifetimeMs = expiresIn * 1000L, ) return@withLock } val serverId = activeServerId ?: return@withLock this.accessToken = accessToken this.refreshToken = refreshToken - val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L + val lifetimeMs = expiresIn * 1000L + val expiryEpochMs = System.currentTimeMillis() + lifetimeMs this.tokenExpiryEpochMs = expiryEpochMs + this.tokenLifetimeMs = lifetimeMs persistentCredentialEpoch += 1 prefs.edit() .putString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), accessToken) .putString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), refreshToken) .putLong(serverScopedKey(serverId, KEY_TOKEN_EXPIRY), expiryEpochMs) + .putLong(serverScopedKey(serverId, KEY_TOKEN_LIFETIME), lifetimeMs) .apply() } } override suspend fun clearTokens() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { clearCurrentScopeLocked() } } } @@ -171,7 +268,10 @@ class EncryptedTokenManagerImpl( override suspend fun invalidateSession() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { val wasTemporary = temporaryScope != null clearCurrentScopeLocked() @@ -181,7 +281,7 @@ class EncryptedTokenManagerImpl( } } - private fun clearCurrentScopeLocked() { + private suspend fun clearCurrentScopeLocked() { if (temporaryScope != null) { temporaryScope = null } else { @@ -189,23 +289,34 @@ class EncryptedTokenManagerImpl( } } - private fun clearPersistentTokensLocked() { - persistentCredentialEpoch += 1 + private suspend fun clearPersistentTokensLocked() { val serverId = activeServerId + var committedSignOut = false + if (serverId != null) { + val androidRegistry = registry as? AndroidServerRegistry + if (androidRegistry != null) { + androidRegistry.commitAccountSignOut(serverId) + } else { + val editor = prefs.edit() + .remove(serverScopedKey(serverId, KEY_ACCESS_TOKEN)) + .remove(serverScopedKey(serverId, KEY_REFRESH_TOKEN)) + .remove(serverScopedKey(serverId, KEY_TOKEN_EXPIRY)) + .remove(serverScopedKey(serverId, KEY_TOKEN_LIFETIME)) + .remove(serverScopedKey(serverId, KEY_PROFILE_ID)) + .remove(serverScopedKey(serverId, KEY_PROFILE_TOKEN)) + check(editor.commit()) { "unable to durably sign out account" } + registry.signOut(serverId) + } + committedSignOut = true + } + persistentCredentialEpoch += 1 accessToken = null refreshToken = null tokenExpiryEpochMs = null + tokenLifetimeMs = null profileId = null profileToken = null - if (serverId != null) { - prefs.edit() - .remove(serverScopedKey(serverId, KEY_ACCESS_TOKEN)) - .remove(serverScopedKey(serverId, KEY_REFRESH_TOKEN)) - .remove(serverScopedKey(serverId, KEY_TOKEN_EXPIRY)) - .remove(serverScopedKey(serverId, KEY_PROFILE_ID)) - .remove(serverScopedKey(serverId, KEY_PROFILE_TOKEN)) - .apply() - } + if (committedSignOut) afterAccountSignOutCommit() } override suspend fun getProfileId(): String? = mutex.withLock { @@ -250,6 +361,45 @@ class EncryptedTokenManagerImpl( } } + override suspend fun getProfileIdentity(): ProfileIdentity = mutex.withLock { + ensureCacheMatchesRegistryLocked() + temporaryScope?.let { scope -> + return@withLock ProfileIdentity(scope.profileId, scope.profileToken) + } + ProfileIdentity(profileId, profileToken) + } + + /** + * One lock, one preferences edit, so the PERSISTED id and token cannot + * disagree even if the process dies immediately after. Concurrent readers + * are a separate problem — see [TokenManager.setProfileIdentity]. + * + * While a temporary overlay exists this refuses the write entirely rather + * than merging into it: remote-playback identity belongs to the overlay, + * and a partial merge is what produced the id/token mismatch in the first + * place. + */ + override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) { + mutex.withLock { + // A temporary overlay owns its own identity for the lifetime of a + // remote-playback handoff. Merging a profile commit into it is how + // you get the exact defect this method exists to prevent: writing + // the new profile id beside the overlay's old token. Leave it + // alone; the repository rejects the commit outright. + if (temporaryScope != null) return@withLock + val serverId = activeServerId ?: return + if (this.profileId == profileId && this.profileToken == profileToken) return + this.profileId = profileId + this.profileToken = profileToken + val idKey = serverScopedKey(serverId, KEY_PROFILE_ID) + val tokenKey = serverScopedKey(serverId, KEY_PROFILE_TOKEN) + prefs.edit().apply { + if (profileId == null) remove(idKey) else putString(idKey, profileId) + if (profileToken == null) remove(tokenKey) else putString(tokenKey, profileToken) + }.apply() + } + } + override suspend fun getServerUrl(): String = mutex.withLock { temporaryScope?.serverUrl ?: registry.activeEntry.value?.url.orEmpty() } @@ -289,7 +439,10 @@ class EncryptedTokenManagerImpl( override suspend fun signOutCurrentServer() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { clearCurrentScopeLocked() } } } @@ -323,8 +476,17 @@ class EncryptedTokenManagerImpl( profileToken = scope.profileToken, credentialGenerationId = scope.generationId, identityGeneration = identityTransitions.generation.value, + isIdentityGenerationStamped = true, ) } + // Reconcile with the registry FIRST. The registry observer is + // asynchronous, so immediately after a `switchTo(B)` this cached id can + // still be A — and every guard that trusts the snapshot then decides + // against a server the app has already left. The token reads + // (getAccessToken/getRefreshToken/getProfileId) reconcile; the snapshot + // did not, which made it disagree with them. Note getCurrentServerId + // still reads the cache directly. + ensureCacheMatchesRegistryLocked() val serverId = activeServerId ?: return@withLock null // Resolve the URL for *this* serverId from the registry entries so the // snapshot is internally consistent. Do NOT fall back to activeEntry — @@ -339,6 +501,7 @@ class EncryptedTokenManagerImpl( serverUrl = url, profileToken = profileToken, identityGeneration = identityTransitions.generation.value, + isIdentityGenerationStamped = true, credentialEpoch = persistentCredentialEpoch, ) } @@ -370,29 +533,35 @@ class EncryptedTokenManagerImpl( private fun AuthScopeSnapshot.credentialsReplaced(): Boolean = credentialEpoch != 0L && credentialEpoch != persistentCredentialEpoch - override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = mutex.withLock { - val generationId = scope.credentialGenerationId - if (generationId == null) { - if (scope.credentialsReplaced()) return@withLock null - persistentAccessToken(scope.serverId) - } else { - temporaryScope - ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } - ?.accessToken + override suspend fun getAccessTokenForScope(scope: AuthScopeSnapshot): String? = + withScopeGeneration(scope) { + mutex.withLock { + val generationId = scope.credentialGenerationId + if (generationId == null) { + if (!scope.isLivePersistentScope()) return@withLock null + persistentAccessToken(scope.serverId) + } else { + temporaryScope + ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } + ?.accessToken + } + } } - } - override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String? = mutex.withLock { - val generationId = scope.credentialGenerationId - if (generationId == null) { - if (scope.credentialsReplaced()) return@withLock null - persistentRefreshToken(scope.serverId) - } else { - temporaryScope - ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } - ?.refreshToken + override suspend fun getRefreshTokenForScope(scope: AuthScopeSnapshot): String? = + withScopeGeneration(scope) { + mutex.withLock { + val generationId = scope.credentialGenerationId + if (generationId == null) { + if (!scope.isLivePersistentScope()) return@withLock null + persistentRefreshToken(scope.serverId) + } else { + temporaryScope + ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } + ?.refreshToken + } + } } - } override suspend fun saveTokensForScope( serverId: String, @@ -401,8 +570,9 @@ class EncryptedTokenManagerImpl( expiresIn: Long, ) { mutex.withLock { - val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L - savePersistentTokens(serverId, accessToken, refreshToken, expiryEpochMs) + val lifetimeMs = expiresIn * 1000L + val expiryEpochMs = System.currentTimeMillis() + lifetimeMs + savePersistentTokens(serverId, accessToken, refreshToken, expiryEpochMs, lifetimeMs) } } @@ -412,27 +582,83 @@ class EncryptedTokenManagerImpl( refreshToken: String, expiresIn: Long, ) { - mutex.withLock { - val expiryEpochMs = System.currentTimeMillis() + expiresIn * 1000L - val generationId = scope.credentialGenerationId - if (generationId == null) { - // A stale scope must not overwrite the credentials of the login - // that replaced it. - if (scope.credentialsReplaced()) return@withLock - savePersistentTokens(scope.serverId, accessToken, refreshToken, expiryEpochMs) - return@withLock + // A hand-built persistent scope with neither a credential epoch nor a + // captured identity generation cannot prove which account issued the + // refresh request. It may still be used for compatibility reads, but a + // late response must never overwrite a same-server reauthorization. + if ( + scope.credentialGenerationId == null && + scope.credentialEpoch == 0L && + !scope.isIdentityGenerationStamped + ) { + return + } + val save: suspend () -> Unit = { + mutex.withLock { + val lifetimeMs = expiresIn * 1000L + val expiryEpochMs = System.currentTimeMillis() + lifetimeMs + val generationId = scope.credentialGenerationId + if (generationId == null) { + // A stale scope must not overwrite the credentials of the login + // that replaced it or a server removed while refresh was in flight. + if (!scope.isLivePersistentScope()) return@withLock + savePersistentTokens( + scope.serverId, + accessToken, + refreshToken, + expiryEpochMs, + lifetimeMs, + ) + return@withLock + } + val temporary = temporaryScope + ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } + ?: return@withLock + // expiresAtEpochMs is the temporary SESSION deadline and is NOT + // renewed by refreshing the access token — overwriting it here + // silently extended the guest session on every refresh. + temporaryScope = temporary.copy( + accessToken = accessToken, + refreshToken = refreshToken, + accessTokenExpiresAtEpochMs = expiryEpochMs, + accessTokenLifetimeMs = lifetimeMs, + ) } - val temporary = temporaryScope - ?.takeIf { it.generationId == generationId && it.serverId == scope.serverId } - ?: return@withLock - temporaryScope = temporary.copy( - accessToken = accessToken, - refreshToken = refreshToken, - expiresAtEpochMs = expiryEpochMs, - ) } + withScopeGeneration(scope) { save() } + } + + private suspend fun withScopeGeneration( + scope: AuthScopeSnapshot, + block: suspend () -> T, + ): T? { + // For a live-stamped saved account (credentialEpoch != 0) and a + // temporary scope, the barrier is only a serialization primitive. A + // remote-playback overlay advances the global generation but must leave + // the persistent scope valid; the epoch/generation-id checks decide its + // identity. A hand-built persistent scope has no epoch, so its captured + // identity generation is the only request provenance it can carry. + val expectedGeneration = if ( + scope.credentialGenerationId == null && + scope.credentialEpoch == 0L && + scope.isIdentityGenerationStamped + ) { + scope.identityGeneration + } else { + identityTransitions.generation.value + } + return identityTransitions.withCurrentGeneration(expectedGeneration) { + GuardedScopeValue(block()) + }?.value } + /** Keeps nullable token reads compatible with the barrier's non-null result contract. */ + private data class GuardedScopeValue(val value: T) + + private fun AuthScopeSnapshot.isLivePersistentScope(): Boolean = + !credentialsReplaced() && + registry.entries.value.any { entry -> entry.id == serverId && entry.url == serverUrl } + private fun persistentAccessToken(serverId: String): String? = if (serverId == activeServerId) accessToken else prefs.getString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), null) @@ -446,16 +672,19 @@ class EncryptedTokenManagerImpl( accessToken: String, refreshToken: String, expiryEpochMs: Long, + lifetimeMs: Long, ) { prefs.edit() .putString(serverScopedKey(serverId, KEY_ACCESS_TOKEN), accessToken) .putString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), refreshToken) .putLong(serverScopedKey(serverId, KEY_TOKEN_EXPIRY), expiryEpochMs) + .putLong(serverScopedKey(serverId, KEY_TOKEN_LIFETIME), lifetimeMs) .apply() if (serverId == activeServerId) { this.accessToken = accessToken this.refreshToken = refreshToken this.tokenExpiryEpochMs = expiryEpochMs + this.tokenLifetimeMs = lifetimeMs } } @@ -471,6 +700,7 @@ class EncryptedTokenManagerImpl( accessToken = null refreshToken = null tokenExpiryEpochMs = null + tokenLifetimeMs = null profileId = null profileToken = null return @@ -479,6 +709,8 @@ class EncryptedTokenManagerImpl( refreshToken = prefs.getString(serverScopedKey(serverId, KEY_REFRESH_TOKEN), null) val expiryKey = serverScopedKey(serverId, KEY_TOKEN_EXPIRY) tokenExpiryEpochMs = if (prefs.contains(expiryKey)) prefs.getLong(expiryKey, 0L) else null + val lifetimeKey = serverScopedKey(serverId, KEY_TOKEN_LIFETIME) + tokenLifetimeMs = if (prefs.contains(lifetimeKey)) prefs.getLong(lifetimeKey, 0L) else null profileId = prefs.getString(serverScopedKey(serverId, KEY_PROFILE_ID), null) profileToken = prefs.getString(serverScopedKey(serverId, KEY_PROFILE_TOKEN), null) } @@ -487,6 +719,7 @@ class EncryptedTokenManagerImpl( const val KEY_ACCESS_TOKEN = "access_token" const val KEY_REFRESH_TOKEN = "refresh_token" const val KEY_TOKEN_EXPIRY = "token_expiry_epoch_ms" + const val KEY_TOKEN_LIFETIME = "token_lifetime_ms" const val KEY_PROFILE_ID = "profile_id" const val KEY_PROFILE_TOKEN = "profile_token" // Retained only so [AndroidServerRegistry.migrateLegacyIfNeeded] can @@ -512,6 +745,16 @@ class EncryptedTokenManagerImpl( ?.url == scope.serverUrl } + /** Resolved under the identity-mutation mutex immediately before privacy gates run. */ + private suspend fun currentSignOutTarget(): IdentityTransitionTarget = mutex.withLock { + ensureCacheMatchesRegistryLocked() + val temporary = temporaryScope + IdentityTransitionTarget( + serverId = temporary?.serverId ?: activeServerId, + purgesPersistentIdentity = temporary == null, + ) + } + override suspend fun invalidateSessionForScope(scope: AuthScopeSnapshot): Boolean = tokenWriteMutex.withLock { val matchesBeforeTransition = mutex.withLock { @@ -523,7 +766,10 @@ class EncryptedTokenManagerImpl( } if (!matchesBeforeTransition) return@withLock false - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { IdentityTransitionTarget(serverId = scope.serverId) }, + ) { mutex.withLock { ensureCacheMatchesRegistryLocked() // `changing` increments the generation before entering this diff --git a/shared/src/androidMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.android.kt b/shared/src/androidMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.android.kt new file mode 100644 index 000000000..77506c08b --- /dev/null +++ b/shared/src/androidMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.android.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.overlays + +import java.util.Locale + +internal actual fun overlayLanguageName(tag: String): String? { + val trimmed = tag.trim() + if (trimmed.isEmpty()) return null + val locale = Locale.forLanguageTag(trimmed.replace('_', '-')) + val name = locale.getDisplayName(Locale.ENGLISH) + // `getDisplayName` echoes the tag back when ICU has no name for it; + // treat that as "unnamed" and fall back to the uppercased code. + if (name.isBlank() || name.equals(trimmed, ignoreCase = true)) { + return trimmed.uppercase(Locale.ROOT) + } + return name +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsAttributeRegistryParityTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsAttributeRegistryParityTest.kt new file mode 100644 index 000000000..896934441 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsAttributeRegistryParityTest.kt @@ -0,0 +1,75 @@ +package org.prairieserver.prairie.model.diagnostics + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Parity gate for the hand-maintained [REGISTERED_ATTRIBUTES] mirror. + * + * The canonical attribute registry is owned by the server contract and vendored + * here as `diagnostics/v1/attr-registry.json`. Every other client enforces the + * same invariant (Go `TestAttrRegistryStaysInSync`, Swift + * `DiagnosticsAttributeRegistryParityTests`); without this test the Kotlin copy + * can silently drift from the fixture, which is exactly what happened before. + */ +class DiagnosticsAttributeRegistryParityTest { + @Test + fun registeredAttributesMatchVendoredAttrRegistry() { + val canonical = canonicalRegistry() + val mirrored = REGISTERED_ATTRIBUTES.entries.associate { (category, attributes) -> + category.wireName() to attributes.mapValues { (_, kind) -> kind.wireType() } + } + + assertTrue(canonical.isNotEmpty(), "attr-registry.json declared no categories") + // assertEquals on the whole map compares both directions at once: + // missing categories, extra categories, missing keys, extra keys, and + // every value type. + assertEquals(canonical, mirrored, "REGISTERED_ATTRIBUTES drifted from diagnostics/v1/attr-registry.json") + } + + @Test + fun registryBackedTypeValidationCoversNewlyRegisteredKeys() { + val line = decodeDiagnosticsLogLine( + """{"ts":"2026-08-11T00:00:02Z","run":"run-1","lvl":"I","cat":"lifecycle","tag":"Startup",""" + + """"msg":"phase","attrs":{"phase":"cold_start","duration_ms":42}}""", + ) + line.validate() + + assertFailsWith { + line.copy(attributes = mapOf("duration_ms" to PrairieJson.encodeToJsonElement("42"))).validate() + } + assertFailsWith { + line.copy(attributes = mapOf("phase" to PrairieJson.encodeToJsonElement(7))).validate() + } + } + + private fun canonicalRegistry(): Map> { + val root = PrairieJson.parseToJsonElement(fixture("attr-registry.json")).jsonObject + val categories = checkNotNull(root["categories"]) { "attr-registry.json has no categories" }.jsonObject + return categories.entries.associate { (category, keys) -> + category to (keys as JsonObject).entries.associate { (key, spec) -> + key to spec.jsonObject.getValue("type").jsonPrimitive.content + } + } + } + + private fun DiagnosticsLogCategory.wireName(): String = + PrairieJson.encodeToJsonElement(DiagnosticsLogCategory.serializer(), this).jsonPrimitive.content + + private fun DiagnosticsAttributeKind.wireType(): String = name.lowercase() + + private fun fixture(relativePath: String): String { + val resourceName = "diagnostics/v1/$relativePath" + val resource = checkNotNull(javaClass.classLoader?.getResource(resourceName)) { + "Missing test resource $resourceName" + } + return resource.readText() + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3ConformanceTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3ConformanceTest.kt new file mode 100644 index 000000000..e9c33aee6 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3ConformanceTest.kt @@ -0,0 +1,846 @@ +package org.prairieserver.prairie.model.playback + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.network.ApiErrorBody +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@Serializable +private data class AttemptKeyFixtureV3( + val name: String, + @SerialName("server_plan_attempt_key") val serverPlanAttemptKey: String, + @SerialName("replan_echo") val replanEcho: String, + @SerialName("attempted_plan_keys") val attemptedPlanKeys: List, + @SerialName("expected_server_action") val expectedServerAction: String, +) + +@Serializable +private data class ConformanceMatrixFixtureV3( + @SerialName("schema_version") val schemaVersion: Int, + @SerialName("planner_scenarios") val plannerScenarios: List, + @SerialName("replan_scenarios") val replanScenarios: List, + @SerialName("protocol_scenarios") val protocolScenarios: List, +) + +@Serializable +private data class PlannerScenarioFixtureV3( + val name: String, + val category: String, + val request: PlaybackStartRequestV3, + val source: SourceDescriptorFixtureV3, + @SerialName("attempted_plan_keys") val attemptedPlanKeys: List = emptyList(), + val expected: PlannerExpectationFixtureV3, +) + +@Serializable +private data class PlannerExpectationFixtureV3( + val outcome: PlaybackDecisionOutcome, + val delivery: PlaybackDelivery? = null, + @SerialName("decision_reason") val decisionReason: String? = null, + @SerialName("plan_id") val planId: String? = null, + @SerialName("plan_attempt_key") val planAttemptKey: String? = null, + @SerialName("selected_tracks") val selectedTracks: SelectedPlaybackTracksV3? = null, + val subtitle: PlaybackSubtitleDecisionV3? = null, + val claims: PlaybackValidationClaims? = null, + val transformations: List = emptyList(), + @SerialName("available_qualities") val availableQualities: List = emptyList(), + @SerialName("terminal_reason") val terminalReason: String? = null, +) + +@Serializable +private data class SourceDescriptorFixtureV3( + @SerialName("media_file_id") val mediaFileId: Int, + @SerialName("duration_seconds") val durationSeconds: Double? = null, + val container: String? = null, + @SerialName("video_codec") val videoCodec: String? = null, + @SerialName("video_profile") val videoProfile: String? = null, + @SerialName("video_level") val videoLevel: Int = 0, + @SerialName("bit_depth") val bitDepth: Int = 0, + @SerialName("color_range") val colorRange: String? = null, + val width: Int = 0, + val height: Int = 0, + @SerialName("frame_rate") val frameRate: Double = 0.0, + @SerialName("bitrate_kbps") val bitrateKbps: Int = 0, + @SerialName("dynamic_range") val dynamicRange: String? = null, + @SerialName("hdr10_plus") val hdr10Plus: Boolean = false, + @SerialName("dolby_vision_profile") val dolbyVisionProfile: Int = 0, + @SerialName("dv_bl_compat_id") val dolbyVisionBaseLayerCompatibilityId: Int = 0, + @SerialName("dv_enhancement_layer") val dolbyVisionEnhancementLayer: String, + @SerialName("audio_codec") val audioCodec: String? = null, + @SerialName("audio_channels") val audioChannels: Int = 0, + @SerialName("audio_layout") val audioLayout: String? = null, + @SerialName("video_copy_unsafe") val videoCopyUnsafe: Boolean = false, +) + +@Serializable +private data class ReplanScenarioFixtureV3( + val name: String, + val category: String, + val request: PlaybackReplanRequestV3, + val expected: ReplanExpectationFixtureV3, +) + +@Serializable +private data class ReplanExpectationFixtureV3( + @SerialName("http_status") val httpStatus: Int? = null, + @SerialName("position_seconds") val positionSeconds: Double? = null, + @SerialName("position_preserved") val positionPreserved: Boolean? = null, + @SerialName("preserve_unmodified_tracks") val preserveUnmodifiedTracks: Boolean? = null, + @SerialName("selected_quality") val selectedQuality: String? = null, + @SerialName("same_request_and_body_status") val sameRequestAndBodyStatus: Int? = null, + @SerialName("response_replayed_verbatim") val responseReplayedVerbatim: Boolean? = null, + @SerialName("changed_body_status") val changedBodyStatus: Int? = null, + @SerialName("changed_body_error") val changedBodyError: String? = null, + @SerialName("while_first_lease_active_status") val whileFirstLeaseActiveStatus: Int? = null, + @SerialName("concurrent_error") val concurrentError: String? = null, + @SerialName("after_completion_status") val afterCompletionStatus: Int? = null, +) + +@Serializable +private data class ProtocolScenarioFixtureV3( + val name: String, + val category: String, + val input: ProtocolScenarioInputFixtureV3, + val expected: ProtocolExpectationFixtureV3, +) + +@Serializable +private data class ProtocolScenarioInputFixtureV3( + @SerialName("start_request") val startRequest: PlaybackStartRequestV3? = null, + @SerialName("replan_request") val replanRequest: PlaybackReplanRequestV3? = null, + @SerialName("route_event") val routeEvent: PlaybackRouteEventV3? = null, + @SerialName("persisted_decision") val persistedDecision: PlaybackDecisionResponseV3? = null, + val body: LegacyStartBodyFixtureV3? = null, + @SerialName("plan_id") val planId: String? = null, + @SerialName("first_output_context_id") val firstOutputContextId: String? = null, + @SerialName("second_output_context_id") val secondOutputContextId: String? = null, + @SerialName("first_plan_attempt_key") val firstPlanAttemptKey: String? = null, + @SerialName("second_plan_attempt_key") val secondPlanAttemptKey: String? = null, + @SerialName("server_plan_attempt_key") val serverPlanAttemptKey: String? = null, + @SerialName("replan_echo") val replanEcho: String? = null, + @SerialName("attempted_plan_keys") val attemptedPlanKeys: List = emptyList(), + val restarted: Boolean = false, + @SerialName("capacity_available") val capacityAvailable: Boolean? = null, +) + +@Serializable +private data class LegacyStartBodyFixtureV3( + @SerialName("protocol_version") val protocolVersion: Int? = null, + @SerialName("file_id") val fileId: Int, + @SerialName("client_capabilities") val clientCapabilities: ClientCodecCapabilities? = null, +) + +@Serializable +private data class ProtocolExpectationFixtureV3( + @SerialName("http_status") val httpStatus: Int? = null, + val error: String? = null, + val outcome: PlaybackDecisionOutcome? = null, + @SerialName("terminal_reason") val terminalReason: String? = null, + @SerialName("plan_id_unchanged") val planIdUnchanged: Boolean? = null, + @SerialName("plan_attempt_key_changed") val planAttemptKeyChanged: Boolean? = null, + @SerialName("selection_preserved") val selectionPreserved: Boolean? = null, + @SerialName("position_preserved") val positionPreserved: Boolean? = null, + @SerialName("response_replayed_verbatim") val responseReplayedVerbatim: Boolean? = null, + @SerialName("capacity_delta") val capacityDelta: Int? = null, + @SerialName("cleanup_complete") val cleanupComplete: Boolean? = null, + val action: String? = null, +) + +/** + * The Kotlin runner for the server's golden playback-v3 wire fixtures — this + * client's drift gate on the neutral protocol contract. + * + * The fixtures under `playback/v3/` are vendored byte-identically from the + * server repo, generated there from the live Go contract types; see the SOURCE + * file beside them. Authority runs one way: the server defines the protocol and + * this client proves it can read and write it. Nothing here recomputes an + * expected value — every assertion compares against what the server produced. + * + * That matters most for `attempt_keys.json`. Attempt keys are server-minted + * under the neutral contract: the client stores one, echoes it back, and has no + * hash function of its own to check them with. Deleting the client-side FNV + * implementation is what makes "echo it verbatim" the only assertion available + * here, and the right one. + * + * The gate catches three kinds of drift: + * + * 1. A field the server emits that this client's models silently drop. Caught + * by re-encoding what was decoded and diffing against the fixture — see + * [assertClientReadsEveryFieldExcept], which fails naming the lost path. + * 2. A field this client emits under a name the server's request fixture does + * not use, or a required one it omits. + * 3. An enum member the server uses that does not decode here, which is how a + * new delivery class or subtitle mode announces itself. + */ +class PlaybackProtocolV3ConformanceTest { + + /** + * Unknown keys are tolerated at the decoder and caught by the round-trip + * diff instead. Doing it the other way — a strict decoder — would fail on + * the source facts this client has deliberately chosen not to model, and + * the failure would say only "unknown key", with no way to distinguish a + * known omission from a field that went missing. + */ + private val json = Json { + ignoreUnknownKeys = true + isLenient = false + explicitNulls = false + encodeDefaults = true + } + + @Test + fun decisionResponseDecodesAndRoundTripsToTheGoldenWireShape() { + val raw = fixture("decision_response.json") + + val decoded = json.decodeFromString(PlaybackDecisionResponseV3.serializer(), raw) + + assertEquals(PLAYBACK_PROTOCOL_V3, decoded.protocolVersion) + assertEquals(PlaybackDecisionOutcome.PLAYABLE, decoded.outcome) + assertTrue(NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE in decoded.serverFeatures) + val plan = assertNotNull(decoded.playbackPlan, "the golden response must decode to a playable plan") + assertEquals(PlaybackDelivery.ORIGINAL_HTTP, plan.delivery) + assertEquals(PlaybackStreamProtocol.HTTP_PROGRESSIVE, plan.stream.protocol) + assertEquals("validated_original_playback", plan.decisionReason) + assertEquals(7200.0, plan.source.durationSeconds) + assertTrue(plan.planAttemptKey.startsWith("v3:"), "plan_attempt_key must arrive server-minted") + + assertClientReadsEveryFieldExcept( + raw, + json.encodeToJsonElement(PlaybackDecisionResponseV3.serializer(), decoded), + UNMODELLED_SOURCE_DETAIL, + ) + } + + /** + * A tolerant plan decoder is load-bearing — [TolerantPlaybackPlanV3Serializer] + * turns an unreadable plan into a null the negotiation layer can gate on, + * rather than a transport error — but it also means a plan that failed to + * decode looks exactly like a plan the server never sent. So the golden plan + * has to be proven decodable through the production path too, not only + * through this test's own decoder. + */ + @Test + fun theProductionTolerantDecoderAcceptsTheGoldenPlan() { + val decoded = PrairieJson.decodeFromString( + PlaybackDecisionResponseV3.serializer(), + fixture("decision_response.json"), + ) + + val validation = decoded.validateForMedia3() + + assertTrue( + validation is PlaybackV3Validation.Playable, + "the golden plan must survive the tolerant decoder; got $validation", + ) + assertEquals("11111111-1111-4111-8111-111111111111", validation.sessionId) + } + + /** + * The quality menu and the subtitle inventory are both server-authoritative. + * What is checked here is that the client renders what it was sent rather + * than deriving rungs from the source resolution or renumbering ordinals. + */ + @Test + fun planCarriesTheServersQualityMenuAndSubtitleInventory() { + val plan = assertNotNull( + json.decodeFromString( + PlaybackDecisionResponseV3.serializer(), + fixture("decision_response.json"), + ).playbackPlan, + ) + + assertEquals( + listOf("original"), + plan.availableQualities.map { it.label }, + "the quality menu keeps the server's order", + ) + assertTrue(plan.availableQualities.first().preservesSource) + + assertEquals( + List(5) { it }, + plan.subtitle.inventory.map { it.combinedIndex }, + "combined ordinals are dense and gap-free", + ) + val burnInOnly = plan.subtitle.inventory.single { it.delivery == "burn_in_only" } + assertEquals("file:42:subtitle:3", burnInOnly.trackId) + assertEquals(3, burnInOnly.combinedIndex, "a burn-in-only track still holds its ordinal") + assertNull(burnInOnly.url, "…and carries no sidecar URL") + val styled = plan.subtitle.inventory.single { it.codec == "ass" } + assertNotNull(styled.fontBundleUrl, "styled tracks publish their font bundle") + } + + /** + * The inventory is published twice — inside the plan and as its own fixture + * — from the same server code. If the two ever disagree, this client is + * reading one of them wrong. + */ + @Test + fun standaloneSubtitleInventoryMatchesTheOneInThePlan() { + val standalone = PrairieJson.parseToJsonElement(fixture("subtitle_inventory.json")) + .jsonObject.getValue("inventory") + val fromPlan = PrairieJson.parseToJsonElement(fixture("decision_response.json")) + .jsonObject.getValue("playback_plan") + .jsonObject.getValue("subtitle") + .jsonObject.getValue("inventory") + + assertEquals(standalone, fromPlan) + } + + @Test + fun startRequestRoundTripsToTheGoldenWireShape() { + val raw = fixture("start_request.json") + + val decoded = json.decodeFromString(PlaybackStartRequestV3.serializer(), raw) + + assertEquals(PLAYBACK_PROTOCOL_V3, decoded.protocolVersion) + assertEquals(42, decoded.fileId) + assertEquals(QUALITY_ORIGINAL_V3, decoded.qualityPreference) + assertEquals(SubtitleFidelityPreference.COMPATIBLE, decoded.subtitleFidelityPreference) + assertEquals(ProgressPersistenceV3.CLIENT, decoded.progressPersistence) + assertEquals(CAPABILITY_EVIDENCE_EXACT, decoded.capabilities.videoEvidence) + assertEquals(CAPABILITY_EVIDENCE_EXACT, decoded.capabilities.audioEvidence) + + assertClientReadsEveryFieldExcept( + raw, + json.encodeToJsonElement(PlaybackStartRequestV3.serializer(), decoded), + ) + } + + @Test + fun replanRequestRoundTripsToTheGoldenWireShape() { + val raw = fixture("replan_request.json") + + val decoded = json.decodeFromString(PlaybackReplanRequestV3.serializer(), raw) + val decisionPlan = assertNotNull( + json.decodeFromString( + PlaybackDecisionResponseV3.serializer(), + fixture("decision_response.json"), + ).playbackPlan, + ) + + assertEquals(FAILURE_RECOVERY_V3_OPERATION, decoded.operation) + assertEquals(decisionPlan.planId, decoded.failedPlanId) + assertEquals(decisionPlan.planAttemptKey, decoded.planAttemptKey) + assertEquals(listOf(decoded.planAttemptKey), decoded.attemptedPlanKeys) + assertNotNull(decoded.failure, "a recovery replan states what went wrong") + + assertClientReadsEveryFieldExcept( + raw, + json.encodeToJsonElement(PlaybackReplanRequestV3.serializer(), decoded), + ) + } + + /** + * Output identity is nested under the playback context in the neutral + * contract; there is no top-level output field on either request. Both are + * checked because the two shapes used to disagree. + */ + @Test + fun outputIdentityTravelsNestedUnderThePlaybackContextOnBothRequests() { + val start = json.decodeFromString(PlaybackStartRequestV3.serializer(), fixture("start_request.json")) + val replan = json.decodeFromString(PlaybackReplanRequestV3.serializer(), fixture("replan_request.json")) + + listOf(start.clientPlaybackContext, replan.clientPlaybackContext).forEach { context -> + assertEquals("7", context.output.outputContextId) + assertEquals(PLAYBACK_PROTOCOL_V3, context.protocolVersion) + } + listOf("start_request.json", "replan_request.json").forEach { name -> + val body = PrairieJson.parseToJsonElement(fixture(name)).jsonObject + assertNull(body["output"], "$name: output must not reappear as a top-level field") + assertNull(body["output_route_generation"], "$name: the platform-shaped generation is gone") + } + } + + /** + * The build behind the marketing version, and how the build was + * distributed. Both are optional on the wire, so the round-trip check alone + * would stay green if the client emitted them under names the server does + * not read — assert the encoded keys directly instead. + */ + @Test + fun contextNamesTheBuildAndChannelBehindTheAppVersion() { + listOf("start_request.json", "replan_request.json").forEach { name -> + val context = PrairieJson.parseToJsonElement(fixture(name)) + .jsonObject["client_playback_context"]!!.jsonObject + + assertEquals("5", context["app_build"]?.jsonPrimitive?.content, "$name: app_build") + assertEquals("production", context["app_channel"]?.jsonPrimitive?.content, "$name: app_channel") + } + + val encoded = json.encodeToJsonElement( + ClientPlaybackContext.serializer(), + ClientPlaybackContext( + formFactor = "tv", + appVersion = "3.0-test", + appBuild = "5", + appChannel = "production", + ), + ).jsonObject + + assertEquals("5", encoded["app_build"]?.jsonPrimitive?.content) + assertEquals("production", encoded["app_channel"]?.jsonPrimitive?.content) + } + + /** + * An unstamped local build has no build counter to report. It must vanish + * from the body rather than travel as an explicit null or a literal "0", + * which the server — treating the field as opaque — would render verbatim. + */ + @Test + fun anAbsentBuildIsOmittedFromTheBodyRatherThanSentAsNull() { + val encoded = json.encodeToJsonElement( + ClientPlaybackContext.serializer(), + ClientPlaybackContext(formFactor = "tv", appVersion = "3.0-test"), + ).jsonObject + + assertNull(encoded["app_build"], "an unstamped build must not appear on the wire") + assertNull(encoded["app_channel"], "an unreported channel must not appear on the wire") + } + + /** + * Delivery classes replaced the engine self-description. The server + * negotiates against transports, so a context still describing a Media3 + * engine — or omitting deliveries entirely — would be unroutable. + */ + @Test + fun contextAdvertisesDeliveryClassesRatherThanEngines() { + val raw = fixture("start_request.json") + val context = json.decodeFromString(PlaybackStartRequestV3.serializer(), raw).clientPlaybackContext + + val delivery = assertNotNull(context.deliveries[DELIVERY_CLASS_ORIGINAL_HTTP]) + assertTrue(delivery.enabled && delivery.supportedOnDevice) + assertEquals(listOf("h264"), delivery.videoCodecs) + assertTrue(delivery.authHeaderRefresh) + + val contextJson = PrairieJson.parseToJsonElement(raw) + .jsonObject.getValue("client_playback_context").jsonObject + assertNull(contextJson["engines"], "engine self-description is gone from the contract") + assertNull(contextJson["features"], "feature advertisement lives in top-level client_features") + assertEquals( + emptySet(), + contextJson.getValue("deliveries").jsonObject.keys - DELIVERY_CLASSES, + "deliveries are keyed by delivery class", + ) + } + + /** + * Platform-specific device facts belong in the opaque `platform_details` + * map rather than in fields of their own — that is what keeps the contract + * from growing an Android-shaped hole. + */ + @Test + fun androidBuildFactsTravelAsOpaquePlatformDetails() { + val device = json.decodeFromString( + PlaybackStartRequestV3.serializer(), + fixture("start_request.json"), + ).clientPlaybackContext.device + + assertEquals("android", device.platform) + assertEquals(mapOf("abis" to "arm64-v8a", "sdk_int" to "35"), device.platformDetails) + } + + /** + * Attempt keys are opaque here. The client cannot recompute the server's + * hashes and deliberately no longer tries, so what is asserted is the + * contract it actually depends on: `v3:`-prefixed, distinct per plan, and + * echoed back byte for byte. + */ + @Test + fun serverMintedAttemptKeysAreOpaqueDistinctAndEchoedVerbatim() { + val cases = json.decodeFromString>(fixture("attempt_keys.json")) + assertTrue(cases.size >= 3, "the fixture must keep covering several distinct routes") + + val keys = cases.map { it.serverPlanAttemptKey } + keys.forEach { key -> + assertTrue(key.startsWith("v3:"), "attempt keys are v3-prefixed opaque tokens: $key") + assertTrue(key.length > "v3:".length, "an attempt key must carry a digest: $key") + } + assertEquals(keys.size, keys.toSet().size, "plans differing in delivery or route must not share a key") + + cases.forEach { case -> + assertEquals(case.serverPlanAttemptKey, case.replanEcho) + assertEquals(listOf(case.serverPlanAttemptKey), case.attemptedPlanKeys) + assertEquals("reject_already_attempted_plan", case.expectedServerAction) + + val encoded = PrairieJson.encodeToJsonElement( + PlaybackReplanRequestV3.serializer(), + replanRequestEchoing(case.serverPlanAttemptKey), + ).jsonObject + assertEquals(case.serverPlanAttemptKey, encoded.getValue("plan_attempt_key").jsonPrimitive.content) + assertEquals( + case.attemptedPlanKeys, + encoded.getValue("attempted_plan_keys").jsonArray.map { it.jsonPrimitive.content }, + ) + } + } + + /** + * Attempt-key fixtures intentionally contain no route recipe or hash input. + * If either reappears, the neutral corpus has regressed toward teaching the + * client how the server derives its private identity. + */ + @Test + fun attemptKeyCorpusContainsOnlyOpaqueEchoContractFields() { + PrairieJson.parseToJsonElement(fixture("attempt_keys.json")).jsonArray.forEach { element -> + assertEquals( + setOf( + "name", + "server_plan_attempt_key", + "replan_echo", + "attempted_plan_keys", + "expected_server_action", + ), + element.jsonObject.keys, + ) + } + } + + @Test + fun routeEventRoundTripsToTheGoldenWireShape() { + val raw = fixture("route_event.json") + + val decoded = json.decodeFromString(PlaybackRouteEventV3.serializer(), raw) + val decisionPlan = assertNotNull( + json.decodeFromString( + PlaybackDecisionResponseV3.serializer(), + fixture("decision_response.json"), + ).playbackPlan, + ) + + assertEquals("first_frame", decoded.event) + assertEquals(decisionPlan.planId, decoded.planId) + assertEquals(decisionPlan.planAttemptKey, decoded.planAttemptKey) + assertEquals("7", decoded.outputContextId) + assertTrue(decoded.diagnostics.isNotEmpty(), "route diagnostics travel as opaque string pairs") + + assertClientReadsEveryFieldExcept( + raw, + json.encodeToJsonElement(PlaybackRouteEventV3.serializer(), decoded), + ) + } + + @Test + fun protocolErrorEnvelopeDecodesThroughTheProductionApiErrorType() { + val raw = fixture("error_response.json") + val decoded = json.decodeFromString(ApiErrorBody.serializer(), raw) + + assertEquals("client_upgrade_required", decoded.error) + assertTrue(decoded.message.isNotBlank()) + assertClientReadsEveryFieldExcept(raw, json.encodeToJsonElement(ApiErrorBody.serializer(), decoded)) + } + + @Test + fun conformanceMatrixDecodesAndRoundTripsEveryGeneratedScenario() { + val raw = fixture("conformance_matrix.json") + val matrix = json.decodeFromString(ConformanceMatrixFixtureV3.serializer(), raw) + + assertEquals(1, matrix.schemaVersion) + assertEquals(17, matrix.plannerScenarios.size) + assertEquals(9, matrix.replanScenarios.size) + assertEquals(8, matrix.protocolScenarios.size) + assertClientReadsEveryFieldExcept( + raw, + json.encodeToJsonElement(ConformanceMatrixFixtureV3.serializer(), matrix), + ) + } + + @Test + fun plannerMatrixCoversHdrAudioAndSubtitleContractCategories() { + val scenarios = conformanceMatrix().plannerScenarios.associateBy { it.name } + + assertEquals( + setOf( + "evidence_tier_gating", + "deliveries_negotiation", + "audio_only_planning", + "hdr_dv_matrix", + "audio_matrix", + "subtitle_matrix", + "available_qualities", + ), + scenarios.values.map { it.category }.toSet(), + ) + + val hdr10 = scenarios.getValue("hdr10_exact_direct") + assertEquals("hdr10", hdr10.source.dynamicRange) + assertEquals(PlaybackDelivery.ORIGINAL_HTTP, hdr10.expected.delivery) + + val dolbyVision8 = scenarios.getValue("dolby_vision_8_exact_direct") + assertEquals(8, dolbyVision8.source.dolbyVisionProfile) + assertEquals(PlaybackDelivery.ORIGINAL_HTTP, dolbyVision8.expected.delivery) + + val dolbyVision7 = scenarios.getValue("dolby_vision_7_hdr10_fallback") + assertEquals(7, dolbyVision7.source.dolbyVisionProfile) + assertEquals(PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, dolbyVision7.expected.delivery) + assertEquals(listOf("server_dv7_to_hdr10"), dolbyVision7.expected.transformations.map { it.name }) + + val trueHdConversion = scenarios.getValue("truehd_audio_conversion") + assertEquals("truehd", trueHdConversion.source.audioCodec) + assertEquals(PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, trueHdConversion.expected.delivery) + assertEquals(listOf("audio_to_aac"), trueHdConversion.expected.transformations.map { it.name }) + + val trueHdPassthrough = scenarios.getValue("truehd_exact_layout_passthrough") + assertEquals(PlaybackDelivery.ORIGINAL_HTTP, trueHdPassthrough.expected.delivery) + assertTrue(trueHdPassthrough.expected.claims?.audio?.passthrough == true) + + val pgs = scenarios.getValue("embedded_pgs_sidecar") + assertEquals(PlaybackSubtitleModeV3.RENDER, pgs.expected.subtitle?.mode) + assertEquals(pgs.request.subtitleTrackId, pgs.expected.selectedTracks?.subtitle?.id) + + val ass = scenarios.getValue("embedded_ass_authored_render") + assertEquals(PlaybackSubtitleModeV3.RENDER, ass.expected.subtitle?.mode) + assertEquals(ass.request.subtitleTrackId, ass.expected.selectedTracks?.subtitle?.id) + + val dvd = scenarios.getValue("embedded_dvd_burn_in") + assertEquals(PlaybackSubtitleModeV3.BURN_IN, dvd.expected.subtitle?.mode) + assertEquals(PlaybackDelivery.SERVER_TRANSCODE_HLS, dvd.expected.delivery) + assertEquals(dvd.request.subtitleTrackId, dvd.expected.selectedTracks?.subtitle?.id) + } + + @Test + fun replanMatrixKeepsIntentAndTimelineOperationsFailureFree() { + val scenarios = conformanceMatrix().replanScenarios + + assertEquals( + setOf( + "track_change_replan", + "quality_change_replan", + "idempotent_replan", + "concurrent_replan", + "mid_seek_replan", + ), + scenarios.map { it.category }.toSet(), + ) + assertEquals( + setOf(TRACK_CHANGE_V3_OPERATION, QUALITY_CHANGE_V3_OPERATION, SEEK_REANCHOR_V3_OPERATION), + scenarios.mapNotNull { it.request.operation }.toSet(), + ) + scenarios.forEach { scenario -> + assertNull( + scenario.request.failure, + "${scenario.name} is an intent/timeline operation, not failure recovery", + ) + } + } + + @Test + fun protocolMatrixCoversRecoveryRestartCapacityAndEventLimits() { + val scenarios = conformanceMatrix().protocolScenarios.associateBy { it.name } + + assertTrue("recovery_matrix" in scenarios.values.map { it.category }) + assertTrue("restart_matrix" in scenarios.values.map { it.category }) + assertTrue("capacity_matrix" in scenarios.values.map { it.category }) + assertTrue("route_event_limits" in scenarios.values.map { it.category }) + + val draftV3 = scenarios.getValue("draft_v3_start_requires_upgrade") + assertEquals("draft_v3_426", draftV3.category) + assertEquals(PLAYBACK_PROTOCOL_V3, draftV3.input.body?.protocolVersion) + assertEquals(listOf("h264"), draftV3.input.body?.clientCapabilities?.codecsVideo) + assertEquals(426, draftV3.expected.httpStatus) + assertEquals("client_upgrade_required", draftV3.expected.error) + + val recovery = scenarios.getValue("failure_recovery_preserves_intent") + val recoveryRequest = assertNotNull(recovery.input.replanRequest) + assertEquals(321.25, recoveryRequest.positionSeconds) + assertNotNull(recoveryRequest.selectedTracks.subtitle) + assertEquals(true, recovery.expected.selectionPreserved) + assertEquals(true, recovery.expected.positionPreserved) + + val restart = scenarios.getValue("restart_replays_terminal_attempt") + assertTrue(restart.input.restarted) + assertEquals(PlaybackDecisionOutcome.ADAPTATION_UNAVAILABLE, restart.input.persistedDecision?.outcome) + assertEquals("transcode_start_failed", restart.input.persistedDecision?.terminal?.reason) + assertEquals(true, restart.expected.responseReplayedVerbatim) + assertEquals(0, restart.expected.capacityDelta) + + val capacity = scenarios.getValue("capacity_unavailable_cleans_up") + assertEquals(false, capacity.input.capacityAvailable) + assertEquals("capacity_unavailable", capacity.expected.terminalReason) + assertEquals(true, capacity.expected.cleanupComplete) + assertEquals(0, capacity.expected.capacityDelta) + + val routeLimit = scenarios.getValue("route_event_diagnostic_limit") + assertEquals(33, routeLimit.input.routeEvent?.diagnostics?.size) + assertEquals(400, routeLimit.expected.httpStatus) + assertEquals("bad_request", routeLimit.expected.error) + assertEquals("reject_without_persisting", routeLimit.expected.action) + } + + /** + * Feature detection reads the capability endpoint rather than sniffing a + * version. There is no Kotlin model for this response — the client reads it + * as raw JSON — so the gate is that the advertised protocol version is one + * this client speaks and every advertised delivery is one it can name. + */ + @Test + fun capabilityResponseAdvertisesOnlyProtocolThreeAndNameableDeliveries() { + val capability = PrairieJson.parseToJsonElement(fixture("capability_response.json")).jsonObject + + assertEquals(JsonPrimitive(true), capability.getValue("enabled")) + assertEquals( + listOf(PLAYBACK_PROTOCOL_V3), + capability.getValue("protocol_versions").jsonArray.map { it.jsonPrimitive.content.toInt() }, + "the legacy protocol is gone; v3 is the only one offered", + ) + + val features = capability.getValue("features").jsonArray.map { it.jsonPrimitive.content } + listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + LAYOUT_AWARE_PASSTHROUGH_FEATURE, + DEVICE_QUIRKS_V3_FEATURE, + SEEK_REANCHOR_V3_FEATURE, + DIRECT_STREAM_RESUME_V1_FEATURE, + ).forEach { assertTrue(it in features, "the server must keep advertising $it") } + + // An advertised delivery this client cannot decode means the server can + // route it somewhere the client has no way to represent. + capability.getValue("deliveries").jsonArray.forEach { delivery -> + json.decodeFromJsonElement(PlaybackDelivery.serializer(), delivery) + } + } + + private fun replanRequestEchoing( + planAttemptKey: String, + localMutations: List = emptyList(), + ): PlaybackReplanRequestV3 = PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + playbackAttemptId = "attempt-golden-0001", + replanRequestId = "replan-golden-0001", + failedPlanId = "plan:golden-0001", + planAttemptId = "plan-attempt-golden-0001", + planAttemptKey = planAttemptKey, + attemptedPlanKeys = listOf(planAttemptKey), + localMutations = localMutations, + attemptCount = 1, + positionSeconds = 42.5, + selectedTracks = SelectedPlaybackTracksV3(), + failure = PlaybackFailureV3(classification = "network_degraded"), + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "3.0-test"), + ) + + /** + * Fails if the client lost anything the server sent. + * + * Only the fixture → re-encoded direction is checked: `encodeDefaults` means + * the client legitimately writes back fields the server omitted at their + * default value. The other direction — a path present in the fixture that is + * absent or different after a decode/encode round trip — is always drift, + * except for the paths in [allowedMissing], which this client has a stated + * reason not to model. + */ + private fun assertClientReadsEveryFieldExcept( + rawFixture: String, + reencoded: JsonElement, + allowedMissing: Set = emptySet(), + ) { + val lost = mutableListOf() + collectLostPaths(PrairieJson.parseToJsonElement(rawFixture), reencoded, "$", lost) + + assertEquals( + allowedMissing.sorted(), + lost.sorted(), + "fields the server sent that this client does not round-trip", + ) + } + + private fun collectLostPaths( + expected: JsonElement, + actual: JsonElement?, + path: String, + lost: MutableList, + ) { + if (actual == null) { + lost += path + return + } + when (expected) { + is JsonObject -> { + val actualObject = actual as? JsonObject + if (actualObject == null) { + lost += path + return + } + expected.forEach { (key, value) -> collectLostPaths(value, actualObject[key], "$path.$key", lost) } + } + is JsonArray -> { + val actualArray = actual as? JsonArray + if (actualArray == null || actualArray.size != expected.size) { + lost += path + return + } + expected.forEachIndexed { index, value -> + collectLostPaths(value, actualArray[index], "$path[$index]", lost) + } + } + is JsonPrimitive -> if (!primitivesMatch(expected, actual as? JsonPrimitive)) lost += path + } + } + + /** + * Numbers compare by value, not by spelling: the server writes an integral + * `max_frame_rate` as `60` where the client's `Double` re-encodes it as + * `60.0`, and that is the same frame rate. + */ + private fun primitivesMatch(expected: JsonPrimitive, actual: JsonPrimitive?): Boolean { + if (actual == null) return false + if (expected.isString || actual.isString) return expected == actual + val expectedNumber = expected.content.toDoubleOrNull() + val actualNumber = actual.content.toDoubleOrNull() + return if (expectedNumber != null && actualNumber != null) { + expectedNumber == actualNumber + } else { + expected == actual + } + } + + private fun fixture(name: String): String = + checkNotNull(javaClass.classLoader?.getResource("playback/v3/$name")) { + "Missing vendored playback fixture playback/v3/$name" + }.readText() + + private fun conformanceMatrix(): ConformanceMatrixFixtureV3 = + json.decodeFromString(ConformanceMatrixFixtureV3.serializer(), fixture("conformance_matrix.json")) + + private companion object { + val DELIVERY_CLASSES = setOf( + DELIVERY_CLASS_ORIGINAL_HTTP, + DELIVERY_CLASS_PROGRESSIVE, + DELIVERY_CLASS_HLS, + ) + + /** + * Source facts the server publishes that this client deliberately does + * not model. They inform the server's own routing decisions and clients + * with a technical-details panel; Media3 learns the same things from the + * container it is handed. Shrinking this set is always welcome — growing + * it means a new server field went unread, so add an entry only with a + * reason. + */ + val UNMODELLED_SOURCE_DETAIL = setOf( + "$.playback_plan.source.video_profile", + "$.playback_plan.source.video_level", + "$.playback_plan.source.bit_depth", + "$.playback_plan.source.frame_rate", + "$.playback_plan.source.bitrate_kbps", + "$.playback_plan.source.hdr10_plus", + "$.playback_plan.source.dv_enhancement_layer", + "$.playback_plan.source.audio_channels", + "$.playback_plan.source.audio_layout", + ) + } +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt new file mode 100644 index 000000000..e73627c14 --- /dev/null +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/model/settings/SettingsConformanceTest.kt @@ -0,0 +1,393 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Kotlin runner for the cross-platform settings conformance fixture — the + * contract's named drift gate. + * + * The same hand-authored cases in `contracts/settings/v1/conformance.json` run + * against the Go resolver (`internal/settingsresolve/conformance_test.go`), the + * TypeScript one (`web/src/lib/settingsConformance.test.ts`), this one, and + * Swift in the Apple clients. Four independently written resolvers agreeing on + * every case is the whole point, so this runner takes the fixture at face + * value: it decodes strictly, resolves through [resolveSettingValues] against + * the real vendored manifest, and compares every declared expectation. + * + * Both JSON files are vendored byte-identically from the server repo; see the + * SOURCE file beside them. Nothing here touches the network. + * + * Four things fail this suite, and each of them is drift: + * + * 1. A resolution disagreement — this client would show a user a different + * effective setting than the server resolves. + * 2. A manifest revision mismatch between the fixture, the vendored manifest, + * and the generated [SettingKeys]. A revision bump changes definitions, so + * the expectations have to be re-derived rather than assumed to still hold. + * 3. A key the bindings and the vendored manifest disagree about, which catches + * the two JSON files being vendored from different server commits — skew the + * revision check cannot see, since a revision only moves on a manifest PR + * and both copies would still read the same number. + * 4. A fixture field this runner does not know. Schema drift in the fixture is + * itself drift: a field one platform reads and another silently skips means + * the platforms are no longer running the same cases, which is precisely the + * failure the fixture exists to prevent. Strictness here is not pedantry — + * it is the only thing keeping a silent skip from looking like a pass. + */ +class SettingsConformanceTest { + + // Strict by construction: kotlinx rejects unknown keys and missing required + // fields at every level of the tree by default, which is the unknown-field + // gate for everything below. The places JSON null is a *value* rather than + // an absence — a stored row's value, and an expectation's value and + // stored_value — are typed as non-nullable JsonElement so an authored null + // decodes to JsonNull instead of collapsing onto the Kotlin null an + // omission produces. + private val strictJson = Json { + ignoreUnknownKeys = false + isLenient = false + coerceInputValues = false + } + + @Serializable + private data class ConformanceFixture( + @SerialName("fixture_version") val fixtureVersion: Int, + @SerialName("manifest_revision") val manifestRevision: Int, + val description: String, + val cases: List, + ) + + @Serializable + private data class ConformanceCase( + val name: String, + val description: String? = null, + val keys: List, + val context: ConformanceContext? = null, + val stored: List = emptyList(), + // Policy inputs by name, as the policy layer would supply them. Keys + // here are data, not schema, so they are deliberately not field-checked. + val constraints: Map = emptyMap(), + // Attaches a constraint to a copy of a real definition, so constraint + // kinds no shipped definition carries stay testable. + @SerialName("constraint_bindings") val constraintBindings: List = emptyList(), + val expected: List, + ) + + @Serializable + private data class ConformanceContext( + @SerialName("profile_id") val profileId: String? = null, + @SerialName("client_family") val clientFamily: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_ids") val libraryIds: List = emptyList(), + @SerialName("series_ids") val seriesIds: List = emptyList(), + ) + + @Serializable + private data class ConformanceRow( + val key: String, + val scope: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("client_family") val clientFamily: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, + val value: JsonElement, + ) + + @Serializable + private data class ConformanceBinding( + val key: String, + @SerialName("policy_input") val policyInput: String, + val constraint: SettingConstraintKind, + ) + + @Serializable + private data class ConformanceExpected( + val key: String, + val value: JsonElement, + val source: String, + /** Defaults to false; when true, stored_value and constraint_kind must be present. */ + val constrained: Boolean = false, + // Typed non-nullable so an authored `"stored_value": null` — which two + // bitrate cases rely on — decodes to JsonNull rather than collapsing + // onto the same Kotlin null an omission produces. Presence itself is + // gated in [expectationsSpellNullRatherThanOmittingIt], which reads the + // raw tree; kotlinx cannot express the distinction here. + @SerialName("stored_value") val storedValue: JsonElement = JsonNull, + @SerialName("constraint_kind") val constraintKind: SettingConstraintKind? = null, + ) + + private val manifest: SettingsManifest by lazy { + // The manifest is a vendored copy pinned by its revision rather than by + // field-level strictness, so unknown fields here are tolerated: the + // server may add advisory metadata in a revision this client still + // understands, and only the fields resolution reads are modelled. + Json { ignoreUnknownKeys = true } + .decodeFromString(resource("settings/v1/manifest.json")) + } + + private val fixtureRaw: String by lazy { resource("settings/v1/conformance.json") } + + private val fixture: ConformanceFixture by lazy { + strictJson.decodeFromString(fixtureRaw) + } + + @Test + fun theFixtureTargetsThisBuildsManifestRevision() { + assertEquals(1, fixture.fixtureVersion, "this runner understands fixture_version 1") + + // Three copies of one number, and they are only equal by maintenance: + // the fixture, the manifest it was authored against, and the bindings + // generated from that manifest. A bump to any one without the others is + // a client resolving against a contract it no longer carries. + assertEquals( + manifest.revision, + fixture.manifestRevision, + "the fixture targets manifest revision ${fixture.manifestRevision} but the vendored " + + "manifest is revision ${manifest.revision}; re-vendor both and re-derive the " + + "fixture expectations", + ) + assertEquals( + SettingKeys.REVISION, + manifest.revision, + "the vendored manifest is revision ${manifest.revision} but the generated bindings " + + "are revision ${SettingKeys.REVISION}; re-run make settings-bindings on the " + + "server and re-vendor", + ) + assertTrue(fixture.cases.isNotEmpty(), "the fixture declares no cases") + } + + @Test + fun theVendoredManifestCoversTheGeneratedBindings() { + // The bindings are generated from this manifest, so every remote key + // must resolve against it. A key in one and not the other means the two + // files were vendored from different server commits — the exact drift + // the revision check cannot catch, because a revision is only bumped by + // a manifest PR and both copies would still read 1. + for (key in SettingKeys.REMOTE) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertTrue(definition.isRemote, "$key is remote in the bindings, not in the manifest") + } + for (key in SettingKeys.CLIENT_LOCAL) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertFalse( + definition.isRemote, + "$key is client_local in the bindings but remote in the manifest", + ) + } + } + + @Test + fun generatedPresentationMetadataMatchesTheVendoredManifest() { + for ((key, presentation) in SettingPresentationMetadata.DEFINITIONS) { + val definition = assertNotNull(manifest.lookup(key), "$key is not in the manifest") + assertEquals(definition.suggestedOptions, presentation.suggestedOptions) + assertEquals(definition.unsetLabel, presentation.unsetLabel) + + val setId = definition.suggestedOptions ?: continue + val optionSet = assertNotNull(manifest.optionSets[setId], "$setId is not in the manifest") + assertEquals("language_tag", optionSet.type) + assertEquals( + optionSet.options + .filter { it.introducedIn <= manifest.revision } + .map { it.value }, + SettingPresentationMetadata.suggestedValues(key), + ) + } + } + + @Test + fun everyCaseIsDeclaredOnce() { + val names = fixture.cases.map { it.name } + assertFalse(names.any { it.isBlank() }, "a conformance case has no name") + assertEquals(names.size, names.toSet().size, "duplicate conformance case name") + } + + /** + * The null-vs-absent gate that strict decoding cannot express. + * + * `value` is required, so decoding already caught an omission. `stored_value` + * is the hard one: it may legitimately be JSON null when present, and + * kotlinx collapses "absent" and "explicit null" onto the same Kotlin null. + * So presence is read off the raw tree, matching what the Go runner gets + * from a nil json.RawMessage and the web runner from an `in` check. + */ + @Test + fun expectationsSpellNullRatherThanOmittingIt() { + val cases = strictJson.parseToJsonElement(fixtureRaw).jsonObject + .getValue("cases").jsonArray + assertEquals(fixture.cases.size, cases.size) + + cases.forEachIndexed { caseIndex, rawCase -> + val case = fixture.cases[caseIndex] + val rawObject = rawCase.jsonObject + + rawObject["stored"]?.jsonArray?.forEachIndexed { rowIndex, rawRow -> + assertTrue( + "value" in rawRow.jsonObject, + "${case.name}: stored[$rowIndex] must spell an authored null as null", + ) + } + + rawObject.getValue("expected").jsonArray.forEachIndexed { index, rawExpected -> + val expectation = case.expected[index] + val fields = rawExpected.jsonObject + assertTrue( + "value" in fields, + "${case.name}: expected[$index] must spell an expected null as null", + ) + if (expectation.constrained) { + assertTrue( + "stored_value" in fields, + "${case.name}: ${expectation.key}: a constrained expectation must " + + "declare stored_value", + ) + assertNotNull( + expectation.constraintKind, + "${case.name}: ${expectation.key}: a constrained expectation must " + + "declare constraint_kind", + ) + } else { + assertFalse( + "stored_value" in fields, + "${case.name}: ${expectation.key}: an unconstrained expectation must " + + "not declare stored_value", + ) + assertNull( + expectation.constraintKind, + "${case.name}: ${expectation.key}: an unconstrained expectation must " + + "not declare constraint_kind", + ) + } + } + } + } + + @Test + fun everyCaseResolvesToItsExpectedEffectiveValues() { + for (case in fixture.cases) { + assertTrue(case.keys.isNotEmpty(), "${case.name}: declares no keys") + assertTrue(case.expected.isNotEmpty(), "${case.name}: declares no expectations") + + val bindings = mutableMapOf() + for (binding in case.constraintBindings) { + assertNotNull( + manifest.lookup(binding.key), + "${case.name}: constraint binding names unknown key ${binding.key}", + ) + assertTrue( + binding.policyInput.isNotBlank(), + "${case.name}: constraint binding on ${binding.key} has no policy_input", + ) + assertNull( + bindings.put( + binding.key, + SettingConstraintBinding(binding.policyInput, binding.constraint), + ), + "${case.name}: duplicate constraint binding for ${binding.key}", + ) + } + + val resolved = resolveSettingValues( + manifest = manifest, + keys = case.keys, + stored = case.stored.map { row -> + StoredSettingRow( + key = row.key, + scope = row.scope, + profileId = row.profileId, + clientFamily = row.clientFamily, + deviceId = row.deviceId, + libraryId = row.libraryId, + seriesId = row.seriesId, + value = row.value, + ) + }, + context = SettingResolutionContext( + profileId = case.context?.profileId, + clientFamily = case.context?.clientFamily, + deviceId = case.context?.deviceId, + libraryIds = case.context?.libraryIds.orEmpty(), + seriesIds = case.context?.seriesIds.orEmpty(), + ), + constraints = case.constraints, + constraintBindings = bindings, + ) + + assertEquals( + case.expected.size, + resolved.size, + "${case.name}: resolved ${resolved.size} settings, the fixture expects " + + "${case.expected.size}", + ) + val byKey = resolved.associateBy { it.key } + + for (expectation in case.expected) { + val entry = assertNotNull( + byKey[expectation.key], + "${case.name}: no resolved value for ${expectation.key}", + ) + val where = "${case.name}: ${expectation.key}" + assertTrue( + jsonEquivalent(entry.value, expectation.value), + "$where: value = ${entry.value}, want ${expectation.value}", + ) + assertEquals(expectation.source, entry.source, "$where: source") + assertEquals(expectation.constrained, entry.constrained, "$where: constrained") + assertEquals( + expectation.constraintKind, + entry.constraintKind, + "$where: constraint_kind", + ) + if (expectation.constrained) { + val storedValue = assertNotNull( + entry.storedValue, + "$where: a constrained result must report stored_value", + ) + assertTrue( + jsonEquivalent(storedValue, expectation.storedValue), + "$where: stored_value = $storedValue, want ${expectation.storedValue}", + ) + } else { + assertNull( + entry.storedValue, + "$where: stored_value reported without a constraint", + ) + } + } + } + } + + @Test + fun anUnknownFixtureFieldFails() { + // The unknown-field gate is the one thing here that would otherwise be + // untested — it only ever fires on a fixture this repo does not carry + // yet, so its own regression would be invisible until the day it was + // needed. Injecting a field proves strict decoding is actually on. + val drifted = fixtureRaw.replaceFirst("\"cases\":", "\"cases_v2\": [], \"cases\":") + assertFalse(drifted == fixtureRaw, "failed to inject a drifted field") + val failure = runCatching { strictJson.decodeFromString(drifted) } + assertTrue( + failure.isFailure, + "an unknown fixture field decoded cleanly; strict decoding is off and drift in the " + + "fixture schema would pass silently", + ) + } + + private fun resource(path: String): String = + checkNotNull(javaClass.classLoader?.getResource(path)) { + "Missing test resource $path" + }.readText() +} diff --git a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt index 13e4568cc..9613c0570 100644 --- a/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt +++ b/shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/network/EncryptedTokenManagerScopeGenerationTest.kt @@ -2,16 +2,44 @@ package org.prairieserver.prairie.network import android.content.SharedPreferences import java.lang.reflect.Proxy +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.model.server.ServerEntry import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class EncryptedTokenManagerScopeGenerationTest { + @Test + fun signOutTargetUsesTheLiveRegistryServerEvenBeforeTheCacheObserverRuns() = runTest { + val registry = FakeServerRegistry() + val transitions = DefaultIdentityTransitionBarrier() + val observed = mutableListOf() + transitions.installObserverForTests(observed::add) + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + identityTransitions = transitions, + ) + manager.saveTokens("server-a-access", "server-a-refresh", 3600) + + registry.switchExternally("server-b") + observed.clear() + manager.clearTokens() + + assertEquals(listOf("server-b", "server-b"), observed.map(IdentityTransition::targetServerId)) + assertEquals(listOf(true, true), observed.map(IdentityTransition::affectsCurrentIdentity)) + } + @Test fun staleSameServerScopeCannotReadOrRestoreReloggedCredentials() = runTest { val registry = FakeServerRegistry() @@ -77,6 +105,296 @@ class EncryptedTokenManagerScopeGenerationTest { assertNull(manager.getAccessTokenForScope(staleScope)) } + /** + * The snapshot was the ONE identity read that did not reconcile with the + * registry first, so immediately after a registry-driven switch it still + * described the previous server — and every guard built on it then decided + * against a server the app had already left. Deliberately no intervening + * `getAccessToken()`: that read reconciles as a side effect and hid this. + */ + @Test + fun snapshotReportsTheNewServerImmediatelyAfterARegistryFirstSwitch() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("server-a-access", "server-a-refresh", 3600) + + registry.switchExternally("server-b") + + assertEquals("server-b", manager.snapshotCurrentScope()?.serverId) + } + + /** An overlay owns identity outright; a switch underneath must not retarget it. */ + @Test + fun aTemporaryOverlaySurvivesARegistryFirstSwitch() = runTest { + val registry = FakeServerRegistry() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + ) + manager.saveTokens("server-a-access", "server-a-refresh", 3600) + manager.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay-1", + serverId = "overlay-server", + serverUrl = "https://overlay.example", + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "overlay-profile", + profileToken = "overlay-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + registry.switchExternally("server-b") + + assertEquals("overlay-server", manager.snapshotCurrentScope()?.serverId) + } + + @Test + fun clearingATemporaryOverlayDoesNotAuthorizePersistentIdentityPurge() = runTest { + val registry = FakeServerRegistry() + val transitions = DefaultIdentityTransitionBarrier() + val observed = mutableListOf() + transitions.installObserverForTests(observed::add) + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + identityTransitions = transitions, + ) + manager.saveTokens("saved-access", "saved-refresh", 3600) + manager.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay-1", + serverId = "overlay-server", + serverUrl = "https://overlay.example", + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "overlay-profile", + profileToken = "overlay-profile-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + observed.clear() + + manager.clearTokens() + + assertEquals(listOf(false, false), observed.map(IdentityTransition::purgesPersistentIdentity)) + assertFalse(manager.hasTemporaryScope()) + assertEquals("saved-access", manager.getAccessToken()) + assertEquals("saved-refresh", manager.getRefreshToken()) + } + + @Test + fun freshGenerationZeroCompanionScopeRefreshesButTrueUnversionedResponseFailsClosed() = runTest { + val registry = FakeServerRegistry() + val transitions = DefaultIdentityTransitionBarrier() + val manager = EncryptedTokenManagerImpl( + prefs = inMemoryPreferences(), + registry = registry, + identityTransitions = transitions, + ) + val scope = AuthScopeSnapshot( + serverId = "server-b", + profileId = null, + serverUrl = "https://server-b.example", + profileToken = null, + identityGeneration = transitions.generation.value, + isIdentityGenerationStamped = true, + ) + assertEquals(0L, scope.identityGeneration) + assertTrue(scope.isIdentityGenerationStamped) + + manager.saveTokensForScope(scope, "rotated-access", "rotated-refresh", 3600) + + assertEquals("rotated-access", manager.getAccessTokenForScope(scope)) + assertEquals("rotated-refresh", manager.getRefreshTokenForScope(scope)) + + val unversioned = scope.copy(isIdentityGenerationStamped = false) + manager.saveTokensForScope(unversioned, "unproven-access", "unproven-refresh", 3600) + assertEquals("rotated-access", manager.getAccessTokenForScope(scope)) + assertEquals("rotated-refresh", manager.getRefreshTokenForScope(scope)) + } + + @Test + fun removedInactiveServerCannotBeRecreatedByAStaleUnversionedRefresh() = runTest { + val registry = FakeServerRegistry() + val transitions = DefaultIdentityTransitionBarrier() + val preferences = inMemoryPreferences() + val manager = EncryptedTokenManagerImpl( + prefs = preferences, + registry = registry, + identityTransitions = transitions, + ) + val scope = AuthScopeSnapshot( + serverId = "server-b", + profileId = null, + serverUrl = "https://server-b.example", + profileToken = null, + ) + + transitions.changing(IdentityTransitionKind.SERVER_REMOVE) { + registry.removeExternally("server-b") + } + manager.saveTokensForScope(scope, "stale-access", "stale-refresh", 3600) + + assertFalse( + preferences.contains( + AndroidServerRegistry.serverScopedKey("server-b", EncryptedTokenManagerImpl.KEY_ACCESS_TOKEN), + ), + ) + } + + @Test + fun refreshSuspendedAtServerRemovalCommitCannotReviveRemovedTokenPrefix() = runTest { + val preferences = seededRegistryPreferences(activeServerId = "server-b") + val transitions = DefaultIdentityTransitionBarrier() + val removalCommitted = CountDownLatch(1) + val releaseRemoval = CountDownLatch(1) + val registry = AndroidServerRegistry( + prefs = preferences, + identityTransitions = transitions, + afterServerRemovalCommit = { + removalCommitted.countDown() + check(releaseRemoval.await(5, TimeUnit.SECONDS)) + }, + ) + val serverB = "server-b" + val manager = EncryptedTokenManagerImpl(preferences, registry, identityTransitions = transitions) + manager.saveTokens("server-b-access", "server-b-refresh", 3600) + val scope = AuthScopeSnapshot( + serverId = serverB, + profileId = null, + serverUrl = "https://server-b.example", + profileToken = null, + ) + val removal = backgroundScope.async(Dispatchers.Default) { registry.remove(serverB) } + assertTrue(removalCommitted.await(5, TimeUnit.SECONDS)) + + val staleSave = backgroundScope.async(Dispatchers.Default) { + manager.saveTokensForScope(scope, "stale-access", "stale-refresh", 3600) + } + val staleRead = backgroundScope.async(Dispatchers.Default) { + manager.getAccessTokenForScope(scope) + } + assertFalse(staleRead.isCompleted) + releaseRemoval.countDown() + removal.await() + staleSave.await() + assertNull(staleRead.await()) + + val prefix = AndroidServerRegistry.serverScopedKey(serverB, "") + assertTrue(preferences.all.keys.none { it.startsWith(prefix) }) + } + + @Test + fun stampedHandBuiltRefreshCannotOverwriteSameServerAccountReplacement() = runTest { + val preferences = seededRegistryPreferences(activeServerId = "server-a", includeServerB = false) + val transitions = DefaultIdentityTransitionBarrier() + val registry = AndroidServerRegistry(preferences, identityTransitions = transitions) + val serverId = "server-a" + val manager = EncryptedTokenManagerImpl( + prefs = preferences, + registry = registry, + identityTransitions = transitions, + ) + manager.saveTokens("account-a-access", "account-a-refresh", 3600) + val companionScope = AuthScopeSnapshot( + serverId = serverId, + profileId = null, + serverUrl = "https://server-a.example", + profileToken = null, + identityGeneration = transitions.generation.value, + isIdentityGenerationStamped = true, + ) + + manager.replaceAccountSession( + serverId = serverId, + accessToken = "account-b-access", + refreshToken = "account-b-refresh", + expiresIn = 3600, + profileId = "account-b-profile", + profileToken = "account-b-profile-token", + ) + manager.saveTokensForScope(companionScope, "late-a-access", "late-a-refresh", 3600) + + assertEquals("account-b-access", manager.getAccessToken()) + assertEquals("account-b-refresh", manager.getRefreshToken()) + } + + @Test + fun committedRegistryAndTokenStatePublishesEvenWhenPostCommitCallbacksFail() = runTest { + val preferences = seededRegistryPreferences(activeServerId = "server-a") + val transitions = DefaultIdentityTransitionBarrier() + val registry = AndroidServerRegistry( + prefs = preferences, + identityTransitions = transitions, + afterServerRemovalCommit = { error("after removal") }, + ) + + assertFailsWith { registry.remove("server-b") } + assertTrue(registry.entries.value.none { it.id == "server-b" }) + + val manager = EncryptedTokenManagerImpl( + prefs = preferences, + registry = registry, + identityTransitions = transitions, + afterAccountSessionCommit = { error("after session") }, + ) + assertFailsWith { + manager.replaceAccountSession( + serverId = "server-a", + accessToken = "committed-access", + refreshToken = "committed-refresh", + expiresIn = 3600, + profileId = "overlay-profile", + profileToken = "overlay-profile-token", + ) + } + assertEquals("committed-access", manager.getAccessToken()) + assertEquals("committed-refresh", manager.getRefreshToken()) + } + + @Test + fun rejectedAccountReplacementDoesNotRunPrivacyGatesOrAdvanceGeneration() = runTest { + val preferences = seededRegistryPreferences(activeServerId = "server-a", includeServerB = false) + val transitions = DefaultIdentityTransitionBarrier() + val observed = mutableListOf() + transitions.installObserverForTests(observed::add) + val registry = AndroidServerRegistry(preferences, identityTransitions = transitions) + val manager = EncryptedTokenManagerImpl(preferences, registry, identityTransitions = transitions) + manager.beginTemporaryScope( + TemporaryAuthScope( + generationId = "overlay-1", + serverId = "overlay-server", + serverUrl = "https://overlay.example", + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "overlay-profile", + profileToken = "overlay-profile-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + observed.clear() + val generation = transitions.generation.value + + assertFailsWith { + manager.replaceAccountSession( + serverId = "server-a", + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 3600, + profileId = null, + profileToken = null, + ) + } + + assertEquals(generation, transitions.generation.value) + assertTrue(observed.isEmpty()) + } + private class FakeServerRegistry : ServerRegistry { private val serverA = ServerEntry(id = "server-a", url = "https://server-a.example") private val serverB = ServerEntry(id = "server-b", url = "https://server-b.example") @@ -99,6 +417,11 @@ class EncryptedTokenManagerScopeGenerationTest { activeServerIdFlow.value = serverId activeEntryFlow.value = entriesFlow.value.first { it.id == serverId } } + + fun removeExternally(serverId: String) { + entriesFlow.value = entriesFlow.value.filterNot { it.id == serverId } + if (activeServerIdFlow.value == serverId) switchExternally(entriesFlow.value.first().id) + } } private fun inMemoryPreferences(vararg initialValues: Pair): SharedPreferences { @@ -111,6 +434,7 @@ class EncryptedTokenManagerScopeGenerationTest { when (method.name) { "getString" -> values[args!![0]] as? String ?: args[1] "getLong" -> values[args!![0]] as? Long ?: args[1] + "getBoolean" -> values[args!![0]] as? Boolean ?: args[1] "contains" -> values.containsKey(args!![0]) "getAll" -> values.toMap() "edit" -> editor(values) @@ -129,7 +453,8 @@ class EncryptedTokenManagerScopeGenerationTest { arrayOf(SharedPreferences.Editor::class.java), ) { _, method, args -> when (method.name) { - "putString", "putLong" -> editor.also { values[args!![0] as String] = args[1] } + "putString", "putLong", "putBoolean" -> + editor.also { values[args!![0] as String] = args[1] } "remove" -> editor.also { values.remove(args!![0] as String) } "clear" -> editor.also { values.clear() } "apply" -> Unit @@ -147,4 +472,21 @@ class EncryptedTokenManagerScopeGenerationTest { java.lang.Float.TYPE -> 0f else -> null } + + private fun seededRegistryPreferences( + activeServerId: String, + includeServerB: Boolean = true, + ): SharedPreferences { + val entries = buildList { + add("""{"id":"server-a","url":"https://server-a.example","lastUsedAtEpochMs":1}""") + if (includeServerB) { + add("""{"id":"server-b","url":"https://server-b.example","lastUsedAtEpochMs":2}""") + } + }.joinToString(",") + return inMemoryPreferences( + AndroidServerRegistry.KEY_MIGRATED to true, + AndroidServerRegistry.KEY_REGISTRY_STATE to + """{"entries":[$entries],"activeServerId":"$activeServerId"}""", + ) + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt index 37378ae10..13f3f6c5a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -1,6 +1,5 @@ package org.prairieserver.prairie.di -import org.prairieserver.prairie.discovery.LanDiscovery import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.TokenManagerImpl import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier @@ -14,6 +13,7 @@ val networkModule = module { single { TokenManagerImpl(get()) } single { createPrairieClient(get(), getOrNull(), getOrNull(), getOrNull()) } single { AuthApi(get()) } + single { OnboardingApi(get()) } single { DefaultDeviceLoginApi(get()) } single { CatalogApi(get()) } single { PlaybackApi(get()) } @@ -29,7 +29,8 @@ val networkModule = module { single { DefaultCalendarApi(get()) } single { HealthApi(get()) } single { org.prairieserver.prairie.update.AppUpdateChecker(get()) } - single { LanDiscovery(get()) } + single { org.prairieserver.prairie.discovery.LanDiscovery(get()) } + single { BrandingApi(get()) } single { SettingsApi(get()) } single { LibraryPlaybackPrefsApi(get()) } single { DownloadsApi(get()) } @@ -37,7 +38,6 @@ val networkModule = module { single { DefaultSubtitlesApi(get()) } single { DefaultNotificationsApi(get()) } single { DefaultPushRegistrationApi(get()) } - single { DefaultAdminApi(get()) } single { DefaultWatchTogetherApi(get()) } single { DefaultDiagnosticsApi(get()) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt index 9a74ae010..3ed5f536b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt @@ -6,8 +6,8 @@ import org.prairieserver.prairie.domain.MediaActionsCoordinator import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.repository.LiveTvRepository -import org.prairieserver.prairie.repository.AdminRepository import org.prairieserver.prairie.repository.AuthRepository +import org.prairieserver.prairie.repository.OnboardingRepository import org.prairieserver.prairie.repository.CalendarRepository import org.prairieserver.prairie.repository.DeviceLoginRepository import org.prairieserver.prairie.repository.CatalogRepository @@ -28,6 +28,7 @@ import org.prairieserver.prairie.repository.SettingsRepository import org.prairieserver.prairie.repository.WatchTogetherRepository import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.watchtogether.RoomSession +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway import org.koin.dsl.module import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -44,13 +45,29 @@ import kotlinx.coroutines.SupervisorJob * and TokenManager, so sharing instances is safe and efficient. */ val repositoryModule = module { - // Repositories — `getOrNull()` for ServerRegistry / HealthApi keeps these + // Repositories — optional multi-server identity dependencies keep these // working when the multi-server platform binding isn't installed // (commonMain tests, hypothetical iOS reuse). Both repos no-op the // multi-server side effects when the registry is null. - single { AuthRepository(get(), get(), getOrNull(), getOrNull()) } + single { + AuthRepository( + authApi = get(), + tokenManager = get(), + serverRegistry = getOrNull(), + healthApi = getOrNull(), + brandingApi = getOrNull(), + ) + } + single { OnboardingRepository(get()) } single { DeviceLoginRepository(get()) } - single { CatalogRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort) } + single { + CatalogRepository( + catalogApi = get(), + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), + ) + } single { CalendarRepository(get()) } single { PlaybackRepository(get()) } // `getOrNull()` picks up the Room-backed ports when the Android platform @@ -58,14 +75,24 @@ val repositoryModule = module { // back to the network-only no-op ports in commonMain tests / when unbound. single { PersonalDataRepository( - get(), - getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, - getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + personalDataApi = get(), + userItemStatePort = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpUserItemStatePort, + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), ) } single { ProfileRepository(get(), get(), getOrNull(), get(), get(), get()) } single { CollectionRepository(get()) } - single { SectionRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort) } + single { + SectionRepository( + sectionApi = get(), + catalogCache = getOrNull() + ?: org.prairieserver.prairie.repository.port.NoOpCatalogCachePort, + identityTransitions = get(), + ) + } single { RecommendationRepository(get()) } single { RequestsRepository(get()) } single { RequestsFeatureStore(get()) } @@ -75,11 +102,13 @@ val repositoryModule = module { single { org.prairieserver.prairie.model.feature.MetadataAiFeatureStore(get()) } single { org.prairieserver.prairie.repository.HomeRealtimeCoordinator(get(), get()) } single { SettingsRepository(get()) } + // Profile-scoped canonical settings, shared by the phone and TV screens so + // one platform cannot grow a behavior the other lacks. + single { org.prairieserver.prairie.domain.settings.ProfileSettingsController(get()) } single { LibraryPlaybackPrefsRepository(get()) } single { DownloadsRepository(get(), getOrNull() ?: org.prairieserver.prairie.repository.port.NoOpDownloadDeletionPort) } single { EbookReaderRepository(get()) } single { SubtitlesRepository(get()) } - single { AdminRepository(get()) } single { PushRegistrationRepository(get()) } // REST-backed inbox state plus a realtime factory that builds the default @@ -99,7 +128,7 @@ val repositoryModule = module { // One room's snapshot/suggestions state + WS lifecycle. The realtime factory // builds the per-room socket client from the shared HttpClient + TokenManager. - // Access auth is supplied by the same-origin Prairie auth plugin; the room/profile + // Access auth is supplied by the same-origin Silo auth plugin; the room/profile // query fields are a residual server contract. Lazy so a socket is only minted // when connect() runs. single { @@ -115,6 +144,7 @@ val repositoryModule = module { }, ) } + single { get() } // Eager so the identity-transition privacy gate is installed before any // profile/server/token mutation can occur. This process-lifetime scope, // rather than a screen scope, owns connection replacement and teardown. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/ManagePlaybackUseCase.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/ManagePlaybackUseCase.kt index 8ba143aa7..6ffd34447 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/ManagePlaybackUseCase.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/ManagePlaybackUseCase.kt @@ -1,15 +1,18 @@ package org.prairieserver.prairie.domain import org.prairieserver.prairie.model.catalog.WatchDetail -import org.prairieserver.prairie.model.playback.ClientCodecCapabilities -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PlaybackRepository /** - * Orchestrates the full playback lifecycle: session creation, progress reporting, - * and session teardown. + * Orchestrates the playback lifecycle around an already-started session: + * progress reporting, teardown, and watch detail. + * + * Session creation is not here. Starting playback needs the full v3 evidence + * bundle — codec probe, output context, delivery capabilities — which only the + * platform layer can assemble, so it runs through + * `PlaybackSessionManager.startVideoSessionV3` instead. * * Combines [PlaybackRepository] for session management with [CatalogRepository] * for fetching watch detail (versions, intro/credits markers, user progress). @@ -18,32 +21,6 @@ class ManagePlaybackUseCase( private val playbackRepo: PlaybackRepository, private val catalogRepo: CatalogRepository, ) { - /** - * Starts a playback session for a content item. - * - * @param contentId The content ID (used for logging/context; the server uses fileId). - * @param fileId The specific file version to play. - * @param profileId The active user profile. - * @param capabilities Client codec support for direct-play/transcode decisions. - * @param qualityPreference Optional quality preference (e.g. "original", "1080p"). - * @return The playback session info including stream URL and decision (direct/transcode). - */ - suspend fun startPlayback( - contentId: String, - fileId: Int, - profileId: String, - capabilities: ClientCodecCapabilities, - qualityPreference: String? = null, - startPosition: Double? = null, - ): ApiResult = - playbackRepo.startPlayback( - fileId = fileId, - profileId = profileId, - qualityPreference = qualityPreference, - startPosition = startPosition, - capabilities = capabilities, - ) - /** * Reports the current playback position and paused state. * Should be called periodically during playback (e.g. every 10 seconds). diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipController.kt index 61cd721be..12ee7e4fb 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipController.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipController.kt @@ -10,122 +10,352 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +/** What the intro skip pill should be showing. */ sealed interface IntroAutoSkipState { + /** No pill: outside an intro, this intro is resolved, or the mode is `never`. */ data object Hidden : IntroAutoSkipState - data object ShowingButton : IntroAutoSkipState - data class CountingDown(val secondsRemaining: Int) : IntroAutoSkipState + + /** + * `ask`: the "Skip Intro" offer, with [secondsRemaining] left before it + * withdraws itself. Withdrawal does *not* resolve the intro. + */ + data class Asking(val secondsRemaining: Int) : IntroAutoSkipState + + /** + * `always`: the intro has already been skipped and this is the + * "Intro skipped — Watch Intro" undo, with [secondsRemaining] left. + * + * Anchored to the intro it skipped rather than to the position — the seek + * that produced it necessarily left the range — so position changes never + * take it down. Only the timer, Select, Back, a content change or a mode + * change do. + */ + data class Skipped(val secondsRemaining: Int) : IntroAutoSkipState + + /** Seconds left on the timer, or null while [Hidden]. */ + val secondsRemainingOrNull: Int? + get() = when (this) { + is Asking -> secondsRemaining + is Skipped -> secondsRemaining + Hidden -> null + } + + /** True while a pill is on screen. */ + val isVisible: Boolean get() = this !is Hidden } +/** + * The one intro-skip prompt state machine, shared by the phone and TV players. + * + * The contract is the server repo's `docs/design/2026-08-16-intro-skip-mode.md` + * ("Prompt behaviour"); its `never` / `ask` / `always` tables are the test + * oracle and `IntroAutoSkipControllerTest` asserts them case for case. Read that + * document before changing anything here. + * + * Callers drive it with playback inputs through [observe] and act on the pill + * through [select] / [dismiss]. Everything else — timing, which intros have been + * decided, when the pill may reappear — lives in here so the three clients + * cannot drift. + * + * ### Seeks + * + * The controller performs exactly one seek itself: the immediate skip that + * `always` is. Everything the *viewer* triggers is returned rather than + * performed ([select] hands back a position), because the caller may not be + * allowed to move playback on its own — in a Watch Together room a guest's seek + * has to route through the room's transport gate. Rooms pin the mode to + * [IntroSkipMode.ASK] so the automatic path never runs there at all. + */ class IntroAutoSkipController( private val scope: CoroutineScope, - private val countdownSeconds: Int = 5, + private val countdownSeconds: Int = DEFAULT_COUNTDOWN_SECONDS, ) { + companion object { + /** + * The spec's `INTRO_PROMPT_SECONDS`. Shared with the UI that draws the + * fill so the timer and the bar cannot drift. + */ + const val DEFAULT_COUNTDOWN_SECONDS: Int = 5 + } + + /** Which pill the current run is showing; distinguishes the two timers' expiries. */ + private enum class Prompt { ASKING, SKIPPED } + private val _state = MutableStateFlow(IntroAutoSkipState.Hidden) val state: StateFlow = _state.asStateFlow() - private val cancelledKeys = mutableSetOf() - private var countdownJob: Job? = null + /** + * Intros the viewer has decided in this playback session. A resolved intro + * never shows a pill again, including after scrubbing back into it. + */ + private val resolved = mutableSetOf() + + /** + * The intro whose `ask` offer timed out while the position is still inside + * it. Timing out does not resolve the intro — scrubbing back in re-offers — + * but it must not re-offer on the spot either, so the marker is held until + * the position leaves the range (or the intro, mode or content changes). + */ + private var expiredKey: String? = null + + private var timerJob: Job? = null private var activeKey: String? = null + private var activeRange: TimeRange? = null + private var activePrompt: Prompt? = null + private var remaining: Int = 0 + private var lastMode: IntroSkipMode? = null + + /** + * Increments each time the tick job (re)starts — a fresh offer, and also a + * resume after a pause froze it. + * + * The fill is frame-clock driven (Compose scales `AnimationSpec` by the + * system animator duration scale, and a countdown to an action must ignore + * that), so it needs to know when to re-anchor its clock: [state] alone + * cannot tell a run that merely ticked from one that restarted, since both + * just show a number. Carried outside [IntroAutoSkipState] so that state + * stays comparable by value. + */ + private val _countdownRun = MutableStateFlow(0) + val countdownRun: StateFlow = _countdownRun.asStateFlow() + + /** + * False while the pill is up but its timer is frozen by a pause. The fill + * holds where it is; [countdownRun] bumps when it thaws. + */ + private val _timerRunning = MutableStateFlow(false) + val timerRunning: StateFlow = _timerRunning.asStateFlow() + /** Where a fresh timer starts, for a caller drawing progress against it. */ + val totalCountdownSeconds: Int get() = countdownSeconds + + /** + * Drives the pill from playback state, returning the job that does so. + * + * [mode] is the effective `playback.intro_skip_mode`; changing it mid-intro + * re-evaluates immediately. [onSeek] is the automatic `always` skip and is + * the only seek this controller performs — see the class docs. + * [playbackActive] should already have rebuffer dips filtered out of it + * (`settlingFalseEdges`); a pause that reaches here freezes the timer. + */ fun observe( position: Flow, introRange: Flow, - autoSkipEnabled: Flow, + mode: Flow, introKey: Flow, - onAutoSkipFire: suspend (toSeconds: Double) -> Unit, + onSeek: suspend (toSeconds: Double) -> Unit, + playbackActive: Flow = flowOf(true), ): Job { return scope.launch { - combine(position, introRange, autoSkipEnabled, introKey) { pos, range, enabled, key -> - Inputs(pos, range, enabled, key) + combine(position, introRange, mode, introKey, playbackActive) { + pos, range, activeMode, key, playing -> + Inputs(pos, range, activeMode, key, playing) } .distinctUntilChanged() - .collect { handle(it, onAutoSkipFire) } + .collect { handle(it, onSeek) } + } + } + + /** + * The pill's primary action — click, tap, or Select/OK while it is focused. + * + * Resolves the intro, hides the pill, and returns the position the caller + * must seek to: the intro's `end` for the `ask` offer (skip it) and its + * `start` for the `always` undo (play it after all). Null when no pill is + * showing, so a stray press is a no-op. + */ + fun select(): Double? { + val key = activeKey ?: return null + val range = activeRange ?: return null + val prompt = activePrompt ?: return null + resolved.add(key) + expiredKey = null + clearPrompt() + return when (prompt) { + Prompt.ASKING -> range.end + Prompt.SKIPPED -> range.start } } - fun cancelCountdown() { - val key = activeKey ?: return - cancelledKeys.add(key) - countdownJob?.cancel() - countdownJob = null - _state.value = IntroAutoSkipState.ShowingButton + /** + * Back / Escape / Android system back while the pill is showing: hide it and + * resolve the intro without moving playback. Returns true when a pill was + * actually dismissed, so the caller can consume the press only then — a + * second Back must behave normally. + */ + fun dismiss(): Boolean { + val key = activeKey ?: return false + resolved.add(key) + expiredKey = null + clearPrompt() + return true } + /** Clears all per-intro state, for when playback moves to different content. */ fun reset() { - cancelledKeys.clear() - countdownJob?.cancel() - countdownJob = null - activeKey = null - _state.value = IntroAutoSkipState.Hidden + resolved.clear() + expiredKey = null + lastMode = null + clearPrompt() } private suspend fun handle( inputs: Inputs, - onAutoSkipFire: suspend (toSeconds: Double) -> Unit, + onSeek: suspend (toSeconds: Double) -> Unit, ) { - val (pos, range, enabled, key) = inputs + val (pos, range, mode, key, playbackActive) = inputs - val insideRange = range != null && - key != null && - pos >= range.start && - pos < range.end + // A mode change re-evaluates from scratch: ask -> never takes the offer + // down, never -> always skips the intro the viewer is sitting in. + if (mode != lastMode) { + lastMode = mode + expiredKey = null + clearPrompt() + } - if (!insideRange) { - if (countdownJob != null) { - countdownJob?.cancel() - countdownJob = null - } - activeKey = null - if (_state.value !is IntroAutoSkipState.Hidden) { - _state.value = IntroAutoSkipState.Hidden - } + // The `always` pill is pinned to the intro it skipped, not to the + // position — the skip itself moved the position out of the range, so + // the "outside the range" rule below would take the undo down on the + // very next frame. + if (_state.value is IntroAutoSkipState.Skipped && key != null && key == activeKey) { + applyTimerGate(playbackActive) + return + } + + val inside = range != null && key != null && pos >= range.start && pos < range.end + if (!inside) { + // Leaving the range clears the timed-out marker, so seeking back in + // re-offers with a full timer. It does not clear `resolved`. + expiredKey = null + clearPrompt() return } - // insideRange ⇒ range and key non-null + // inside ⇒ range and key are non-null val safeRange = range!! val safeKey = key!! - // If the active key changed, drop any in-flight countdown. if (activeKey != null && activeKey != safeKey) { - countdownJob?.cancel() - countdownJob = null + expiredKey = null + clearPrompt() } - activeKey = safeKey - val isCancelled = safeKey in cancelledKeys - if (!enabled || isCancelled) { - if (countdownJob != null) { - countdownJob?.cancel() - countdownJob = null - } - if (_state.value !is IntroAutoSkipState.ShowingButton) { - _state.value = IntroAutoSkipState.ShowingButton + if (mode == IntroSkipMode.NEVER || safeKey in resolved || safeKey == expiredKey) { + clearPrompt() + return + } + + if (activeKey == null) { + // Hold the offer until playback is actually running, so the pill and + // its fill start together rather than the fill racing a player that + // is still coming up. Rebuffer dips are filtered upstream. + if (!playbackActive) return + activeKey = safeKey + activeRange = safeRange + remaining = countdownSeconds + when (mode) { + IntroSkipMode.ALWAYS -> { + activePrompt = Prompt.SKIPPED + _state.value = IntroAutoSkipState.Skipped(remaining) + startTimer() + onSeek(safeRange.end) + } + else -> { + activePrompt = Prompt.ASKING + _state.value = IntroAutoSkipState.Asking(remaining) + startTimer() + } } return } - // Auto-skip enabled, key not cancelled — start countdown if not already running for this key. - if (countdownJob?.isActive == true) return - countdownJob = scope.launch { - var remaining = countdownSeconds + // The offer is already up for this intro; only the pause gate can move. + applyTimerGate(playbackActive) + } + + /** Freezes the timer on pause and thaws it on play, keeping the pill up. */ + private fun applyTimerGate(playbackActive: Boolean) { + if (!playbackActive) { + stopTimerKeepingState() + return + } + if (timerJob == null && remaining > 0 && activePrompt != null) startTimer() + } + + /** + * Runs the wall-clock timer down in whole seconds from [remaining]. + * + * A freeze cancels the job without touching [remaining], so a resume + * continues from the same number rather than restarting from full. The + * partial second in flight when the pause landed is not carried across — + * the tick model has always been whole seconds, and the alternative is a + * second clock for the fill to disagree with. + */ + private fun startTimer() { + timerJob?.cancel() + _countdownRun.value += 1 + _timerRunning.value = true + timerJob = scope.launch { while (remaining > 0) { - _state.value = IntroAutoSkipState.CountingDown(remaining) + publishRemaining() delay(1000L) remaining -= 1 } + timerJob = null + _timerRunning.value = false + expire() + } + } + + private fun publishRemaining() { + _state.value = when (activePrompt) { + Prompt.ASKING -> IntroAutoSkipState.Asking(remaining) + Prompt.SKIPPED -> IntroAutoSkipState.Skipped(remaining) + null -> IntroAutoSkipState.Hidden + } + } + + /** + * Timer ran out. The two prompts differ here and only here: the `ask` offer + * withdraws without deciding anything, while the `always` undo resolves the + * intro — the viewer was told it was skipped and let it go. + */ + private fun expire() { + val key = activeKey + when (activePrompt) { + Prompt.SKIPPED -> if (key != null) resolved.add(key) + Prompt.ASKING -> expiredKey = key + null -> Unit + } + clearPrompt() + } + + private fun stopTimerKeepingState() { + timerJob?.cancel() + timerJob = null + _timerRunning.value = false + } + + /** Takes the pill down and drops its anchor, deciding nothing. */ + private fun clearPrompt() { + stopTimerKeepingState() + activeKey = null + activeRange = null + activePrompt = null + remaining = 0 + if (_state.value !is IntroAutoSkipState.Hidden) { _state.value = IntroAutoSkipState.Hidden - countdownJob = null - onAutoSkipFire(safeRange.end) } } private data class Inputs( val position: Double, val range: TimeRange?, - val enabled: Boolean, + val mode: IntroSkipMode, val key: String?, + val playbackActive: Boolean, ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroSkipMode.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroSkipMode.kt new file mode 100644 index 000000000..e93b1d489 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/IntroSkipMode.kt @@ -0,0 +1,53 @@ +package org.prairieserver.prairie.domain.player + +/** + * What Silo does when playback enters a detected intro — + * `playback.intro_skip_mode`, contract revision 7. + * + * The spec is the server repo's `docs/design/2026-08-16-intro-skip-mode.md`; + * [IntroAutoSkipController] implements the tables in its "Prompt behaviour" + * section and its tests assert them case for case. + */ +enum class IntroSkipMode { + /** Entering an intro does nothing: no pill, no skip. */ + NEVER, + + /** Offer a "Skip Intro" pill for [IntroAutoSkipController.totalCountdownSeconds]. */ + ASK, + + /** Skip immediately and offer an "Intro skipped — Watch Intro" undo. */ + ALWAYS, + ; + + /** The contract's enum member spelling. */ + val wireValue: String + get() = when (this) { + NEVER -> "never" + ASK -> "ask" + ALWAYS -> "always" + } + + companion object { + /** + * The contract default. Identical to what the deprecated + * `playback.auto_skip_intro = false` always did, so an untouched + * profile behaves the same across the cutover. + */ + val Default: IntroSkipMode = ASK + + /** Parses a stored/wire value; null for absent or unrecognized input. */ + fun fromWire(value: String?): IntroSkipMode? = when (value) { + "never" -> NEVER + "ask" -> ASK + "always" -> ALWAYS + else -> null + } + + /** + * The lossy compatibility direction, for a server whose contract + * predates revision 7 and therefore only answers the boolean. It cannot + * produce [NEVER] — nobody could express it before this cut. + */ + fun fromLegacyBoolean(autoSkip: Boolean): IntroSkipMode = if (autoSkip) ALWAYS else ASK + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdges.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdges.kt new file mode 100644 index 000000000..140518c2e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdges.kt @@ -0,0 +1,61 @@ +package org.prairieserver.prairie.domain.player + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +/** + * Passes `true` straight through, but only reports `false` once it has held for + * [graceMillis]. + * + * `isPlaying` dips false for a rebuffer exactly as it does for a deliberate + * pause, and consumers that treat the two alike misbehave on a stuttering + * stream. The intro countdown is the case in hand: a real pause is meant to + * restart it, so an unfiltered dip hands a stuttering stream a fresh countdown + * every time it hiccups — and the prompt can sit there indefinitely without + * ever firing. + * + * Asymmetric on purpose. Resuming is not worth delaying: the viewer can see + * playback running, and holding the countdown back for another second after it + * does looks like a bug. Only the pause edge is in doubt. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun Flow.settlingFalseEdges(graceMillis: Long): Flow = + distinctUntilChanged() + .flatMapLatest { active -> + if (active) { + flowOf(true) + } else { + // flatMapLatest cancels this if the value flips back inside the + // window, which is what swallows a short stall. + flow { + delay(graceMillis) + emit(false) + } + } + } + .distinctUntilChanged() + +/** + * [settlingFalseEdges] for a signal that is ambiguous, plus [deliberatelyInactive] + * for one that is not. + * + * The grace window exists because a dip in the ambiguous signal might be a + * stall. A viewer pressing pause is not in doubt, and waiting the window out + * before reporting it leaves a countdown visibly running under a paused + * picture. So that edge reports at once and only the ambiguous one settles. + */ +fun Flow.settlingFalseEdges( + graceMillis: Long, + deliberatelyInactive: Flow, +): Flow = + combine( + settlingFalseEdges(graceMillis), + deliberatelyInactive.distinctUntilChanged(), + ) { settled, stopped -> settled && !stopped } + .distinctUntilChanged() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt new file mode 100644 index 000000000..e465787a5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsController.kt @@ -0,0 +1,227 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.SettingsRepository +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * The profile-scoped half of the settings screens, shared by the phone and TV + * apps. + * + * These preferences used to ride `PUT /profiles/{id}` as named columns. They + * are canonical settings now: reads come from the batched effective endpoint + * (so a value set from another device, or narrowed by policy, is what the + * screen shows) and writes address `scope=profile` explicitly. Android no + * longer depends on the profile endpoint accepting `subtitle_language`, + * `subtitle_mode`, `show_forced_subtitles` or `preferred_metadata_language`, + * even though the server still mirrors them until the cutover. + * + * Both apps route through this one class deliberately: the two settings + * screens have drifted apart before, and a behavior that lives here cannot be + * present on one and missing on the other. + */ +class ProfileSettingsController( + private val repository: SettingsRepository, +) { + + /** + * Whether the connected server can serve canonical settings at all. + * [ServerUpgradeRequired] is not an error to swallow — the screen says so, + * and playback continues on local defaults. + */ + enum class Availability { + /** Not probed yet. */ + UNKNOWN, + AVAILABLE, + /** The server predates the canonical settings API (404 on the contract). */ + SERVER_UPGRADE_REQUIRED, + /** Reachable server, failed probe — transient, retryable. */ + UNAVAILABLE, + } + + /** The resolved profile preferences a settings screen renders. */ + data class Snapshot( + /** BCP 47 tag; "" is "no subtitle preference". */ + val subtitleLanguage: String = "", + /** One of "auto", "always", "off". */ + val subtitleMode: String = DEFAULT_SUBTITLE_MODE, + val showForcedSubtitles: Boolean = true, + /** BCP 47 tag; "" inherits the library metadata language. */ + val metadataLanguage: String = "", + val audioLanguageSuggestions: List = emptyList(), + val subtitleLanguageSuggestions: List = emptyList(), + val metadataLanguageSuggestions: List = emptyList(), + ) + + /** Probe result plus the values, so a screen loads both in one call. */ + data class LoadResult( + val availability: Availability, + val snapshot: Snapshot?, + ) + + /** + * The outcome of one setter: whether the write landed, and what the server + * resolves for these keys now. + * + * [snapshot] is what the screen should render — it may differ from what the + * user just chose when policy narrowed the value or a device-scoped row + * shadows the profile one. Null means the write landed but the re-resolve + * did not; the caller keeps its optimistic value rather than rolling back a + * change that did take effect. + */ + data class WriteResult( + val succeeded: Boolean, + val snapshot: Snapshot?, + ) + + /** + * Probes the contract and, when the server speaks it, resolves the profile + * keys. A failed *probe* leaves the snapshot null so the caller keeps + * whatever it had; a successful probe with a failed resolve is reported as + * [Availability.UNAVAILABLE] for the same reason. + */ + suspend fun load(): LoadResult { + val availability = when (repository.contractCapabilities()) { + is SettingsCapabilitiesResult.Available -> Availability.AVAILABLE + is SettingsCapabilitiesResult.ServerUpgradeRequired -> + Availability.SERVER_UPGRADE_REQUIRED + is SettingsCapabilitiesResult.Error, + is SettingsCapabilitiesResult.NetworkError -> Availability.UNAVAILABLE + } + if (availability != Availability.AVAILABLE) return LoadResult(availability, null) + + return when (val result = repository.getEffectiveValues(PROFILE_KEYS)) { + is ApiResult.Success -> LoadResult(availability, snapshotOf(result.data)) + is ApiResult.Error, is ApiResult.NetworkError -> + LoadResult(Availability.UNAVAILABLE, null) + } + } + + /** + * Re-resolves every profile key after a successful write. + * + * A stored value is not necessarily the effective one. Policy can narrow + * or lock a setting (`playback.preferred_quality` carries a `ceiling` + * today, and the response type has carried `constrained`/`stored_value` + * since the contract landed), and a `profile_device` row for the same key + * outranks the `profile` row these setters write — the resolver answers + * with the device id attached. In both cases the PUT succeeds and changes + * nothing the user can see, so keeping the optimistic value would leave + * the screen asserting a preference playback is not using. + * + * Returns null when the re-resolve itself fails, which is not an error the + * caller should surface: the write landed, and the optimistic value is + * still the best guess until the next load. + */ + private suspend fun reresolve(): Snapshot? = + when (val result = repository.getEffectiveValues(PROFILE_KEYS)) { + is ApiResult.Success -> snapshotOf(result.data) + is ApiResult.Error, is ApiResult.NetworkError -> null + } + + /** [language] is a BCP 47 tag, or "" for no preference. */ + suspend fun setSubtitleLanguage(language: String): WriteResult = + resolved(writeLanguage(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, language)) + + /** [mode] is a `playback.subtitle_mode` member: "auto", "always" or "off". */ + suspend fun setSubtitleMode(mode: String): WriteResult = + resolved(write(SettingKeys.PLAYBACK_SUBTITLE_MODE, JsonPrimitive(normalizeSubtitleMode(mode)))) + + suspend fun setShowForcedSubtitles(enabled: Boolean): WriteResult = + resolved(write(SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, JsonPrimitive(enabled))) + + /** [language] is a BCP 47 tag, or "" to inherit the library's language. */ + suspend fun setMetadataLanguage(language: String): WriteResult = + resolved(writeLanguage(SettingKeys.CATALOG_METADATA_LANGUAGE, language)) + + private suspend fun resolved(write: ApiResult): WriteResult = + if (write is ApiResult.Success) { + WriteResult(succeeded = true, snapshot = reresolve()) + } else { + WriteResult(succeeded = false, snapshot = null) + } + + private suspend fun writeLanguage(key: String, language: String): ApiResult { + val tag = language.trim() + // The store spells "no preference" as the empty string; the contract + // spells it as no row at all (the server's language_tag validator + // refuses ""). Clearing rather than writing null keeps the two the + // same statement and matches how the server mirrors the legacy column. + return if (tag.isEmpty()) repository.clearProfileValue(key) else write(key, JsonPrimitive(tag)) + } + + private suspend fun write(key: String, value: JsonElement): ApiResult = + when (val result = repository.setProfileValue(key, value)) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + is ApiResult.NetworkError -> result + } + + private fun snapshotOf(effective: Map): Snapshot = + Snapshot( + subtitleLanguage = effective.stringOrEmpty(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + subtitleMode = normalizeSubtitleMode( + effective.stringOrEmpty(SettingKeys.PLAYBACK_SUBTITLE_MODE), + ), + showForcedSubtitles = effective.boolOr(SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, true), + metadataLanguage = effective.stringOrEmpty(SettingKeys.CATALOG_METADATA_LANGUAGE), + audioLanguageSuggestions = + effective[SettingKeys.PLAYBACK_AUDIO_LANGUAGE]?.suggestedValues.orEmpty(), + subtitleLanguageSuggestions = + effective[SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE]?.suggestedValues.orEmpty(), + metadataLanguageSuggestions = + effective[SettingKeys.CATALOG_METADATA_LANGUAGE]?.suggestedValues.orEmpty(), + ) + + private companion object { + const val DEFAULT_SUBTITLE_MODE = "auto" + val SUBTITLE_MODES = setOf("auto", "always", "off") + + /** + * Every profile-scoped key these screens read, in one round trip. + * + * Quality is deliberately absent: on Android the two quality axes are + * device-scoped (the store owns them and "Reset Playback Overrides" + * clears them), so a profile-scope write would be shadowed by this + * device's own row and the picker would appear not to save. + */ + val PROFILE_KEYS: List = listOf( + SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + SettingKeys.PLAYBACK_SUBTITLE_MODE, + SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, + SettingKeys.CATALOG_METADATA_LANGUAGE, + ) + + fun normalizeSubtitleMode(value: String): String { + val v = value.trim().lowercase() + // The legacy empty string means unset, not a fourth mode. + return if (v in SUBTITLE_MODES) v else DEFAULT_SUBTITLE_MODE + } + + fun Map.stringOrEmpty(key: String): String { + val value = this[key]?.value ?: return "" + if (value is JsonNull) return "" + return runCatching { value.jsonPrimitive.content }.getOrDefault("") + } + + fun Map.boolOr(key: String, fallback: Boolean): Boolean { + val value = this[key]?.value ?: return fallback + return runCatching { value.jsonPrimitive.booleanOrNull }.getOrNull() ?: fallback + } + + fun Map.intOrNull(key: String): Int? { + val value = this[key]?.value ?: return null + if (value is JsonNull) return null + return runCatching { value.jsonPrimitive.intOrNull }.getOrNull() + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicy.kt deleted file mode 100644 index 4b755bda0..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicy.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.prairieserver.prairie.model.admin - -// The admin STATS dashboard (Apple-parity surface) is exposed to acting -// admins. The richer hub/users/sessions/logs/scans screens stay unlinked — -// Apple has no counterpart, so no menu should route to them. -const val CLIENT_ADMIN_SURFACE_ENABLED: Boolean = true - -fun shouldShowClientAdminSurface(isActingAdmin: Boolean): Boolean = - CLIENT_ADMIN_SURFACE_ENABLED && isActingAdmin diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminModels.kt deleted file mode 100644 index 9ab68f16d..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminModels.kt +++ /dev/null @@ -1,285 +0,0 @@ -// shared/src/commonMain/kotlin/org/prairieserver/prairie/model/admin/AdminModels.kt -package org.prairieserver.prairie.model.admin - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement - -// --------------------------------------------------------------------------- -// Stats — GET /api/v1/admin/stats[?refresh=true] -// (prairie-server internal/api/handlers/admin_stats.go: AdminStats / WatchProviderActivity) -// --------------------------------------------------------------------------- - -@Serializable -data class AdminStats( - @SerialName("total_items") val totalItems: Int = 0, - @SerialName("total_files") val totalFiles: Int = 0, - @SerialName("total_users") val totalUsers: Int = 0, - @SerialName("total_movies") val totalMovies: Int = 0, - @SerialName("total_movie_files") val totalMovieFiles: Int = 0, - @SerialName("total_shows") val totalShows: Int = 0, - @SerialName("total_show_files") val totalShowFiles: Int = 0, - @SerialName("active_streams") val activeStreams: Int = 0, - @SerialName("total_storage_bytes") val totalStorageBytes: Long = 0, - @SerialName("watch_provider_activity") val watchProviderActivity: WatchProviderActivity = WatchProviderActivity(), -) - -@Serializable -data class WatchProviderActivity( - @SerialName("trakt_connected_profiles") val traktConnectedProfiles: Long = 0, - @SerialName("trakt_enabled_profiles") val traktEnabledProfiles: Long = 0, - @SerialName("trakt_export_enabled") val traktExportEnabled: Long = 0, - @SerialName("trakt_scrobble_enabled") val traktScrobbleEnabled: Long = 0, - @SerialName("last_sync_completed_at") val lastSyncCompletedAt: String? = null, - @SerialName("sync_runs_24h") val syncRuns24h: Long = 0, - @SerialName("sync_errors_24h") val syncErrors24h: Long = 0, - @SerialName("imported_watched_24h") val importedWatched24h: Long = 0, - @SerialName("imported_progress_24h") val importedProgress24h: Long = 0, - @SerialName("exported_watched_24h") val exportedWatched24h: Long = 0, - @SerialName("pending_exports") val pendingExports: Long = 0, - @SerialName("failed_exports") val failedExports: Long = 0, - @SerialName("open_scrobbles") val openScrobbles: Long = 0, - @SerialName("scrobbles_24h") val scrobbles24h: Long = 0, -) - -// --------------------------------------------------------------------------- -// Users — GET/POST /admin/users, GET/PUT/DELETE /admin/users/{id} -// (admin.go: adminUserResponse / createUserRequest / updateUserRequest) -// The list endpoint returns a bare JSON array of AdminUser. -// --------------------------------------------------------------------------- - -@Serializable -data class AdminUser( - val id: Int, - val username: String, - val email: String, - val role: String, - val permissions: List = emptyList(), - val enabled: Boolean = true, - @SerialName("library_ids") val libraryIds: List = emptyList(), - @SerialName("max_playback_quality") val maxPlaybackQuality: String = "", - @SerialName("max_streams") val maxStreams: Int = 0, - @SerialName("max_transcodes") val maxTranscodes: Int = 0, - @SerialName("max_profiles") val maxProfiles: Int = 0, - @SerialName("download_allowed") val downloadAllowed: Boolean = false, - @SerialName("download_transcode_allowed") val downloadTranscodeAllowed: Boolean = false, - @SerialName("created_at") val createdAt: String = "", - @SerialName("updated_at") val updatedAt: String = "", - @SerialName("last_active_at") val lastActiveAt: String? = null, -) - -/** - * POST /admin/users body. Required: username/email/password/role. The server - * treats `permissions` and `library_ids` as present-when-sent; optional caps - * (`max_streams` etc.) and `download_*` are pointer fields server-side, so we - * leave them nullable and rely on explicitNulls=false to omit them. - */ -@Serializable -data class CreateUserRequest( - val username: String, - val email: String, - val password: String, - val role: String, - val permissions: List? = null, - @SerialName("create_default_profile") val createDefaultProfile: Boolean = false, - @SerialName("default_profile_name") val defaultProfileName: String? = null, - @SerialName("library_ids") val libraryIds: List = emptyList(), - @SerialName("max_playback_quality") val maxPlaybackQuality: String = "", - @SerialName("max_streams") val maxStreams: Int? = null, - @SerialName("max_transcodes") val maxTranscodes: Int? = null, - @SerialName("max_profiles") val maxProfiles: Int? = null, - @SerialName("download_allowed") val downloadAllowed: Boolean? = null, - @SerialName("download_transcode_allowed") val downloadTranscodeAllowed: Boolean? = null, -) - -/** - * PUT /admin/users/{id} — fully partial; every field is optional. With - * explicitNulls=false, unset (null) fields are omitted from the body, matching - * the server's "omitted key keeps current value" pointer semantics. - */ -@Serializable -data class UpdateUserRequest( - val username: String? = null, - val email: String? = null, - val password: String? = null, - val role: String? = null, - val permissions: List? = null, - val enabled: Boolean? = null, - @SerialName("library_ids") val libraryIds: List? = null, - @SerialName("max_playback_quality") val maxPlaybackQuality: String? = null, - @SerialName("max_streams") val maxStreams: Int? = null, - @SerialName("max_transcodes") val maxTranscodes: Int? = null, - @SerialName("max_profiles") val maxProfiles: Int? = null, - @SerialName("download_allowed") val downloadAllowed: Boolean? = null, - @SerialName("download_transcode_allowed") val downloadTranscodeAllowed: Boolean? = null, -) - -// --------------------------------------------------------------------------- -// Sessions — GET /admin/sessions returns a bare JSON array of AdminSession. -// (playback_sessions.go: playbackSessionRow — note `transcode_node_url` is -// json:"-" on the server and intentionally NOT modeled here.) -// --------------------------------------------------------------------------- - -@Serializable -data class AdminSession( - @SerialName("session_id") val sessionId: String, - @SerialName("user_id") val userId: Int, - val username: String, - @SerialName("profile_id") val profileId: String, - @SerialName("profile_name") val profileName: String = "", - @SerialName("media_file_id") val mediaFileId: Int, - @SerialName("requested_media_file_id") val requestedMediaFileId: Int, - @SerialName("content_id") val contentId: String = "", - @SerialName("media_title") val mediaTitle: String, - @SerialName("media_type") val mediaType: String, - @SerialName("series_name") val seriesName: String = "", - @SerialName("episode_name") val episodeName: String = "", - @SerialName("season_number") val seasonNumber: Int? = null, - @SerialName("episode_number") val episodeNumber: Int? = null, - @SerialName("poster_url") val posterUrl: String = "", - @SerialName("play_method") val playMethod: String, - @SerialName("reporting_node") val reportingNode: String, - @SerialName("node_display_name") val nodeDisplayName: String = "", - @SerialName("file_duration") val fileDuration: Int? = null, - @SerialName("started_at") val startedAt: String, - @SerialName("updated_at") val updatedAt: String, - @SerialName("position_seconds") val positionSeconds: Double = 0.0, - @SerialName("is_paused") val isPaused: Boolean = false, - @SerialName("has_playback_control") val hasPlaybackControl: Boolean = false, - @SerialName("client_ip") val clientIp: String = "", - @SerialName("audio_track_index") val audioTrackIndex: Int = 0, - @SerialName("transcode_audio") val transcodeAudio: Boolean = false, - @SerialName("stream_bitrate_kbps") val streamBitrateKbps: Int? = null, - @SerialName("target_resolution") val targetResolution: String = "", - @SerialName("target_video_codec") val targetVideoCodec: String = "", - @SerialName("target_audio_codec") val targetAudioCodec: String = "", - @SerialName("target_bitrate_kbps") val targetBitrateKbps: Int? = null, - @SerialName("transcode_hw_accel") val transcodeHwAccel: String = "", - @SerialName("source_container") val sourceContainer: String = "", - @SerialName("source_bitrate_kbps") val sourceBitrateKbps: Int? = null, - @SerialName("source_video_codec") val sourceVideoCodec: String = "", - @SerialName("source_video_resolution") val sourceVideoResolution: String = "", - @SerialName("source_audio_codec") val sourceAudioCodec: String = "", - @SerialName("source_audio_channels") val sourceAudioChannels: Int? = null, - @SerialName("source_audio_language") val sourceAudioLanguage: String = "", - @SerialName("source_audio_title") val sourceAudioTitle: String = "", - @SerialName("source_audio_layout") val sourceAudioLayout: String = "", - @SerialName("requested_video_codec") val requestedVideoCodec: String = "", - @SerialName("requested_video_resolution") val requestedVideoResolution: String = "", - @SerialName("video_decision") val videoDecision: String = "", - @SerialName("audio_decision") val audioDecision: String = "", -) - -/** - * Session control body for POST /admin/sessions/{id}/{pause|resume|stop|terminate|message}. - * (admin_playback_control.go: playbackControlRequest — `message`/`title` - * required only for the message action.) All fields optional here; callers - * populate only what the chosen action needs. - */ -@Serializable -data class SessionControlRequest( - val reason: String? = null, - val title: String? = null, - val message: String? = null, - @SerialName("deadline_ms") val deadlineMs: Int? = null, -) - -/** Response from a session control action (admin_playback_control.go: playbackControlResponse). */ -@Serializable -data class SessionControlResponse( - @SerialName("command_id") val commandId: String, - val status: String, -) - -/** Known session control actions (URL path segment). */ -enum class SessionControlAction(val wire: String) { - Pause("pause"), - Resume("resume"), - Stop("stop"), - Terminate("terminate"), - Message("message"), -} - -// --------------------------------------------------------------------------- -// Logs — GET /admin/logs/app and /admin/logs/audit -// (opslog.EntryRow / activitylog.AuditEntry; both pages: {entries, next_cursor?}) -// --------------------------------------------------------------------------- - -@Serializable -data class AdminLogEntry( - val id: Long, - val timestamp: String, - val level: String, - val component: String, - val message: String, - @SerialName("request_id") val requestId: String? = null, - @SerialName("user_id") val userId: Int? = null, - @SerialName("session_id") val sessionId: String? = null, - @SerialName("playback_session_id") val playbackSessionId: String? = null, - @SerialName("client_ip") val clientIp: String? = null, - @SerialName("node_id") val nodeId: String? = null, - val attrs: Map? = null, -) - -@Serializable -data class AdminAuditEntry( - val id: Long, - val timestamp: String, - @SerialName("client_ip") val clientIp: String, - @SerialName("user_id") val userId: Int? = null, - @SerialName("impersonator_user_id") val impersonatorUserId: Int? = null, - @SerialName("session_id") val sessionId: String? = null, - @SerialName("playback_session_id") val playbackSessionId: String? = null, - @SerialName("request_id") val requestId: String? = null, - @SerialName("node_id") val nodeId: String? = null, - val method: String, - val path: String, - @SerialName("path_pattern") val pathPattern: String? = null, - @SerialName("status_code") val statusCode: Int, - @SerialName("user_agent") val userAgent: String? = null, - @SerialName("duration_ms") val durationMs: Int = 0, -) - -/** App log page (opslog.ListResult). */ -@Serializable -data class AdminLogPage( - val entries: List = emptyList(), - @SerialName("next_cursor") val nextCursor: String? = null, -) - -/** Audit log page (activitylog.ListResult). */ -@Serializable -data class AdminAuditPage( - val entries: List = emptyList(), - @SerialName("next_cursor") val nextCursor: String? = null, -) - -// --------------------------------------------------------------------------- -// Scans — POST /libraries/scan and /libraries/scan/cancel -// (libraries.go: scanRequest / scanResponse / scanCancelRequest / scanCancelResponse) -// NOTE: these live under /libraries, NOT /admin — see AdminApi for placement. -// --------------------------------------------------------------------------- - -@Serializable -data class ScanRequest( - @SerialName("library_id") val libraryId: Int? = null, - val path: String? = null, -) - -@Serializable -data class ScanResponse( - val status: String, - val mode: String, - @SerialName("library_id") val libraryId: Int, -) - -@Serializable -data class ScanCancelRequest( - @SerialName("library_id") val libraryId: Int, -) - -@Serializable -data class ScanCancelResponse( - val cancelled: Int, - @SerialName("library_id") val libraryId: Int, -) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AdminPermissions.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AdminPermissions.kt deleted file mode 100644 index 031b96fd0..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AdminPermissions.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.prairieserver.prairie.model.auth - -import org.prairieserver.prairie.model.profile.Profile - -/** Admin role wire value (server `user.role`). */ -const val ADMIN_ROLE = "admin" - -/** - * Client mirror of the server's `RequireActingAdmin` gate (web - * `isActingAdmin(user, profile)`): the account role must be admin AND the - * active household profile must be the primary (owner) profile. - * - * A null [profile] is treated as "not yet resolved" and does NOT block an - * admin user — the active profile may not be loaded when the gate is first - * evaluated, and every admin route is still gated server-side (defense in - * depth). A null [user] is never acting-admin. - */ -fun isActingAdmin(user: User?, profile: Profile?): Boolean = - user?.role == ADMIN_ROLE && (profile == null || profile.isPrimary) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AuthModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AuthModels.kt index c14cf6afa..93c784a0a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AuthModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/AuthModels.kt @@ -71,18 +71,3 @@ data class SignupRequest( data class SignupStatusResponse( val enabled: Boolean ) - -@Serializable -data class AuthSession( - val id: String, - @SerialName("device_name") val deviceName: String, - @SerialName("ip_address") val ipAddress: String, - @SerialName("created_at") val createdAt: String, - @SerialName("expires_at") val expiresAt: String, - @SerialName("revoked_at") val revokedAt: String? = null -) - -@Serializable -data class SessionsResponse( - val sessions: List -) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt new file mode 100644 index 000000000..c33d8d08e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/auth/InvitationModels.kt @@ -0,0 +1,21 @@ +package org.prairieserver.prairie.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Emailed-invitation claim flow. The invitee's email address is their + * username; the claim screen asks for a password and nothing else. + */ +@Serializable +data class InvitationLookupResponse( + val email: String, + @SerialName("inviter_name") val inviterName: String? = null, + @SerialName("server_name") val serverName: String, + @SerialName("expires_at") val expiresAt: String, +) + +@Serializable +data class AcceptInvitationRequest( + val password: String, +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt index 924ec0b79..a30e8ee26 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt @@ -15,7 +15,7 @@ data class MediaItemUserState( /** * Tech-level overlay summary derived server-side from the best-ranked file - * (see prairie-server `internal/overlays/summary.go`). Wire keys match the server + * (see silo-server `internal/overlays/summary.go`). Wire keys match the server * JSON and Apple's `OverlaySummary` (which decodes via `.convertFromSnakeCase`, * so `audioChannels` ⇄ `audio_channels`, etc.). `audioChannels` arrives * pre-formatted (e.g. "5.1", "7.1", "Stereo"). @@ -81,7 +81,20 @@ data class CatalogResponse( val items: List = emptyList(), val source: String? = null, val title: String? = null, - val snapshot: String? = null + val snapshot: String? = null, + /** + * What the server actually sorted by. Sources with an intrinsic order + * (library collections keep their manual / MDBList / smart order when no + * `sort` is sent) echo the resolved field here, so a client that sent + * nothing can still say what it is looking at. + */ + @SerialName("effective_sort") val effectiveSort: CatalogEffectiveSort? = null +) + +@Serializable +data class CatalogEffectiveSort( + val field: String? = null, + val order: String? = null, ) data class CatalogQueryRule( @@ -190,6 +203,8 @@ data class ItemDetail( @SerialName("overlay_summary") val overlaySummary: OverlaySummary? = null, val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, /** Populated only when [type] is "audiobook". Forward-compat — the * server may stop returning it once a dedicated /api/v1/audiobooks * endpoint lands; until then it rides on ItemDetail. */ @@ -271,7 +286,7 @@ data class FileVersion( * the client renders whatever it receives. * * Shape mirrors the server's `VersionChapter` struct at - * `prairie-server/internal/catalog/detail.go:258-266` and Apple's + * `silo-server/internal/catalog/detail.go:258-266` and Apple's * `VersionChapter` at `iosApp/Networking/Models.swift:537-545` exactly. */ @Serializable @@ -372,14 +387,47 @@ data class SeasonsResponse( val seasons: List = emptyList() ) +fun Season.isSpecialsForDisplay(): Boolean = + isSpecials || seasonNumber == 0 + fun List.sortedForDisplay(): List = sortedWith( - compareBy { if (it.isSpecials) 1 else 0 } + compareByDescending { it.isSpecialsForDisplay() } .thenBy { it.seasonNumber } .thenBy { it.title.orEmpty() } .thenBy { it.contentId }, ) +private fun List.selectedSeasonForDisplay(preferredSeasonNumber: Int?): Season? { + return preferredSeasonNumber + ?.let { preferred -> firstOrNull { it.seasonNumber == preferred } } + ?: firstOrNull { !it.isSpecialsForDisplay() } + ?: firstOrNull() +} + +fun List.initialSeasonForDisplay(preferredSeasonNumber: Int?): Season? = + sortedForDisplay().selectedSeasonForDisplay(preferredSeasonNumber) + +data class InitialSeasonDisplayPlan( + val seasons: List, + val selectedSeasonNumber: Int?, +) { + val episodeRequestSeasonNumber: Int? + get() = selectedSeasonNumber +} + +fun List.initialSeasonDisplayPlan( + preferredSeasonNumber: Int?, +): InitialSeasonDisplayPlan { + val seasons = sortedForDisplay() + return InitialSeasonDisplayPlan( + seasons = seasons, + selectedSeasonNumber = seasons + .selectedSeasonForDisplay(preferredSeasonNumber) + ?.seasonNumber, + ) +} + @Serializable data class EpisodeListItem( @SerialName("content_id") val contentId: String, @@ -449,6 +497,8 @@ data class WatchDetail( val subtitles: List = emptyList(), val intro: TimeRange? = null, val credits: TimeRange? = null, + val recap: TimeRange? = null, + val preview: TimeRange? = null, @SerialName("user_data") val userData: LeafItemUserData? = null, @SerialName("series_id") val seriesId: String? = null, @SerialName("series_title") val seriesTitle: String? = null, @@ -457,11 +507,9 @@ data class WatchDetail( @SerialName("effective_subtitle_language") val effectiveSubtitleLanguage: String? = null, @SerialName("effective_subtitle_mode") val effectiveSubtitleMode: String? = null, @SerialName("effective_show_forced_subtitles") val effectiveShowForcedSubtitles: Boolean? = null, - // Presigned image URLs — match the server's ItemDetail response - // (prairie-server/internal/catalog/detail.go:100-104). Consumed by the - // phone player's Now Playing lock-screen metadata; TV side reads - // them too for the same purpose when the MediaSession-driven - // notification surfaces (system media controls). + // Optional forward-compatible artwork. Current servers expose these on + // ItemDetail rather than WatchDetail, so playback clients must fall back + // to the full catalog detail when these fields are absent. @SerialName("poster_url") val posterUrl: String? = null, @SerialName("poster_thumbhash") val posterThumbhash: String? = null, @SerialName("backdrop_url") val backdropUrl: String? = null, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsValidation.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsValidation.kt index ca9596d5b..3b7bfa5fa 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsValidation.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/diagnostics/DiagnosticsValidation.kt @@ -229,9 +229,16 @@ private fun requireDiagnostics(condition: Boolean, path: String, message: String if (!condition) throw DiagnosticsValidationException("$path: $message") } -private enum class DiagnosticsAttributeKind { STRING, INTEGER } +internal enum class DiagnosticsAttributeKind { STRING, INTEGER } -private val REGISTERED_ATTRIBUTES = mapOf( +/** + * Mirror of the canonical client-diagnostics v1 attribute registry. + * + * This map must stay byte-for-byte equivalent to the vendored + * `diagnostics/v1/attr-registry.json` contract fixture; the parity test in + * `DiagnosticsAttributeRegistryParityTest` is the gate that enforces it. + */ +internal val REGISTERED_ATTRIBUTES = mapOf( DiagnosticsLogCategory.PLAYBACK to mapOf( "sink" to DiagnosticsAttributeKind.STRING, "fmt" to DiagnosticsAttributeKind.STRING, @@ -242,6 +249,10 @@ private val REGISTERED_ATTRIBUTES = mapOf( "bitrate_kbps" to DiagnosticsAttributeKind.INTEGER, "dropped_frames" to DiagnosticsAttributeKind.INTEGER, "audio_underruns" to DiagnosticsAttributeKind.INTEGER, + "session_id" to DiagnosticsAttributeKind.STRING, + "play_method" to DiagnosticsAttributeKind.STRING, + "reason" to DiagnosticsAttributeKind.STRING, + "position_ms" to DiagnosticsAttributeKind.INTEGER, ), DiagnosticsLogCategory.FOCUS to mapOf( "target" to DiagnosticsAttributeKind.STRING, @@ -252,9 +263,17 @@ private val REGISTERED_ATTRIBUTES = mapOf( "path" to DiagnosticsAttributeKind.STRING, "status" to DiagnosticsAttributeKind.INTEGER, "duration_ms" to DiagnosticsAttributeKind.INTEGER, + "outcome" to DiagnosticsAttributeKind.STRING, + "error_code" to DiagnosticsAttributeKind.STRING, + "attempt" to DiagnosticsAttributeKind.INTEGER, ), DiagnosticsLogCategory.LIFECYCLE to mapOf( "state" to DiagnosticsAttributeKind.STRING, + "phase" to DiagnosticsAttributeKind.STRING, + "duration_ms" to DiagnosticsAttributeKind.INTEGER, + "outcome" to DiagnosticsAttributeKind.STRING, + "reason" to DiagnosticsAttributeKind.STRING, + "launch_type" to DiagnosticsAttributeKind.STRING, ), DiagnosticsLogCategory.CRASH to mapOf( "fingerprint" to DiagnosticsAttributeKind.STRING, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt index 94f96978a..5fc61e86b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicy.kt @@ -5,4 +5,4 @@ package org.prairieserver.prairie.model.feature * appear in normal user navigation yet. Routes, repositories, and deep-link * plumbing can remain compiled while menus/actions stay hidden. */ -const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = true +const val CLIENT_WATCH_TOGETHER_SURFACE_ENABLED: Boolean = false diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt index 20941ae8b..98dcf37aa 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/navigation/MediaMode.kt @@ -38,7 +38,7 @@ private val videoLibraryTypes = setOf( // here did not degrade the library — it erased it: the type mapped to no // MediaMode, so the library never reached navigation, search or browse on // either platform, with nothing to indicate anything was missing. - // silo-apple hit the same thing and fixed it in #93. + // prairie-apple hit the same thing and fixed it in #93. "mixed", ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt new file mode 100644 index 000000000..ae7981ecd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModels.kt @@ -0,0 +1,63 @@ +package org.prairieserver.prairie.model.onboarding + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Server-driven onboarding tour manifest. The server has already filtered + * out steps for disabled features and for the requested surface; the client + * renders the step kinds it knows and silently skips the rest — that skip is + * the forward-compatibility contract. + */ +@Serializable +data class OnboardingFlow( + val version: Int, + @SerialName("tour_id") val tourId: String, + val steps: List = emptyList(), +) + +@Serializable +data class OnboardingStep( + val id: String, + /** Open string on purpose: unknown kinds must be skipped, not fail decode. */ + val kind: String, + val title: String? = null, + val body: String? = null, + /** Client-side asset key; the server never sends image URLs. */ + val illustration: String? = null, + val setting: OnboardingSettingSpec? = null, +) + +@Serializable +data class OnboardingSettingSpec( + /** "profile_field" | "setting" | "device_setting" — selects the write API. */ + val target: String, + val key: String, + val control: String, + val options: List = emptyList(), + val default: String? = null, + val label: String? = null, +) + +@Serializable +data class OnboardingSettingOption( + val value: String, + val label: String, +) + +@Serializable +data class OnboardingState( + @SerialName("tour_id") val tourId: String, + @SerialName("last_step") val lastStep: String? = null, + @SerialName("completed_at") val completedAt: String? = null, + @SerialName("skipped_at") val skippedAt: String? = null, + val done: Boolean = false, +) + +@Serializable +data class OnboardingProgressRequest( + @SerialName("tour_id") val tourId: String, + @SerialName("last_step") val lastStep: String? = null, + val completed: Boolean = false, + val skipped: Boolean = false, +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt new file mode 100644 index 000000000..013079741 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolver.kt @@ -0,0 +1,233 @@ +package org.prairieserver.prairie.model.playback + +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily +import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired + +/** + * The ONE subtitle auto-selection resolver. + * + * The detail page's "Auto - " preview and the player's no-handoff + * fallback used to be two independent implementations (plus a third on phone), + * with divergent inventories, SDH detection, bitmap detection and language + * folding. They disagreed in the field: the detail row previewed an external + * SRT while playback started on an embedded PGS track, because the player only + * ever ranked tracks Media3 had already mounted. + * + * The DETAIL PAGE's semantics are the reference behaviour — its ordering and + * cascade are what the viewer sees and what QA signed off (tvOS parity, QA + * 2026-07-09). Candidates are supplied in the caller's own iteration order and + * carry a [AutoSubtitleCandidate.selectionIndex] in the server's COMBINED + * selection space, so the winner can be handed straight to a playback start + * request. + */ +data class AutoSubtitleCandidate( + /** + * COMBINED-space selection index (externals first, embedded after) — the + * identity `subtitle_track_index` requests and session `subtitle_urls` + * resolve against. Callers that only need an ordinal (the detail preview) + * may put their own ordinal here; the resolver never interprets it. + */ + val selectionIndex: Int, + val language: String? = null, + val codec: String? = null, + /** Catalog/track title. Feeds the SDH predicate alongside [hearingImpaired]. */ + val title: String? = null, + val forced: Boolean = false, + /** + * A hearing-impaired signal the caller already knows (Media3 role flags, an + * accessibility label). ORed with a title match — never a replacement for + * it, because the catalog only ever says SDH in the title. + */ + val hearingImpaired: Boolean = false, +) + +/** Cascaded preference inputs. Same shape on every surface. */ +data class AutoSubtitleContext( + /** Cascaded `subtitle_language`. `null` = no preference; empty = "no subs". */ + val preferredLanguage: String?, + /** Cascaded `subtitle_mode`. `null`/blank → "auto". */ + val mode: String?, + /** Whether forced subs should be auto-selected when available. */ + val showForced: Boolean = false, + /** Language of the audio track that will play. */ + val audioLanguage: String? = null, +) + +sealed class AutoSubtitleResolution { + /** Auto picked nothing, and nothing needs turning off. */ + data object NoChange : AutoSubtitleResolution() + + /** Auto decided subtitles must be off. */ + data object Disable : AutoSubtitleResolution() + + data class Select(val candidate: AutoSubtitleCandidate) : AutoSubtitleResolution() +} + +/** The chosen candidate, or null when Auto resolves to no subtitle at all. */ +fun AutoSubtitleResolution.selectedCandidate(): AutoSubtitleCandidate? = + (this as? AutoSubtitleResolution.Select)?.candidate + +/** + * Resolves the track Auto should start with. + * + * Cascade (unchanged from the detail page): + * mode `off` / an explicitly empty preferred language → off; no preferred + * language → only mode `always` picks anything; audio already in the preferred + * language under mode `auto` → off, or the language's forced track when forced + * subs are enabled; otherwise the best track in the preferred language, falling + * back to any forced track when forced subs are enabled. + * + * Within a pool: full-dialogue text → non-forced text → any text → first. + * Bitmap tracks stay DEPRIORITISED, never excluded: a bitmap track that is the + * only candidate still wins. + * + * "Show forced subtitles" is a SEPARATE setting and never outranks the + * viewer's full-subtitle preference: when subtitles are wanted (mode `always`, + * or `auto` with foreign audio) the full-dialogue track wins and a forced + * track is only the last resort when the language has nothing else. Forced + * leads only in the branch where subtitles would otherwise be OFF (audio + * already in the preferred language). Product owner call, 2026-08-16: an + * "English – Always" profile with forced enabled was starting on the Forced + * track of a disc that also carried a plain English track. + */ +fun resolveAutoSubtitle( + candidates: List, + context: AutoSubtitleContext, +): AutoSubtitleResolution { + if (candidates.isEmpty()) return AutoSubtitleResolution.NoChange + + val mode = context.mode?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: "auto" + if (mode == "off") return AutoSubtitleResolution.Disable + + val preferred = context.preferredLanguage + if (preferred != null && preferred.isBlank()) return AutoSubtitleResolution.Disable + + val targetLanguage = autoSubtitleLanguageKey(preferred) + if (targetLanguage == null) { + if (mode != "always") return AutoSubtitleResolution.NoChange + return bestAutoSubtitleCandidate(candidates, null) + ?.let(AutoSubtitleResolution::Select) + ?: AutoSubtitleResolution.NoChange + } + + val audioLanguage = autoSubtitleLanguageKey(context.audioLanguage) + if (mode == "auto" && audioLanguage != null && audioLanguage == targetLanguage) { + if (context.showForced) { + bestForcedAutoSubtitleCandidate(candidates, targetLanguage) + // Idempotent re-select even when this track is already on: + // NoChange is reserved for "no track should be on", so a + // launch-time consumer can map it to an explicit disable + // without turning off a forced track the defaults picked. + ?.let { return AutoSubtitleResolution.Select(it) } + } + return AutoSubtitleResolution.Disable + } + + val target = bestAutoSubtitleCandidate(candidates, targetLanguage) + ?: if (context.showForced) candidates.firstOrNull { it.forced } else null + return target?.let(AutoSubtitleResolution::Select) ?: AutoSubtitleResolution.NoChange +} + +private fun bestAutoSubtitleCandidate( + candidates: List, + targetLanguage: String?, +): AutoSubtitleCandidate? { + val pool = if (targetLanguage == null) { + candidates + } else { + candidates.filter { autoSubtitleLanguageKey(it.language) == targetLanguage } + } + if (pool.isEmpty()) return null + + pool.firstOrNull { !it.forced && !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } + pool.firstOrNull { !it.forced && !it.isBitmap() }?.let { return it } + pool.firstOrNull { !it.isBitmap() }?.let { return it } + return pool.first() +} + +private fun bestForcedAutoSubtitleCandidate( + candidates: List, + targetLanguage: String?, +): AutoSubtitleCandidate? { + val pool = candidates + .filter { targetLanguage == null || autoSubtitleLanguageKey(it.language) == targetLanguage } + .filter { it.forced } + if (pool.isEmpty()) return null + + pool.firstOrNull { !it.isHearingImpaired() && !it.isBitmap() }?.let { return it } + pool.firstOrNull { !it.isHearingImpaired() }?.let { return it } + return pool.first() +} + +/** The ONE SDH predicate: an explicit signal, or the track's own title. */ +fun AutoSubtitleCandidate.isHearingImpaired(): Boolean = + hearingImpaired || subtitleLabelIndicatesHearingImpaired(title) + +/** The ONE bitmap predicate (PGS / VobSub / DVB / HDMV aliases). */ +private fun AutoSubtitleCandidate.isBitmap(): Boolean = isBitmapSubtitleCodecFamily(codec) + +/** + * The ONE ISO-639 folding table for auto-selection language comparison. + * + * Deliberately smaller than the display-name alias table and deliberately + * drops `und`: it answers "is this the language the viewer asked for", not + * "what do we call this language". + */ +fun autoSubtitleLanguageKey(language: String?): String? { + val primary = language + ?.trim() + ?.takeUnless { it.isBlank() || it.equals("und", ignoreCase = true) } + ?.lowercase() + ?.replace('_', '-') + ?.substringBefore('-') + ?: return null + return when (primary) { + "eng" -> "en" + "spa" -> "es" + "fre", "fra" -> "fr" + "ger", "deu" -> "de" + "dut", "nld" -> "nl" + "jpn" -> "ja" + "dan" -> "da" + else -> primary + } +} + +/** + * Candidates over the CATALOG subtitle list, in catalog order, addressed in + * combined selection space — the inventory the detail page previews and the + * one a playback start request can act on. + */ +fun catalogAutoSubtitleCandidates( + catalogTracks: List, +): List { + val combined = combinedSubtitleSelectionIndexes(catalogTracks) + return catalogTracks.mapIndexed { ordinal, track -> + AutoSubtitleCandidate( + selectionIndex = combined[ordinal], + language = track.language, + codec = track.codec, + title = track.title, + forced = track.forced, + ) + } +} + +/** + * Candidates over the SERVER subtitle inventory (`subtitle_urls`), which + * includes external sidecars the player has not mounted yet. Ranking an + * unmounted sidecar is the point: resolving over Media3's mounted text tracks + * alone made every external row structurally invisible. + */ +fun inventoryAutoSubtitleCandidates( + rows: List, +): List = rows.map { row -> + AutoSubtitleCandidate( + selectionIndex = row.index, + language = row.language, + codec = row.codec, + title = row.catalogLabel ?: row.label, + forced = row.forced == true, + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt index cd094440e..89933c14a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackModels.kt @@ -66,8 +66,22 @@ data class PlayerSubtitleInfo( @SerialName("download_id") val downloadId: Int? = null, /** Optional exact Media3 Format.id retained by client-created/local rows. */ @SerialName("media_track_id") val mediaTrackId: String? = null, + /** Exact protocol-v3 server identity from the authoritative subtitle inventory. */ + @SerialName("server_track_id") val serverTrackId: String? = null, + /** Protocol-v3 representation: `sidecar` or `burn_in_only`. */ + @SerialName("server_delivery") val serverDelivery: String? = null, ) +/** + * Whether this row represents a subtitle artifact downloaded onto this Android + * device, rather than a server-managed provider download in the v3 inventory. + */ +fun PlayerSubtitleInfo.isLocalDownloadedSubtitle(): Boolean = + serverTrackId == null && serverDelivery == null && + (downloadId != null || + source.equals("downloaded", ignoreCase = true) || + catalogSource.equals("downloaded", ignoreCase = true)) + /** * Granular HDR support advertised by the client. Optional; absent means the * server uses the legacy [ClientCodecCapabilities.hdr] boolean for SDR-vs-HDR @@ -127,6 +141,15 @@ data class VideoDecodeCapability( @Serializable data class ClientCodecCapabilities( + /** + * How the video capability list was obtained. Android probes + * `MediaCodecList` for concrete profile/level/bit-depth tuples, so it + * advertises [CAPABILITY_EVIDENCE_EXACT] — the only tier the server will + * strictly validate against, and the only one that earns audio passthrough. + */ + @SerialName("video_evidence") val videoEvidence: String = CAPABILITY_EVIDENCE_EXACT, + /** Evidence tier for the audio lists, on the same scale as [videoEvidence]. */ + @SerialName("audio_evidence") val audioEvidence: String = CAPABILITY_EVIDENCE_EXACT, @SerialName("codecs_video") val codecsVideo: List = emptyList(), // Hardware-decodable subset. In the Media3-only protocol this currently // equals codecsVideo; it remains on the wire for older server readers. @@ -149,16 +172,6 @@ enum class PlaybackDelivery { @SerialName("client_local_normalization") CLIENT_LOCAL_NORMALIZATION, } -@Serializable -enum class PlaybackEngineKind { - @SerialName("media3_direct") MEDIA3_DIRECT, - @SerialName("mpv_direct") MPV_DIRECT, - @SerialName("media3_progressive_remux") MEDIA3_PROGRESSIVE_REMUX, - @SerialName("media3_hls") MEDIA3_HLS, - @SerialName("client_local_loopback") CLIENT_LOCAL_LOOPBACK, - @SerialName("external_player") EXTERNAL_PLAYER, -} - @Serializable enum class PlaybackRouteFamily { @SerialName("platform_native") PLATFORM_NATIVE, @@ -167,24 +180,27 @@ enum class PlaybackRouteFamily { @SerialName("client_normalized") CLIENT_NORMALIZED, } +/** + * The player-facing projection of a [PlaybackPlanV3], built by + * `PlaybackV3Session.toSessionResponse`. It is a UI view of the plan, not a wire + * type of its own: the server's neutral contract has no notion of a client + * engine or route family, so those are derived here from the plan's delivery. + */ @Serializable data class PlaybackExecutionPlan( @SerialName("plan_id") val planId: String, - @SerialName("protocol_version") val protocolVersion: Int = 2, + @SerialName("protocol_version") val protocolVersion: Int = PLAYBACK_PROTOCOL_V3, val delivery: PlaybackDelivery, - val engine: PlaybackEngineKind, @SerialName("route_family") val routeFamily: PlaybackRouteFamily, val stream: PlaybackStreamRequest = PlaybackStreamRequest(), val timeline: PlaybackTimeline = PlaybackTimeline(), @SerialName("selected_tracks") val selectedTracks: SelectedPlaybackTracks = SelectedPlaybackTracks(), val source: PlaybackSourceMetadata = PlaybackSourceMetadata(), - val capabilities: RouteCapabilitySnapshot = RouteCapabilitySnapshot(), - val requirements: RouteRequirements = RouteRequirements(), val claims: PlaybackValidationClaims = PlaybackValidationClaims(), val transformations: List = emptyList(), @SerialName("applied_quirks") val appliedQuirks: List = emptyList(), @SerialName("runtime_corrections") val runtimeCorrections: List = emptyList(), - val fallbacks: List = emptyList(), + @SerialName("available_qualities") val availableQualities: List = emptyList(), @SerialName("degradation_warnings") val degradationWarnings: List = emptyList(), @SerialName("decision_trace") val decisionTrace: List = emptyList(), @SerialName("requested_media_file_id") val requestedMediaFileId: Int? = null, @@ -192,14 +208,12 @@ data class PlaybackExecutionPlan( ) /** - * Deserializes a [PlaybackExecutionPlan] but yields `null` when the server sends - * a present-but-incomplete/malformed plan (a missing required field such as - * `plan_id`/`delivery`/`engine`/`route_family`, a malformed `fallbacks[]` / - * `degradation_warnings[]` entry, or an unknown enum value). Without this, a - * single missing field throws [SerializationException] and fails the decode of - * the ENTIRE session-start response — turning an HTTP-200 into a NetworkError so - * playback never starts. A null plan instead makes the client fall back to the - * legacy V1 routing, which is the safe degrade. + * Deserializes a [PlaybackExecutionPlan] but yields `null` when the value is + * present-but-malformed (a missing required field such as + * `plan_id`/`delivery`/`route_family`, a malformed `degradation_warnings[]` + * entry, or an unknown enum value). Without this, a single missing field throws + * [SerializationException] and fails the decode of the ENTIRE session-start + * response — turning an HTTP-200 into a NetworkError so playback never starts. */ @OptIn(ExperimentalSerializationApi::class) internal object TolerantPlaybackPlanSerializer : KSerializer { @@ -265,22 +279,6 @@ data class PlaybackSourceMetadata( @SerialName("letterbox_bottom_fraction") val letterboxBottomFraction: Double = 0.0, ) -@Serializable -data class RouteCapabilitySnapshot( - @SerialName("engine_available") val engineAvailable: Boolean = true, - @SerialName("validated_claims") val validatedClaims: List = emptyList(), - val blockers: List = emptyList(), -) - -@Serializable -data class RouteRequirements( - @SerialName("requires_hdr_preservation") val requiresHdrPreservation: Boolean = false, - @SerialName("requires_dolby_vision_preservation") val requiresDolbyVisionPreservation: Boolean = false, - @SerialName("requires_audio_passthrough") val requiresAudioPassthrough: Boolean = false, - @SerialName("requires_ass_fidelity") val requiresAssFidelity: Boolean = false, - @SerialName("requires_bitmap_subtitles") val requiresBitmapSubtitles: Boolean = false, -) - @Serializable data class PlaybackValidationClaims( val video: VideoValidationClaims = VideoValidationClaims(), @@ -314,51 +312,59 @@ data class SubtitleValidationClaims( val reason: String? = null, ) -@Serializable -data class PlaybackFallbackCandidate( - val delivery: PlaybackDelivery, - val engine: PlaybackEngineKind, - val reason: String, -) - @Serializable data class PlaybackDegradationWarning( val code: String, val message: String, ) +/** + * Everything the server needs to know about *this device and its current + * output route*, as opposed to the codec lists in [ClientCodecCapabilities]. + * + * There is deliberately no `platform` field and no second `features` list here: + * feature advertisement lives exclusively in the request's top-level + * `client_features`, and the platform is inferred by the server from the + * capability evidence and delivery classes it is given. + */ @Serializable data class ClientPlaybackContext( @SerialName("protocol_version") val protocolVersion: Int = PLAYBACK_PROTOCOL_V3, - val features: List = listOf( - PLAYBACK_PLAN_V3_FEATURE, - MEDIA3_ONLY_FEATURE, - DETAILED_DECODE_CAPABILITIES_FEATURE, - DEVICE_QUIRKS_V3_FEATURE, - SEEK_REANCHOR_V3_FEATURE, - ), - val platform: String = "android", @SerialName("form_factor") val formFactor: String, @SerialName("app_version") val appVersion: String, + /** + * CI's per-marketing-version build counter behind [appVersion], so two + * builds sharing a version name are still distinguishable in the server's + * session/activity views. Null where the platform has no such value. + */ + @SerialName("app_build") val appBuild: String? = null, + /** How this build was distributed — "release" / "beta" / "sideload" / "dev". */ + @SerialName("app_channel") val appChannel: String? = null, val device: PlaybackDeviceContext = PlaybackDeviceContext(), val output: PlaybackOutputContext = PlaybackOutputContext(), - val engines: Map = emptyMap(), + /** + * What this client is willing and able to play, keyed by delivery class + * ([DELIVERY_CLASS_ORIGINAL_HTTP], [DELIVERY_CLASS_PROGRESSIVE], + * [DELIVERY_CLASS_HLS]). Replaces the old engine self-description: the + * server negotiates against transports, not against a client's internal + * player component names. + */ + val deliveries: Map = emptyMap(), ) +/** + * Neutral device identity. Everything platform-specific — SoC, build + * fingerprint, SDK level, ABIs — goes in [platformDetails] as opaque + * string pairs so the server can key device quirks on it without the contract + * growing an Android-shaped hole. + */ @Serializable data class PlaybackDeviceContext( + val platform: String? = null, + @SerialName("os_version") val osVersion: String? = null, val manufacturer: String? = null, val model: String? = null, - val brand: String? = null, - val device: String? = null, - val product: String? = null, - @SerialName("soc_manufacturer") val socManufacturer: String? = null, - @SerialName("soc_model") val socModel: String? = null, - @SerialName("build_id") val buildId: String? = null, - @SerialName("build_display") val buildDisplay: String? = null, - @SerialName("security_patch") val securityPatch: String? = null, - @SerialName("sdk_int") val sdkInt: Int? = null, - val abis: List = emptyList(), + @SerialName("platform_details") val platformDetails: Map = emptyMap(), ) @Serializable @@ -367,13 +373,22 @@ data class PlaybackOutputContext( @SerialName("audio_passthrough") val audioPassthrough: AudioPassthroughCapabilities? = null, @SerialName("current_sink") val currentSink: String? = null, @SerialName("sink_type") val sinkType: String? = null, - @SerialName("output_route_generation") val outputRouteGeneration: Long = 0, + /** + * Opaque token identifying the current output route. The server only ever + * compares it for equality — Android supplies its route generation + * stringified, Apple a synthetic sink hash, web omits it. + */ + @SerialName("output_context_id") val outputContextId: String? = null, ) +/** What the client can do with one delivery class. */ @Serializable -data class EngineCapabilityEnvelope( +data class DeliveryCapability( + /** Whether the client is *willing* to be routed here. */ val enabled: Boolean = true, + /** Whether the client is *able* to play it at all on this device. */ @SerialName("supported_on_device") val supportedOnDevice: Boolean = true, + /** Diagnostics only; the server never routes on this string. */ @SerialName("failure_reason") val failureReason: String? = null, val containers: List = emptyList(), @SerialName("video_codecs") val videoCodecs: List = emptyList(), @@ -381,7 +396,7 @@ data class EngineCapabilityEnvelope( @SerialName("audio_passthrough_codecs") val audioPassthroughCodecs: List = emptyList(), @SerialName("max_channels") val maxChannels: Int? = null, @SerialName("hdr_details") val hdrDetails: HdrCapabilities? = null, - val subtitles: EngineSubtitleCapabilities = EngineSubtitleCapabilities(), + val subtitles: DeliverySubtitleCapabilities = DeliverySubtitleCapabilities(), val features: List = emptyList(), val transformations: List = emptyList(), @SerialName("auth_header_refresh") val authHeaderRefresh: Boolean = false, @@ -389,7 +404,7 @@ data class EngineCapabilityEnvelope( ) @Serializable -data class EngineSubtitleCapabilities( +data class DeliverySubtitleCapabilities( @SerialName("embedded_text") val embeddedText: Boolean = true, @SerialName("sidecar_text") val sidecarText: Boolean = true, @SerialName("ass_styling") val assStyling: Boolean = false, @@ -398,70 +413,8 @@ data class EngineSubtitleCapabilities( @SerialName("font_attachments") val fontAttachments: Boolean = false, ) -/** - * Body for `POST /api/v1/playback/start`. - * - * The server expects codec/container/HDR fields **flat at the top level** — - * see `Prairie/internal/api/handlers/playback.go::startPlaybackRequest`. A - * previous version of this class nested them under `client_capabilities`, - * which the Go JSON decoder silently ignored; the server then saw empty codec - * lists and force-transcoded every stream. Keep this flat. - */ -@Serializable -data class StartPlaybackRequest( - @SerialName("file_id") val fileId: Int, - @SerialName("profile_id") val profileId: String? = null, - @SerialName("play_method") val playMethod: String? = null, - @SerialName("start_position") val startPosition: Double? = null, - @SerialName("audio_track_index") val audioTrackIndex: Int? = null, - @SerialName("subtitle_track_index") val subtitleTrackIndex: Int? = null, - @SerialName("quality_preference") val qualityPreference: String? = null, - @SerialName("preserve_direct_audio_selection") val preserveDirectAudioSelection: Boolean = false, - @SerialName("codecs_video") val codecsVideo: List = emptyList(), - @SerialName("codecs_audio") val codecsAudio: List = emptyList(), - val containers: List = emptyList(), - @SerialName("max_resolution") val maxResolution: String? = null, - val hdr: Boolean = false, - @SerialName("hdr_details") val hdrDetails: HdrCapabilities? = null, - @SerialName("audio_passthrough") val audioPassthrough: AudioPassthroughCapabilities? = null, - @SerialName("client_playback_context") val clientPlaybackContext: ClientPlaybackContext? = null, - @SerialName("disable_progress_persistence") val disableProgressPersistence: Boolean = false, - // Set when the stream URL is handed to a device that can only seek via - // HTTP Range or an HLS VOD manifest (e.g. a Cast receiver): the server - // upgrades a would-be progressive remux (unseekable live pipe) to a - // transcode session. - @SerialName("seekable_streams_only") val seekableStreamsOnly: Boolean = false, -) - @Serializable data class ProgressRequest( val position: Double, @SerialName("is_paused") val isPaused: Boolean ) - -@Serializable -data class TranscodeStartRequest( - @SerialName("session_id") val sessionId: String, - @SerialName("seek_seconds") val seekSeconds: Double, - @SerialName("target_resolution") val targetResolution: String? = null, - @SerialName("target_codec_video") val targetCodecVideo: String? = null, - @SerialName("target_codec_audio") val targetCodecAudio: String? = null, - @SerialName("target_bitrate_kbps") val targetBitrateKbps: Int, - @SerialName("segment_duration") val segmentDuration: Int, - @SerialName("audio_track_index") val audioTrackIndex: Int? = null, - @SerialName("subtitle_track_index") val subtitleTrackIndex: Int? = null, - @SerialName("subtitle_burn_in") val subtitleBurnIn: Boolean -) - -@Serializable -data class TranscodeStartResponse( - @SerialName("session_id") val sessionId: String, - val status: String, - @SerialName("switched_file_id") val switchedFileId: Int? = null, - @SerialName("manifest_url") val manifestUrl: String, - @SerialName("duration_seconds") val durationSeconds: Double? = null, - @SerialName("player_start_seconds") val playerStartSeconds: Double = 0.0, - @SerialName("stream_origin_seconds") val streamOriginSeconds: Double = 0.0, - @SerialName("timeline_offset_seconds") val timelineOffsetSeconds: Double = 0.0, - @SerialName("can_seek_anywhere") val canSeekAnywhere: Boolean = false -) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt index d27f58e47..5c2b18c0d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3.kt @@ -14,15 +14,48 @@ import kotlinx.serialization.json.decodeFromJsonElement const val PLAYBACK_PROTOCOL_V3 = 3 const val PLAYBACK_PLAN_V3_FEATURE = "playback_plan_v3" -const val MEDIA3_ONLY_FEATURE = "media3_only" -const val DETAILED_DECODE_CAPABILITIES_FEATURE = "detailed_decode_capabilities" +const val NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE = "neutral_playback_v3_contract_v1" const val LAYOUT_AWARE_PASSTHROUGH_FEATURE = "layout_aware_passthrough" const val CLIENT_VIDEO_TRANSFORMATIONS_FEATURE = "client_video_transformations_v1" const val DEVICE_QUIRKS_V3_FEATURE = "device_quirks_v1" const val SEEK_REANCHOR_V3_FEATURE = "seek_reanchor_v1" const val DIRECT_STREAM_RESUME_V1_FEATURE = "direct_stream_resume_v1" + +/** + * How a capability list was obtained. The server validates strictly against + * [CAPABILITY_EVIDENCE_EXACT] and only grants audio passthrough at that tier; + * weaker tiers exist so a platform that cannot enumerate decoders does not have + * to fabricate profile/level tuples to be understood. + */ +const val CAPABILITY_EVIDENCE_EXACT = "exact" +const val CAPABILITY_EVIDENCE_PLATFORM_ATTESTED = "platform_attested" +const val CAPABILITY_EVIDENCE_DECLARED = "declared" + +/** Transport classes a client negotiates over; the unit of [DeliveryCapability]. */ +const val DELIVERY_CLASS_ORIGINAL_HTTP = "original_http" +const val DELIVERY_CLASS_PROGRESSIVE = "progressive" +const val DELIVERY_CLASS_HLS = "hls" + +/** Subtitle delivery values understood by this client. */ +const val SUBTITLE_DELIVERY_SIDECAR = "sidecar" +const val SUBTITLE_DELIVERY_BURN_IN_ONLY = "burn_in_only" + +/** Replan operations. Omitted means [FAILURE_RECOVERY_V3_OPERATION]. */ +const val FAILURE_RECOVERY_V3_OPERATION = "failure_recovery" const val SEEK_REANCHOR_V3_OPERATION = "seek_reanchor" const val SEEK_FAILURE_RECOVERY_V3_OPERATION = "seek_failure_recovery" +const val TRACK_CHANGE_V3_OPERATION = "track_change" +const val QUALITY_CHANGE_V3_OPERATION = "quality_change" + +/** + * Operations that describe a user-initiated change rather than a failure, and so + * carry no `failure.classification`. + */ +val INTENT_V3_OPERATIONS = setOf(TRACK_CHANGE_V3_OPERATION, QUALITY_CHANGE_V3_OPERATION) + +/** The quality rung that asks the server to preserve the source as-is. */ +const val QUALITY_ORIGINAL_V3 = "original" + const val CLIENT_DV7_TO_DV81 = "client_dv7_to_dv81" const val CLIENT_DV7_TO_HDR10 = "client_dv7_to_hdr10" const val CLIENT_DV_TRANSFORM_RECIPE_VERSION = "1" @@ -30,17 +63,33 @@ const val CLIENT_DV8_HDR10_PLUS_SANITIZER = "client_dv8_hdr10plus_sanitizer_v1" const val CLIENT_POST_RESUME_VIDEO_RECOVERY = "client_post_resume_video_recovery_v1" const val CLIENT_SURFACE_RECOVERY = "client_surface_recovery_v1" -/** Features the client advertises on `POST /api/v1/playback/start`. */ +/** Features the client advertises unconditionally on every v3 request. */ val PLAYBACK_START_CLIENT_FEATURES_V3 = listOf( PLAYBACK_PLAN_V3_FEATURE, - MEDIA3_ONLY_FEATURE, - DETAILED_DECODE_CAPABILITIES_FEATURE, CLIENT_VIDEO_TRANSFORMATIONS_FEATURE, DEVICE_QUIRKS_V3_FEATURE, SEEK_REANCHOR_V3_FEATURE, DIRECT_STREAM_RESUME_V1_FEATURE, ) +/** + * The features to advertise for a given output context. + * + * `client_features` lives only at the top level of a start/replan request — the + * neutral contract deliberately has no second features list inside the playback + * context — so anything conditional on the device's current output has to be + * folded in here. [LAYOUT_AWARE_PASSTHROUGH_FEATURE] is exactly that: the + * server grants a validated passthrough claim only when the client both + * advertises the feature and enumerates real per-codec channel layouts, so + * claiming it with an empty entry list would be a claim we cannot back. + */ +fun playbackClientFeaturesV3(context: ClientPlaybackContext): List = buildList { + addAll(PLAYBACK_START_CLIENT_FEATURES_V3) + if (!context.output.audioPassthrough?.entries.isNullOrEmpty()) { + add(LAYOUT_AWARE_PASSTHROUGH_FEATURE) + } +} + @Serializable enum class PlaybackDecisionOutcome { @SerialName("playable") PLAYABLE, @@ -74,6 +123,12 @@ enum class SubtitleFidelityPreference { @SerialName("compatible") COMPATIBLE, } +@Serializable +enum class ProgressPersistenceV3 { + @SerialName("server") SERVER, + @SerialName("client") CLIENT, +} + @Serializable data class PlaybackDecisionResponseV3( @SerialName("protocol_version") val protocolVersion: Int? = null, @@ -117,10 +172,15 @@ internal object TolerantPlaybackPlanV3Serializer : KSerializer data class PlaybackPlanV3( @SerialName("protocol_version") val protocolVersion: Int = PLAYBACK_PROTOCOL_V3, @SerialName("plan_id") val planId: String, + /** + * Server-minted opaque loop-prevention token for this plan. The client + * stores it and echoes the keys of everything it has already attempted in + * `attempted_plan_keys`; it never computes a key itself. + */ + @SerialName("plan_attempt_key") val planAttemptKey: String = "", @SerialName("session_id") val sessionId: String? = null, @SerialName("expires_at") val expiresAt: String? = null, val delivery: PlaybackDelivery, - val engine: PlaybackEngineKind, val stream: PlaybackStreamV3, val timeline: PlaybackTimelineV3 = PlaybackTimelineV3(), @SerialName("selected_tracks") val selectedTracks: SelectedPlaybackTracksV3 = SelectedPlaybackTracksV3(), @@ -130,11 +190,27 @@ data class PlaybackPlanV3( val transformations: List = emptyList(), @SerialName("applied_quirks") val appliedQuirks: List = emptyList(), @SerialName("runtime_corrections") val runtimeCorrections: List = emptyList(), + /** + * The quality rungs the server will honor for this source, in the order it + * wants them shown. The client renders this menu; it never derives rungs + * from the source resolution itself. + */ + @SerialName("available_qualities") val availableQualities: List = emptyList(), @SerialName("degradation_warnings") val degradationWarnings: List = emptyList(), @SerialName("decision_reason") val decisionReason: String, @SerialName("requested_media_file_id") val requestedMediaFileId: Int? = null, @SerialName("effective_media_file_id") val effectiveMediaFileId: Int? = null, val source: PlaybackSourceDescriptorV3 = PlaybackSourceDescriptorV3(), + @SerialName("subtitle_fidelity_policy") val subtitleFidelityPolicy: String? = null, +) + +/** One selectable rung of [PlaybackPlanV3.availableQualities]. */ +@Serializable +data class PlaybackAvailableQualityV3( + val label: String, + val height: Int = 0, + @SerialName("bitrate_kbps") val bitrateKbps: Int = 0, + @SerialName("preserves_source") val preservesSource: Boolean = false, ) /** @@ -158,7 +234,13 @@ data class PlaybackSourceDescriptorV3( * reports the window produced so far, not the runtime. */ @SerialName("duration_seconds") val durationSeconds: Double? = null, + val container: String? = null, + @SerialName("video_codec") val videoCodec: String? = null, @SerialName("color_range") val colorRange: String? = null, + val width: Int = 0, + val height: Int = 0, + @SerialName("dynamic_range") val dynamicRange: String? = null, + @SerialName("audio_codec") val audioCodec: String? = null, @SerialName("letterbox_top_fraction") val letterboxTopFraction: Double = 0.0, @SerialName("letterbox_bottom_fraction") val letterboxBottomFraction: Double = 0.0, ) @@ -232,8 +314,42 @@ data class PlaybackSubtitleDecisionV3( val mode: PlaybackSubtitleModeV3 = PlaybackSubtitleModeV3.OFF, @SerialName("track_id") val trackId: String? = null, val artifact: PlaybackSubtitleArtifactV3? = null, + /** + * The complete, gap-free combined-ordinal subtitle list for the effective + * source. Authoritative: select a track by echoing an entry's + * [PlaybackSubtitleInventoryItemV3.trackId] or + * [PlaybackSubtitleInventoryItemV3.combinedIndex], never by counting tracks + * or taking `max(index) + 1`. + */ + val inventory: List = emptyList(), ) +/** One selectable subtitle track at its frozen combined ordinal. */ +@Serializable +data class PlaybackSubtitleInventoryItemV3( + @SerialName("track_id") val trackId: String, + @SerialName("combined_index") val combinedIndex: Int, + val source: String, + val codec: String? = null, + val language: String? = null, + val label: String? = null, + val forced: Boolean = false, + @SerialName("default") val isDefault: Boolean = false, + @SerialName("hearing_impaired") val hearingImpaired: Boolean = false, + /** `sidecar` or `burn_in_only`; the last carries no [url]. */ + val delivery: String = "", + val url: String? = null, + @SerialName("font_bundle_url") val fontBundleUrl: String? = null, +) + +/** Resolves the optional ordinal from the stable server track identity. */ +fun PlaybackPlanV3.resolvedSelectedSubtitleIndex(): Int? = + selectedTracks.subtitle?.let { selected -> + selected.index ?: subtitle.inventory + .firstOrNull { it.trackId == selected.id } + ?.combinedIndex + } + @Serializable data class PlaybackTransformationV3( val name: String, @@ -258,18 +374,18 @@ data class PlaybackTerminalV3( @Serializable data class PlaybackStartRequestV3( @SerialName("protocol_version") val protocolVersion: Int = PLAYBACK_PROTOCOL_V3, - @SerialName("client_features") val clientFeatures: List = PLAYBACK_START_CLIENT_FEATURES_V3, + @SerialName("client_features") val clientFeatures: List, @SerialName("file_id") val fileId: Int, @SerialName("profile_id") val profileId: String, @SerialName("playback_attempt_id") val playbackAttemptId: String, @SerialName("quality_preference") val qualityPreference: String = "auto", @SerialName("subtitle_fidelity_preference") val subtitleFidelityPreference: SubtitleFidelityPreference, @SerialName("start_position") val startPosition: Double? = null, + @SerialName("progress_persistence") val progressPersistence: ProgressPersistenceV3 = ProgressPersistenceV3.SERVER, @SerialName("audio_track_id") val audioTrackId: String? = null, @SerialName("audio_track_index") val audioTrackIndex: Int? = null, @SerialName("subtitle_track_id") val subtitleTrackId: String? = null, @SerialName("subtitle_track_index") val subtitleTrackIndex: Int? = null, - @SerialName("output_route_generation") val outputRouteGeneration: Long, val metered: Boolean = false, @SerialName("bandwidth_estimate_kbps") val bandwidthEstimateKbps: Int? = null, @SerialName("bandwidth_cap_kbps") val bandwidthCapKbps: Int? = null, @@ -287,11 +403,8 @@ data class PlaybackFailureV3( @Serializable data class PlaybackReplanRequestV3( @SerialName("protocol_version") val protocolVersion: Int = PLAYBACK_PROTOCOL_V3, - /** - * Omitted requests retain the protocol-v3 failure-recovery behavior. - * Explicit operations are negotiated independently through client/server - * features so an older server never has to infer new semantics. - */ + @SerialName("client_features") val clientFeatures: List, + /** Omitted means [FAILURE_RECOVERY_V3_OPERATION]. */ val operation: String? = null, @SerialName("playback_attempt_id") val playbackAttemptId: String, @SerialName("replan_request_id") val replanRequestId: String, @@ -299,15 +412,26 @@ data class PlaybackReplanRequestV3( @SerialName("plan_attempt_id") val planAttemptId: String, @SerialName("plan_attempt_key") val planAttemptKey: String, @SerialName("attempted_plan_keys") val attemptedPlanKeys: List, + /** + * Client-applied mutations the server should fold into the next attempt + * key, so two plans that differ only by something the client did locally do + * not collide. The client reports the mutations; the server does the + * hashing. + */ + @SerialName("local_mutations") val localMutations: List = emptyList(), @SerialName("attempt_count") val attemptCount: Int, @SerialName("quality_preference") val qualityPreference: String = "auto", @SerialName("position_seconds") val positionSeconds: Double, - @SerialName("output_route_generation") val outputRouteGeneration: Long, val metered: Boolean = false, @SerialName("bandwidth_estimate_kbps") val bandwidthEstimateKbps: Int? = null, @SerialName("bandwidth_cap_kbps") val bandwidthCapKbps: Int? = null, @SerialName("selected_tracks") val selectedTracks: SelectedPlaybackTracksV3, - val failure: PlaybackFailureV3, + /** + * Absent for intent operations ([INTENT_V3_OPERATIONS]), which describe a + * user's choice, and for [SEEK_REANCHOR_V3_OPERATION], which requests a + * different transport anchor rather than reporting a route failure. + */ + val failure: PlaybackFailureV3? = null, @SerialName("client_capabilities") val capabilities: ClientCodecCapabilities, @SerialName("client_playback_context") val clientPlaybackContext: ClientPlaybackContext, ) @@ -325,48 +449,10 @@ data class PlaybackRouteEventV3( @SerialName("fallback_reason") val fallbackReason: String? = null, @SerialName("applied_quirk_ids") val appliedQuirkIds: List = emptyList(), @SerialName("quirk_registry_revision") val quirkRegistryRevision: String? = null, - @SerialName("output_route_generation") val outputRouteGeneration: Long, + @SerialName("output_context_id") val outputContextId: String? = null, val diagnostics: Map = emptyMap(), ) -fun PlaybackPlanV3.planAttemptKey( - outputRouteGeneration: Long, - localMutations: List = emptyList(), -): String { - val canonical = buildString { - append(planId) - append('|').append(delivery.name) - append('|').append(stream.protocol.name) - append('|').append(stream.container.orEmpty().lowercase()) - append('|').append(effectiveRecipe.videoCodec.orEmpty().lowercase()) - append('|').append(effectiveRecipe.audioCodec.orEmpty().lowercase()) - append('|').append(effectiveRecipe.width ?: 0) - append('x').append(effectiveRecipe.height ?: 0) - append('|').append(effectiveRecipe.bitrateKbps ?: 0) - append('|').append(effectiveRecipe.dynamicRange.orEmpty().lowercase()) - append('|').append(subtitle.mode.name) - append('|').append(transformations.map { - "${it.executor.name.lowercase()}:${it.name}:${it.recipeVersion}" - }.sorted().joinToString(",") { - it - }) - if (appliedQuirks.isNotEmpty() || runtimeCorrections.isNotEmpty()) { - append('|').append(appliedQuirks.map { - "${it.registryRevision}:${it.id}" - }.sorted().joinToString(",")) - append('|').append(runtimeCorrections.sorted().joinToString(",")) - } - append('|').append(outputRouteGeneration) - append('|').append(localMutations.sorted().joinToString(",")) - } - var hash = 0xcbf29ce484222325uL - canonical.encodeToByteArray().forEach { byte -> - hash = hash xor byte.toUByte().toULong() - hash *= 0x100000001b3uL - } - return "v3:${hash.toString(16).padStart(16, '0')}" -} - sealed interface PlaybackV3Validation { data class Playable(val plan: PlaybackPlanV3, val sessionId: String) : PlaybackV3Validation data class Terminal(val reason: String, val message: String, val retryable: Boolean) : PlaybackV3Validation @@ -375,7 +461,10 @@ sealed interface PlaybackV3Validation { } fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { - if (protocolVersion != PLAYBACK_PROTOCOL_V3 || PLAYBACK_PLAN_V3_FEATURE !in serverFeatures) { + if (protocolVersion != PLAYBACK_PROTOCOL_V3 || + PLAYBACK_PLAN_V3_FEATURE !in serverFeatures || + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE !in serverFeatures + ) { return PlaybackV3Validation.Incompatible(sessionId) } if (outcome == PlaybackDecisionOutcome.ADAPTATION_UNAVAILABLE) { @@ -400,11 +489,37 @@ fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { if (plan.protocolVersion != PLAYBACK_PROTOCOL_V3) { return PlaybackV3Validation.Terminal("invalid_playback_plan", "The server returned an unsupported plan version.", false) } - if (plan.engine == PlaybackEngineKind.MPV_DIRECT || - plan.engine == PlaybackEngineKind.CLIENT_LOCAL_LOOPBACK || - plan.engine == PlaybackEngineKind.EXTERNAL_PLAYER + if (plan.planAttemptKey.isBlank()) { + return PlaybackV3Validation.Terminal( + "invalid_playback_plan", + "The server returned no plan-attempt identity.", + false, + ) + } + if (!plan.hasValidSubtitleInventory()) { + return PlaybackV3Validation.Terminal( + "invalid_playback_plan", + "The server returned an invalid subtitle inventory.", + false, + ) + } + val selectedSubtitle = plan.selectedTracks.subtitle?.let { selected -> + plan.subtitle.inventory.firstOrNull { + it.trackId == selected.id && + (selected.index == null || it.combinedIndex == selected.index) + } + } + if (selectedSubtitle != null && + selectedSubtitle.delivery !in setOf( + SUBTITLE_DELIVERY_SIDECAR, + SUBTITLE_DELIVERY_BURN_IN_ONLY, + ) ) { - return PlaybackV3Validation.ReplanRequired("unsupported_legacy_engine", plan, resolvedSessionId) + return PlaybackV3Validation.ReplanRequired( + "unsupported_subtitle_delivery:${selectedSubtitle.delivery}", + plan, + resolvedSessionId, + ) } if (plan.stream.url.isBlank()) { return PlaybackV3Validation.Terminal("invalid_playback_plan", "The server returned an empty stream URL.", false) @@ -453,11 +568,15 @@ fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { resolvedSessionId, ) } + // A client-side Dolby Vision rewrite edits the elementary stream on its way + // into the decoder, which the client only owns when it is playing the + // original file. On any server-produced delivery the server already made + // the dynamic-range decision and there is nothing left to rewrite. if (plan.transformations.any { it.executor == PlaybackTransformationExecutor.CLIENT } && - plan.engine != PlaybackEngineKind.MEDIA3_DIRECT + plan.delivery != PlaybackDelivery.ORIGINAL_HTTP ) { return PlaybackV3Validation.ReplanRequired( - "client_transformation_requires_media3_direct", + "client_transformation_requires_original_delivery", plan, resolvedSessionId, ) @@ -470,6 +589,34 @@ fun PlaybackDecisionResponseV3.validateForMedia3(): PlaybackV3Validation { return PlaybackV3Validation.Playable(plan, resolvedSessionId) } +private fun PlaybackPlanV3.hasValidSubtitleInventory(): Boolean { + if (subtitle.inventory.map { it.combinedIndex }.sorted() != subtitle.inventory.indices.toList()) { + return false + } + if (subtitle.inventory.any { it.trackId.isBlank() } || + subtitle.inventory.map { it.trackId }.distinct().size != subtitle.inventory.size + ) { + return false + } + if (subtitle.inventory.any { item -> + when (item.delivery) { + SUBTITLE_DELIVERY_SIDECAR -> item.url.isNullOrBlank() + SUBTITLE_DELIVERY_BURN_IN_ONLY -> !item.url.isNullOrBlank() + else -> true + } + } + ) { + return false + } + val selected = selectedTracks.subtitle + if (subtitle.artifact != null && selected == null) return false + if (selected == null) return true + return subtitle.inventory.any { + it.trackId == selected.id && + (selected.index == null || it.combinedIndex == selected.index) + } +} + fun PlaybackPlanV3.executableMedia3ClientTransformations(): List = transformations.executableMedia3ClientTransformations() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt index afb273bd5..d60d2a734 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoices.kt @@ -87,6 +87,39 @@ fun buildPlaybackSubtitleChoices( .distinctBy(PlayerSubtitleInfo::index) } +/** + * Enriches a protocol-v3 subtitle inventory without changing its membership. + * + * Unlike [buildPlaybackSubtitleChoices], this function never synthesizes a row + * from catalog metadata: `PlaybackPlanV3.subtitle.inventory` is complete and + * authoritative, including when it is empty. Catalog data may only fill display + * metadata on the exact combined ordinal the server already published. + */ +fun enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks: List, + plannedTracks: List, +): List { + if (plannedTracks.isEmpty()) return emptyList() + val catalogByCombinedIndex = combinedSubtitleSelectionIndexes(catalogTracks) + .zip(catalogTracks) + .toMap() + return plannedTracks + .distinctBy(PlayerSubtitleInfo::index) + .map { planned -> + val catalog = catalogByCombinedIndex[planned.index] ?: return@map planned + planned.copy( + language = planned.language ?: catalog.language, + codec = planned.codec ?: catalog.codec, + label = planned.label ?: catalog.title, + source = planned.source ?: if (catalog.external) "external" else "embedded", + forced = planned.forced ?: catalog.forced, + catalogLabel = catalog.title, + catalogSource = if (catalog.external) "external" else "embedded", + isDefault = catalog.isDefault, + ) + } +} + private val DOWNLOADED_SUBTITLE_SESSION_PATH = Regex("""(/stream/)([^/]+)(/subtitles/[0-9]+\.[^/]+)$""") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt index 2d3613cdc..2c7db6ef9 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/playback/SubtitleTransition.kt @@ -39,6 +39,15 @@ data class CommittedSubtitle( val identity: SubtitleIdentity, val audioTrackIndex: Int? = null, val qualityPreference: String? = null, + /** + * Whether the AUDIO was what this commit changed. + * + * Every commit carries the current audio index, including one that only + * changed subtitles — so the index alone cannot say whether the viewer + * chose that audio or merely happens to be listening to it. Callers that + * persist an audio preference need the difference. + */ + val audioPreferenceSpecified: Boolean = false, ) data class PendingSubtitle( @@ -171,7 +180,12 @@ private fun SubtitleTransitionState.select(identity: SubtitleIdentity): Subtitle ) } if (identity.requiresClientMount()) { - val updated = CommittedSubtitle(identity, audioTrackIndex, qualityPreference) + val updated = CommittedSubtitle( + identity = identity, + audioTrackIndex = audioTrackIndex, + qualityPreference = qualityPreference, + audioPreferenceSpecified = pending?.audioPreferenceSpecified == true, + ) return SubtitleTransitionResult( state = copy( committed = updated, @@ -244,6 +258,7 @@ private fun SubtitleTransitionState.validate( identity = latest.identity, audioTrackIndex = latest.audioTrackIndex, qualityPreference = latest.qualityPreference, + audioPreferenceSpecified = latest.audioPreferenceSpecified, ) return SubtitleTransitionResult( state = copy(committed = updated, pending = null), diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt index 5e99a11da..3fc4dfaff 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/profile/ProfileModels.kt @@ -8,6 +8,19 @@ data class Profile( val id: String, val name: String, val avatar: String? = null, + /** + * Fetchable URL for [avatar], supplied by the server. + * + * For an `upload:` ref this is a **presigned** object-store URL that the + * client could never construct itself (it carries an AWS SigV4 signature) + * and that expires — the server currently signs for 900 seconds. It is + * therefore authoritative but perishable: fetch it, cache the *bytes* under + * a key derived from the stable [avatar] ref, and never persist the signed + * URL as if it were durable. See `ProfileAvatarSupport` on the Android side. + */ + @SerialName("avatar_url") val avatarUrl: String? = null, + /** How [avatar] was produced — e.g. `upload`, `preset`, `emoji`, `initials`. */ + @SerialName("avatar_source") val avatarSource: String? = null, @SerialName("is_primary") val isPrimary: Boolean = false, @SerialName("has_pin") val hasPin: Boolean = false, @SerialName("is_child") val isChild: Boolean = false, @@ -45,7 +58,6 @@ data class CreateProfileRequest( val pin: String? = null, @SerialName("is_child") val isChild: Boolean? = null, @SerialName("max_content_rating") val maxContentRating: String? = null, - @SerialName("quality_preference") val qualityPreference: String? = null, val language: String? = null, @SerialName("subtitle_language") val subtitleLanguage: String? = null, @SerialName("preferred_metadata_language") val preferredMetadataLanguage: String? = null, @@ -94,3 +106,20 @@ data class VerifyPinResponse( @SerialName("profile_token") val profileToken: String? = null, @SerialName("expires_at") val expiresAt: String? = null ) + +/** + * The profile token to commit for a successful PIN verification, or null if + * this response does not authorize entry. + * + * Fail closed on shape, not just on [VerifyPinResponse.valid]: the token is + * the artifact that proves the PIN was entered, and the server rejects + * management calls that cannot present one. Treating a bare `valid=true` with + * no token as success let the client enter a protected profile holding nothing + * to prove it — the failure then surfaced much later, as a confusing 403 on an + * unrelated action. + * + * Expiry is left to the server, which validates the token on every use; the + * client does not parse [VerifyPinResponse.expiresAt]. + */ +fun VerifyPinResponse.authorizedProfileToken(): String? = + profileToken?.takeIf { valid && it.isNotBlank() } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/recommendation/RecommendationModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/recommendation/RecommendationModels.kt index 0f771da12..2783247a9 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/recommendation/RecommendationModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/recommendation/RecommendationModels.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.Serializable data class DiscoverRow( val type: String, val label: String, + @SerialName("section_kind") val sectionKind: String? = null, + @SerialName("section_key") val sectionKey: String? = null, val items: List = emptyList(), ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt index 60292a3ce..ab158c521 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/server/ServerEntry.kt @@ -10,7 +10,8 @@ import kotlinx.serialization.Serializable * the same value share an id, so re-adding the same server upserts rather * than duplicates. * - * Names: [fetchedName] is what the server reports via `/api/v1/health`; + * Names: [fetchedName] is the server's native branding identity, with the + * legacy health name used only when branding is unavailable; * [userOverrideName] is whatever the user types in the rename dialog and * always wins when both are present. */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt new file mode 100644 index 000000000..84d9290c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/LanguageOptions.kt @@ -0,0 +1,101 @@ +package org.prairieserver.prairie.model.settings + +import org.prairieserver.prairie.playback.canonicalSubtitleLanguage + +/** Platform CLDR/ICU display name for a valid BCP 47 tag. */ +internal expect fun localizedLanguageName(tag: String): String + +/** Alias-aware identity that retains explicit script and region subtags. */ +internal expect fun canonicalLanguageIdentity(tag: String): String + +/** + * Presentation adapter for the generated settings language catalogs. + * + * `language_tag` stays open: the generated list is a stable floor, the server + * may add deployment-observed tags, and an exact current value is always kept + * selectable. Labels come from the platform locale rather than a hand-written + * English table that can drift between phone, TV, web, and Apple clients. + */ +object LanguageOptions { + /** Wire value meaning "no preference"; the server stores this as null. */ + const val UNSET = "" + + fun namedOptions( + key: String, + currentValue: String? = null, + runtimeValues: List = emptyList(), + ): List> { + val values = mutableListOf() + val indexByLanguage = mutableMapOf() + + fun add(rawValue: String, replaceAlias: Boolean) { + val value = rawValue.trim() + if (!isPreservableTag(value)) return + val identity = canonicalLanguageIdentity(value) + val existing = indexByLanguage[identity] + if (existing != null) { + if (replaceAlias) values[existing] = value + return + } + indexByLanguage[identity] = values.size + values += value + } + + SettingPresentationMetadata.suggestedValues(key).forEach { add(it, false) } + runtimeValues.forEach { add(it, false) } + currentValue?.let { add(it, true) } + + return values.map { it to localizedLanguageName(it) } + } + + /** Full picker list led by the contract's context-specific unset copy. */ + fun options( + key: String, + currentValue: String? = null, + runtimeValues: List = emptyList(), + ): List> = + listOf(UNSET to unsetLabel(key)) + namedOptions(key, currentValue, runtimeValues) + + fun label(wire: String?, key: String): String { + if (wire.isNullOrBlank()) return unsetLabel(key) + return if (isPreservableTag(wire)) localizedLanguageName(wire) else unsetLabel(key) + } + + /** Resolve a selected display label against the exact options rendered. */ + fun wireValue(label: String?, options: List>): String = + options.firstOrNull { it.second == label }?.first ?: UNSET + + /** + * Translates values written by older builds that persisted English labels. + * This compatibility map is not a picker catalog; new choices come only + * from the generated contract and server response. + */ + fun migrateLegacyValue(stored: String?): String = when { + stored.isNullOrBlank() -> UNSET + isPreservableTag(stored) -> stored + else -> legacyEnglishLabels[stored] ?: UNSET + } + + private fun unsetLabel(key: String): String = + SettingPresentationMetadata.DEFINITIONS[key]?.unsetLabel ?: "Unset" + + private fun isPreservableTag(value: String): Boolean = + value.isNotBlank() && + !value.equals("Off", ignoreCase = true) && + !value.equals("Default", ignoreCase = true) && + Regex("^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{1,8})*$").matches(value) && + canonicalSubtitleLanguage(value) != null + + private val legacyEnglishLabels = mapOf( + "English" to "en", + "Spanish" to "es", + "French" to "fr", + "German" to "de", + "Japanese" to "ja", + "Korean" to "ko", + "Chinese" to "zh", + "Portuguese" to "pt", + "Italian" to "it", + "Russian" to "ru", + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt index e255adc65..1df43c83b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/PlaybackSettingsKeys.kt @@ -2,26 +2,51 @@ package org.prairieserver.prairie.model.settings object PlaybackSettingsKeys { const val PreferredQuality = "playback.preferred_quality" + + /** + * The bandwidth half of the quality choice, orthogonal to + * [PreferredQuality]. Nullable on the wire — uncapped is the *absence* of + * a value, not a large number — which the local store spells as 0, the one + * value the contract's 100..200000 range cannot hold. + */ + const val MaxBitrateKbps = "playback.max_bitrate_kbps" const val AudioLanguage = "playback.audio_language" + /** + * Deprecated in contract revision 7 by [IntroSkipMode], which says what a + * boolean could not: `never` (no prompt at all). Kept because the profile + * DTO carries it as a NOT NULL column and older clients still read it; the + * server mirrors the two keys at write time for one release. Nothing in + * this app should read it except as the pre-revision-7 fallback. + */ const val AutoSkipIntro = "playback.auto_skip_intro" + + /** + * `never` | `ask` | `always` — see + * [org.prairieserver.prairie.domain.player.IntroSkipMode] and the server's + * `docs/design/2026-08-16-intro-skip-mode.md`. Same scopes and resolution + * order as [AutoSkipIntro], which it supersedes. + */ + const val IntroSkipMode = "playback.intro_skip_mode" const val AutoSkipCredits = "playback.auto_skip_credits" const val AutoPlayNext = "playback.auto_play_next" - const val SubtitleAppearance = "subtitle_appearance" + // Renamed at the settings cutover: every other key carries a domain prefix + // and this one did not, so the contract registers it as + // playback.subtitle_appearance. Old servers are gone by the time this + // ships, so there is no dual-write to the server — but the local slot an + // installed build already wrote is still on disk under the old spelling, + // so [RenamedLocalKeys] copies it forward once. + const val SubtitleAppearance = "playback.subtitle_appearance" const val HdrEnabled = "player.hdr_enabled" const val PlaybackSpeed = "player.playback_speed" const val AudioSyncMs = "player.audio_sync_ms" const val SubtitleSyncMs = "player.subtitle_sync_ms" - - /** - * Per-item subtitle sync overrides, encoded as `contentId=ms` pairs. - * Deliberately absent from [DeviceSettings]: it is local-only, because the - * server has no schema for it and an unknown key would poison a settings - * flush batch. - */ - const val SubtitleSyncMsByItem = "player.subtitle_sync_ms_by_item" const val VideoGravity = "player.video_gravity" const val OrientationMode = "player.orientation_mode" - const val NextUpPromptSeconds = "player.next_up_prompt_seconds" + // Android shipped this under player.* while Apple and the server used + // playback.*, so the same preference was two settings and neither client + // could read the other's. The contract settles on playback.*, and + // [RenamedLocalKeys] carries the already-written local value across. + const val NextUpPromptSeconds = "playback.next_up_prompt_seconds" const val DvProfile7HDR10Fallback = "player.dv_profile7_hdr10_fallback" const val DolbyVisionEnabled = "player.dolby_vision_enabled" const val MatchContentFrameRate = "player.match_frame_rate" @@ -96,10 +121,20 @@ object PlaybackSettingsKeys { */ const val PictureInPictureEnabled = "player.picture_in_picture_enabled" + /** + * Local-only per-profile setting: how far to expand video whose black bars + * are encoded into the picture. It depends on reading back decoded frames + * and on this display's cutout geometry — both device capabilities rather + * than server playback preferences — so this never enters [DeviceSettings]. + */ + const val LetterboxExpansion = "player.letterbox_expansion" + val DeviceSettings = listOf( PreferredQuality, + MaxBitrateKbps, AudioLanguage, AutoSkipIntro, + IntroSkipMode, AutoSkipCredits, AutoPlayNext, SubtitleAppearance, @@ -124,4 +159,22 @@ object PlaybackSettingsKeys { SubtitleTextOutlineColor, SubtitlePosition, ) + + /** + * `old local slot -> current key`, for the two keys the settings cutover + * renamed. + * + * The rename is only a contract question for the *server*; on disk it + * orphans a value the user already set. Both keys read local-first — + * subtitle appearance drives downloaded playback with no server in the + * loop at all, and next-up prompt seconds falls back to its 30s default — + * so without this copy an upgrade silently reverts both until (and unless) + * a canonical refresh succeeds. The copy runs once, inside the same + * sentinel-gated migration that imports the legacy cache, and never + * overwrites a value already present under the new name. + */ + val RenamedLocalKeys: Map = mapOf( + "subtitle_appearance" to SubtitleAppearance, + "player.next_up_prompt_seconds" to NextUpPromptSeconds, + ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt new file mode 100644 index 000000000..018ea66e6 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/QualityPresets.kt @@ -0,0 +1,160 @@ +package org.prairieserver.prairie.model.settings + +/** + * The quality picker's presets. + * + * The server stores two orthogonal values — `playback.preferred_quality` (a + * resolution cap) and `playback.max_bitrate_kbps` (a bandwidth cap, null for + * uncapped). This table composes them into the single list a user picks from, + * and is a field-for-field port of the web client's `qualityPresets.ts` so a + * preset chosen on one platform reads back with the same label on the others. + * + * Presets live here rather than in the contract on purpose. Baking "high" into + * an enum member would freeze what it means: retuning 1080p High from 10 to 12 + * Mbps would be a contract change every client has to agree to. As a + * client-side table it is a one-line edit, and older servers keep working + * because they only ever see the two axes they already understand. + * + * The compound legacy spellings (`1080p-high`, `720p-high`, …) were never a + * third dimension, only a bitrate written into the resolution string. They are + * dead: nothing here produces one, and [presetFor] decomposes any that a + * pre-contract local value still holds. + */ +data class QualityPreset( + val id: String, + val label: String, + val description: String, + /** A `playback.preferred_quality` enum member. */ + val resolution: String, + /** null is uncapped — the absence of a `playback.max_bitrate_kbps` row. */ + val bitrateKbps: Int?, +) + +object QualityPresets { + + const val RESOLUTION_AUTO = "auto" + const val RESOLUTION_ORIGINAL = "original" + + val ALL: List = listOf( + QualityPreset( + id = "auto", + label = "Auto", + description = "Prairie picks based on your connection.", + resolution = RESOLUTION_AUTO, + bitrateKbps = null, + ), + QualityPreset( + id = "original", + label = "Original", + description = "Never transcode. Needs bandwidth to match the file.", + resolution = RESOLUTION_ORIGINAL, + bitrateKbps = null, + ), + QualityPreset( + id = "2160p", + label = "4K", + description = "Up to 2160p.", + resolution = "2160p", + bitrateKbps = null, + ), + QualityPreset( + id = "1080p-high", + label = "1080p High", + description = "1080p at up to 10 Mbps.", + resolution = "1080p", + bitrateKbps = 10000, + ), + QualityPreset( + id = "1080p", + label = "1080p", + description = "1080p at up to 6 Mbps.", + resolution = "1080p", + bitrateKbps = 6000, + ), + QualityPreset( + id = "1080p-low", + label = "1080p Low", + description = "1080p at up to 3 Mbps, for a slower link.", + resolution = "1080p", + bitrateKbps = 3000, + ), + QualityPreset( + id = "720p-high", + label = "720p High", + description = "720p at up to 4 Mbps.", + resolution = "720p", + bitrateKbps = 4000, + ), + QualityPreset( + id = "720p", + label = "720p", + description = "720p at up to 2 Mbps.", + resolution = "720p", + bitrateKbps = 2000, + ), + QualityPreset( + id = "480p", + label = "480p", + description = "480p at up to 1.5 Mbps, for the tightest connections.", + resolution = "480p", + bitrateKbps = 1500, + ), + ) + + /** The preset for a stored (resolution, bitrate) pair, or null for a combination no preset covers. */ + fun presetFor(resolution: String?, bitrateKbps: Int?): QualityPreset? { + val normalizedResolution = normalizeResolution(resolution) + val normalizedBitrate = bitrateKbps?.takeIf { it > 0 } + return ALL.firstOrNull { + it.resolution == normalizedResolution && it.bitrateKbps == normalizedBitrate + } + } + + fun byId(id: String?): QualityPreset? = ALL.firstOrNull { it.id == id } + + /** + * A label for any stored pair, including combinations no preset covers — + * someone who set the two axes independently through the API, or whose + * values came from a legacy compound value. + */ + fun describe(resolution: String?, bitrateKbps: Int?): String { + presetFor(resolution, bitrateKbps)?.let { return it.label } + + val normalized = normalizeResolution(resolution) + val resolutionLabel = when (normalized) { + RESOLUTION_AUTO -> "Auto" + RESOLUTION_ORIGINAL -> "Original" + "2160p" -> "4K" + else -> normalized + } + val capped = bitrateKbps?.takeIf { it > 0 } ?: return resolutionLabel + val mbps = capped / 1000.0 + val rounded = if (capped % 1000 == 0) "${capped / 1000}" else formatOneDecimal(mbps) + return "$resolutionLabel at $rounded Mbps" + } + + /** + * Reduces any stored resolution — including the compound transcode-ladder + * spellings older builds wrote (`1080p-high`, `720p-8`, `4k`) — to a member + * of the contract's enum. The bitrate half of a compound value is dropped + * rather than guessed at: the bitrate axis carries it now, and inventing a + * cap the user never chose would silently throttle playback. + */ + fun normalizeResolution(value: String?): String { + val trimmed = value?.trim()?.lowercase().orEmpty() + if (trimmed.isEmpty()) return RESOLUTION_AUTO + if (trimmed == RESOLUTION_AUTO || trimmed == RESOLUTION_ORIGINAL) return trimmed + if (trimmed == "4k") return "2160p" + val head = trimmed.substringBefore('-') + return when (head) { + "480p", "720p", "1080p", "2160p" -> head + "4k" -> "2160p" + else -> RESOLUTION_AUTO + } + } + + private fun formatOneDecimal(value: Double): String { + val tenths = kotlin.math.round(value * 10).toInt() + return "${tenths / 10}.${tenths % 10}" + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt new file mode 100644 index 000000000..14e132265 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingKeys.kt @@ -0,0 +1,381 @@ +// Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT. +// +// Regenerate with: make settings-bindings +// +// Every key, type, scope and default here comes from the manifest, so a client +// cannot drift from the server's contract by editing a constant. Adding a setting +// is a manifest change plus a regeneration, never a hand-written key. + +package org.prairieserver.prairie.model.settings + +data class SettingSuggestedOption( + val value: String, + val introducedIn: Int, +) + +data class SettingOptionSet( + val type: String, + val options: List, +) + +data class SettingPresentation( + val suggestedOptions: String? = null, + val unsetLabel: String? = null, +) + +object SettingKeys { + const val REVISION = 7 + + /** Metadata language */ + const val CATALOG_METADATA_LANGUAGE = "catalog.metadata_language" + /** Metadata language exceptions */ + const val CATALOG_METADATA_LANGUAGE_OVERRIDES = "catalog.metadata_language_overrides" + /** Download quality */ + const val DOWNLOADS_DEFAULT_QUALITY = "downloads.default_quality" + /** Keep watched downloads */ + const val DOWNLOADS_KEEP_WATCHED = "downloads.keep_watched" + /** Download over Wi-Fi only */ + const val DOWNLOADS_WIFI_ONLY = "downloads.wifi_only" + /** Primary menu */ + const val NAV_PRIMARY_MENU = "nav.primary_menu" + /** Navigation shortcuts */ + const val NAV_SHORTCUTS = "nav.shortcuts" + /** Show audiobooks */ + const val NAV_SHOW_AUDIOBOOKS = "nav.show_audiobooks" + /** Preferred audio language */ + const val PLAYBACK_AUDIO_LANGUAGE = "playback.audio_language" + /** Auto-play next episode */ + const val PLAYBACK_AUTO_PLAY_NEXT = "playback.auto_play_next" + /** Preview next episode */ + const val PLAYBACK_AUTO_PLAY_NEXT_PREVIEW = "playback.auto_play_next_preview" + /** Auto-skip credits */ + const val PLAYBACK_AUTO_SKIP_CREDITS = "playback.auto_skip_credits" + /** Auto-skip intros */ + const val PLAYBACK_AUTO_SKIP_INTRO = "playback.auto_skip_intro" + /** Auto-skip recaps */ + const val PLAYBACK_AUTO_SKIP_RECAP = "playback.auto_skip_recap" + /** Skip intros */ + const val PLAYBACK_INTRO_SKIP_MODE = "playback.intro_skip_mode" + /** Maximum bitrate */ + const val PLAYBACK_MAX_BITRATE_KBPS = "playback.max_bitrate_kbps" + /** Next up prompt */ + const val PLAYBACK_NEXT_UP_PROMPT_SECONDS = "playback.next_up_prompt_seconds" + /** Preferred quality */ + const val PLAYBACK_PREFERRED_QUALITY = "playback.preferred_quality" + /** Show forced subtitles */ + const val PLAYBACK_SHOW_FORCED_SUBTITLES = "playback.show_forced_subtitles" + /** Subtitle appearance */ + const val PLAYBACK_SUBTITLE_APPEARANCE = "playback.subtitle_appearance" + /** Preferred subtitle language */ + const val PLAYBACK_SUBTITLE_LANGUAGE = "playback.subtitle_language" + /** Subtitles */ + const val PLAYBACK_SUBTITLE_MODE = "playback.subtitle_mode" + /** Audio sync offset */ + const val PLAYER_AUDIO_SYNC_MS = "player.audio_sync_ms" + /** Dolby Vision */ + const val PLAYER_DOLBY_VISION_ENABLED = "player.dolby_vision_enabled" + /** Dolby Vision Profile 7 fallback */ + const val PLAYER_DV_PROFILE7_HDR10_FALLBACK = "player.dv_profile7_hdr10_fallback" + /** HDR */ + const val PLAYER_HDR_ENABLED = "player.hdr_enabled" + /** Match content frame rate */ + const val PLAYER_MATCH_FRAME_RATE = "player.match_frame_rate" + /** Screen orientation */ + const val PLAYER_ORIENTATION_MODE = "player.orientation_mode" + /** Still watching prompt */ + const val PLAYER_PASSOUT_THRESHOLD = "player.passout_threshold" + /** Picture in picture */ + const val PLAYER_PICTURE_IN_PICTURE_ENABLED = "player.picture_in_picture_enabled" + /** Playback speed */ + const val PLAYER_PLAYBACK_SPEED = "player.playback_speed" + /** Rewind on resume */ + const val PLAYER_RESUME_REWIND_SECONDS = "player.resume_rewind_seconds" + /** Seek cache */ + const val PLAYER_SEEK_CACHE_ENABLED = "player.seek_cache_enabled" + /** Default sleep timer */ + const val PLAYER_SLEEP_TIMER_DEFAULT_MINUTES = "player.sleep_timer_default_minutes" + /** Subtitle sync offset */ + const val PLAYER_SUBTITLE_SYNC_MS = "player.subtitle_sync_ms" + /** Video sizing */ + const val PLAYER_VIDEO_GRAVITY = "player.video_gravity" + /** Search scope */ + const val SEARCH_MEDIA_SCOPE = "search.media_scope" + /** Match device caption settings */ + const val SUBTITLE_MATCHES_DEVICE = "subtitle.matches_device" + /** Poster badges */ + const val UI_CARD_OVERLAYS = "ui.card_overlays" + /** Media cards */ + const val UI_CARD_PRESENTATION = "ui.card_presentation" + /** Custom CSS */ + const val UI_CUSTOM_CSS = "ui.custom_css" + /** Custom theme variables */ + const val UI_CUSTOM_THEME_VARS = "ui.custom_theme_vars" + /** Date format */ + const val UI_DATE_FORMAT = "ui.date_format" + /** Hidden libraries */ + const val UI_DISABLED_LIBRARY_IDS = "ui.disabled_library_ids" + /** High contrast */ + const val UI_HIGH_CONTRAST = "ui.high_contrast" + /** Library order */ + const val UI_LIBRARY_ORDER = "ui.library_order" + /** Remembered library view */ + const val UI_LIBRARY_PAGE_STATE = "ui.library_page_state" + /** Next up episodes */ + const val UI_NEXT_UP_MODE = "ui.next_up_mode" + /** Remember library view */ + const val UI_REMEMBER_LIBRARY_PAGE_STATE = "ui.remember_library_page_state" + /** Pinned sidebar items */ + const val UI_SIDEBAR_PINS = "ui.sidebar_pins" + /** Text size */ + const val UI_TEXT_SCALE = "ui.text_scale" + /** Text weight */ + const val UI_TEXT_WEIGHT = "ui.text_weight" + /** Theme */ + const val UI_THEME = "ui.theme" + /** Time format */ + const val UI_TIME_FORMAT = "ui.time_format" + + /** Every key the server stores. Safe to flush. */ + val REMOTE: List = listOf( + CATALOG_METADATA_LANGUAGE, + CATALOG_METADATA_LANGUAGE_OVERRIDES, + NAV_PRIMARY_MENU, + NAV_SHORTCUTS, + PLAYBACK_AUDIO_LANGUAGE, + PLAYBACK_AUTO_PLAY_NEXT, + PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, + PLAYBACK_AUTO_SKIP_CREDITS, + PLAYBACK_AUTO_SKIP_INTRO, + PLAYBACK_AUTO_SKIP_RECAP, + PLAYBACK_INTRO_SKIP_MODE, + PLAYBACK_MAX_BITRATE_KBPS, + PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PLAYBACK_PREFERRED_QUALITY, + PLAYBACK_SHOW_FORCED_SUBTITLES, + PLAYBACK_SUBTITLE_APPEARANCE, + PLAYBACK_SUBTITLE_LANGUAGE, + PLAYBACK_SUBTITLE_MODE, + PLAYER_AUDIO_SYNC_MS, + PLAYER_DOLBY_VISION_ENABLED, + PLAYER_DV_PROFILE7_HDR10_FALLBACK, + PLAYER_HDR_ENABLED, + PLAYER_MATCH_FRAME_RATE, + PLAYER_ORIENTATION_MODE, + PLAYER_PLAYBACK_SPEED, + PLAYER_SEEK_CACHE_ENABLED, + PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PLAYER_SUBTITLE_SYNC_MS, + PLAYER_VIDEO_GRAVITY, + SEARCH_MEDIA_SCOPE, + UI_CARD_OVERLAYS, + UI_CARD_PRESENTATION, + UI_CUSTOM_CSS, + UI_CUSTOM_THEME_VARS, + UI_DATE_FORMAT, + UI_DISABLED_LIBRARY_IDS, + UI_HIGH_CONTRAST, + UI_LIBRARY_ORDER, + UI_LIBRARY_PAGE_STATE, + UI_NEXT_UP_MODE, + UI_REMEMBER_LIBRARY_PAGE_STATE, + UI_SIDEBAR_PINS, + UI_TEXT_SCALE, + UI_TEXT_WEIGHT, + UI_THEME, + UI_TIME_FORMAT, + ) + + /** Contract-known keys that never leave the device. */ + val CLIENT_LOCAL: List = listOf( + DOWNLOADS_DEFAULT_QUALITY, + DOWNLOADS_KEEP_WATCHED, + DOWNLOADS_WIFI_ONLY, + NAV_SHOW_AUDIOBOOKS, + PLAYER_PASSOUT_THRESHOLD, + PLAYER_PICTURE_IN_PICTURE_ENABLED, + PLAYER_RESUME_REWIND_SECONDS, + SUBTITLE_MATCHES_DEVICE, + ) + + val BOOLEAN_KEYS: Set = setOf( + PLAYBACK_AUTO_PLAY_NEXT, + PLAYBACK_AUTO_PLAY_NEXT_PREVIEW, + PLAYBACK_AUTO_SKIP_CREDITS, + PLAYBACK_AUTO_SKIP_INTRO, + PLAYBACK_AUTO_SKIP_RECAP, + PLAYBACK_SHOW_FORCED_SUBTITLES, + PLAYER_DOLBY_VISION_ENABLED, + PLAYER_DV_PROFILE7_HDR10_FALLBACK, + PLAYER_HDR_ENABLED, + PLAYER_MATCH_FRAME_RATE, + PLAYER_SEEK_CACHE_ENABLED, + UI_HIGH_CONTRAST, + UI_REMEMBER_LIBRARY_PAGE_STATE, + ) + + val INT_KEYS: Set = setOf( + PLAYBACK_MAX_BITRATE_KBPS, + PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PLAYER_AUDIO_SYNC_MS, + PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PLAYER_SUBTITLE_SYNC_MS, + ) + + val DOUBLE_KEYS: Set = setOf( + PLAYER_PLAYBACK_SPEED, + ) +} + +object SettingPresentationMetadata { + val OPTION_SETS: Map = mapOf( + "catalog_metadata_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + "playback_audio_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + "playback_subtitle_languages" to SettingOptionSet( + type = "language_tag", + options = listOf( + SettingSuggestedOption("ar", 1), + SettingSuggestedOption("bn", 1), + SettingSuggestedOption("bg", 1), + SettingSuggestedOption("zh", 1), + SettingSuggestedOption("hr", 1), + SettingSuggestedOption("cs", 1), + SettingSuggestedOption("da", 1), + SettingSuggestedOption("nl", 1), + SettingSuggestedOption("en", 1), + SettingSuggestedOption("fi", 1), + SettingSuggestedOption("fr", 1), + SettingSuggestedOption("de", 1), + SettingSuggestedOption("el", 1), + SettingSuggestedOption("he", 1), + SettingSuggestedOption("hi", 1), + SettingSuggestedOption("hu", 1), + SettingSuggestedOption("id", 1), + SettingSuggestedOption("it", 1), + SettingSuggestedOption("ja", 1), + SettingSuggestedOption("ko", 1), + SettingSuggestedOption("ms", 1), + SettingSuggestedOption("no", 1), + SettingSuggestedOption("fa", 1), + SettingSuggestedOption("pl", 1), + SettingSuggestedOption("pt", 1), + SettingSuggestedOption("ro", 1), + SettingSuggestedOption("ru", 1), + SettingSuggestedOption("sk", 1), + SettingSuggestedOption("sl", 1), + SettingSuggestedOption("es", 1), + SettingSuggestedOption("sv", 1), + SettingSuggestedOption("ta", 1), + SettingSuggestedOption("te", 1), + SettingSuggestedOption("th", 1), + SettingSuggestedOption("tr", 1), + SettingSuggestedOption("uk", 1), + SettingSuggestedOption("vi", 1), + ), + ), + ) + + val DEFINITIONS: Map = mapOf( + SettingKeys.CATALOG_METADATA_LANGUAGE to SettingPresentation( + suggestedOptions = "catalog_metadata_languages", + unsetLabel = "Library default", + ), + SettingKeys.PLAYBACK_AUDIO_LANGUAGE to SettingPresentation( + suggestedOptions = "playback_audio_languages", + unsetLabel = "No preference", + ), + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE to SettingPresentation( + suggestedOptions = "playback_subtitle_languages", + unsetLabel = "None", + ), + ) + + fun suggestedValues(key: String, revision: Int = SettingKeys.REVISION): List { + val setId = DEFINITIONS[key]?.suggestedOptions ?: return emptyList() + return OPTION_SETS[setId]?.options + ?.filter { it.introducedIn <= revision } + ?.map { it.value } + .orEmpty() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt new file mode 100644 index 000000000..962134e10 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SettingValueModels.kt @@ -0,0 +1,163 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull + +/** + * Wire models for the canonical settings API (`/api/v1/settings/contract` + * and the `/api/v1/settings/values` routes). + * + * These mirror the server's `settings_values.go` handler shapes exactly. The + * older models in [SettingsModels.kt] speak the legacy string-only endpoints; + * here values are typed JSON, scope is explicit, and unknown keys are refused + * by the server rather than stored. + */ + +/** + * The five scopes an explicit value can live at, in the server's wire + * spelling. Kept as an enum for request construction only — response fields + * stay raw strings so a server that adds a scope cannot break deserialization. + */ +enum class SettingScope(val wire: String) { + ACCOUNT("account"), + PROFILE("profile"), + PROFILE_DEVICE("profile_device"), + PROFILE_LIBRARY("profile_library"), + PROFILE_SERIES("profile_series"), +} + +/** + * The scope identity a write or delete addresses. + * + * Only the content ids travel with the request: `scope` plus `library_id` / + * `series_id` go in the query string. The profile and device parts of the + * identity come from the session headers (`X-Profile-Id`, `X-Prairie-Device-Id`) + * that the auth interceptor already attaches — the server reads them from + * there deliberately, so one profile cannot write another's settings by + * naming it in the query. + * + * The init block enforces the fields each scope requires — the same check the + * server's identity validation makes — so an invalid identity fails at + * construction instead of as a 400. The companion factories are the readable + * way to build one. + */ +data class SettingScopeIdentity( + val scope: SettingScope, + /** Set only for [SettingScope.PROFILE_LIBRARY]. */ + val libraryId: Int? = null, + /** Set only for [SettingScope.PROFILE_SERIES]. */ + val seriesId: String? = null, +) { + init { + require((scope == SettingScope.PROFILE_LIBRARY) == (libraryId != null)) { + "library_id is required for profile_library and forbidden elsewhere" + } + libraryId?.let { require(it > 0) { "library_id must be positive" } } + require((scope == SettingScope.PROFILE_SERIES) == (seriesId != null)) { + "series_id is required for profile_series and forbidden elsewhere" + } + seriesId?.let { require(it.isNotBlank()) { "series_id must not be blank" } } + } + + companion object { + fun account(): SettingScopeIdentity = SettingScopeIdentity(SettingScope.ACCOUNT) + + fun profile(): SettingScopeIdentity = SettingScopeIdentity(SettingScope.PROFILE) + + fun profileDevice(): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_DEVICE) + + fun profileLibrary(libraryId: Int): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_LIBRARY, libraryId = libraryId) + + fun profileSeries(seriesId: String): SettingScopeIdentity = + SettingScopeIdentity(SettingScope.PROFILE_SERIES, seriesId = seriesId) + } +} + +/** + * `GET /api/v1/settings/contract/capabilities` — what the connected server + * supports, for feature detection rather than version sniffing. Compare + * [revision] against the generated [SettingKeys.REVISION] to hide definitions + * the server does not know yet. + */ +@Serializable +data class SettingsContractCapabilities( + @SerialName("api_version") val apiVersion: Int = 0, + val revision: Int = 0, + @SerialName("contract_etag") val contractEtag: String = "", + @SerialName("definition_count") val definitionCount: Int = 0, + val scopes: List = emptyList(), + @SerialName("supports_batched_effective") val supportsBatchedEffective: Boolean = false, + @SerialName("supports_idempotent_writes") val supportsIdempotentWrites: Boolean = false, +) + +/** Body for `PUT /api/v1/settings/values/{key}`: `{"value": …}`. */ +@Serializable +data class SettingValueWriteRequest( + val value: JsonElement, +) + +/** + * One explicit stored value: the receipt returned by a PUT, and the shape a + * GET at one scope returns. An idempotent replay of a PUT returns the + * recorded receipt, which omits [revision] and [updatedAt] — treat them as + * informational, not as fields every response carries. + */ +@Serializable +data class StoredSettingValue( + val key: String, + val scope: String, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, + val value: JsonElement = JsonNull, + val revision: Long = 0, + @SerialName("updated_at") val updatedAt: String? = null, +) + +/** + * One resolved value plus where it came from. + * + * [storedValue] and [constrained] are present only when policy narrowed the + * answer: [value] is what applies, [storedValue] is what the user chose, so a + * client can say "your choice is capped" instead of silently showing the cap. + * The scope fields locate the row the value came from, so a reset can target + * exactly that scope; they are absent for a contract default. + */ +@Serializable +data class EffectiveSettingValue( + val key: String, + val value: JsonElement = JsonNull, + val source: String = SOURCE_DEFAULT, + @SerialName("stored_value") val storedValue: JsonElement? = null, + val constrained: Boolean = false, + /** One of "ceiling", "floor", "allowlist", "locked" when [constrained]. */ + @SerialName("constraint_kind") val constraintKind: String? = null, + /** Advisory values for an open picker; never a write allowlist. */ + @SerialName("suggested_values") val suggestedValues: List = emptyList(), + val scope: String? = null, + @SerialName("profile_id") val profileId: String? = null, + @SerialName("device_id") val deviceId: String? = null, + @SerialName("library_id") val libraryId: Int? = null, + @SerialName("series_id") val seriesId: String? = null, +) { + companion object { + /** [source] when no stored value applied and the contract default won. */ + const val SOURCE_DEFAULT = "default" + } +} + +/** + * `GET /api/v1/settings/values/effective`. [revision] names the contract + * revision the resolution was computed at, so definitions, scopes and enum + * members can be filtered against it. + */ +@Serializable +data class EffectiveSettingValuesResponse( + val settings: List = emptyList(), + val revision: Int = 0, +) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt index ef01bdd07..2695fd533 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearance.kt @@ -5,13 +5,19 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +/** + * `wire` repeats what each `@SerialName` declares. It is spelled out as a + * property so the granular, client-local `subtitle.*` slots can be parsed and + * written without a JSON round-trip — see [SubtitleAppearanceProjection] — and + * so a rename cannot change the enum's wire form on one path only. + */ @Serializable -enum class SubtitleFontSizePreset { - @SerialName("small") Small, - @SerialName("medium") Medium, - @SerialName("large") Large, - @SerialName("xlarge") XLarge, - @SerialName("xxlarge") XXLarge, +enum class SubtitleFontSizePreset(val wire: String) { + @SerialName("small") Small("small"), + @SerialName("medium") Medium("medium"), + @SerialName("large") Large("large"), + @SerialName("xlarge") XLarge("xlarge"), + @SerialName("xxlarge") XXLarge("xxlarge"), } val SubtitleFontSizePreset.pointSize: Double @@ -24,18 +30,18 @@ val SubtitleFontSizePreset.pointSize: Double } @Serializable -enum class SubtitleBackgroundStylePreset { - @SerialName("box") Box, - @SerialName("shadow") Shadow, - @SerialName("outline") Outline, - @SerialName("none") None, +enum class SubtitleBackgroundStylePreset(val wire: String) { + @SerialName("box") Box("box"), + @SerialName("shadow") Shadow("shadow"), + @SerialName("outline") Outline("outline"), + @SerialName("none") None("none"), } @Serializable -enum class SubtitlePositionPreset { - @SerialName("bottom") Bottom, - @SerialName("lower-third") LowerThird, - @SerialName("top") Top, +enum class SubtitlePositionPreset(val wire: String) { + @SerialName("bottom") Bottom("bottom"), + @SerialName("lower-third") LowerThird("lower-third"), + @SerialName("top") Top("top"), } val SubtitlePositionPreset.legacyPosition: Int diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt new file mode 100644 index 000000000..a8f6b2517 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjection.kt @@ -0,0 +1,121 @@ +package org.prairieserver.prairie.model.settings + +/** + * Bridge between the granular `subtitle.*` fields Android stores on the device + * and the one composite `playback.subtitle_appearance` object the contract + * carries. + * + * The contract has no definitions for the granular fields — the resolver would + * refuse them as `unknown_setting` — so they stay client-local. That does not + * make them private: a per-field edit has to reach the server, and it does so + * by being projected into the composite before the flush. [project] merges the + * granular slots over a base appearance (sparse: an absent or unparseable field + * leaves the base value alone, matching the schema's "a stored value is a + * sparse override" rule), and [flatten] writes the composite back out so the + * two representations never drift after a server refresh. + */ +object SubtitleAppearanceProjection { + + /** The granular keys, in the order [flatten] emits them. */ + val GRANULAR_KEYS: List = listOf( + PlaybackSettingsKeys.SubtitleFontSize, + PlaybackSettingsKeys.SubtitleFontFamily, + PlaybackSettingsKeys.SubtitleTextColor, + PlaybackSettingsKeys.SubtitleBackgroundColor, + PlaybackSettingsKeys.SubtitleBackgroundStyle, + PlaybackSettingsKeys.SubtitleBackgroundOpacity, + PlaybackSettingsKeys.SubtitleTextOutline, + PlaybackSettingsKeys.SubtitleTextOutlineColor, + PlaybackSettingsKeys.SubtitlePosition, + ) + + /** + * Merges the granular [fields] over [base]. Values are the store's plain + * strings; a field that is absent, blank, or not a member of its enum is + * skipped rather than reset to a default, because the granular slots are a + * sparse overlay and a bad value must not erase a good one. + */ + fun project( + fields: Map, + base: SubtitleAppearance = SubtitleAppearance.DEFAULT, + ): SubtitleAppearance { + var out = base + fields[PlaybackSettingsKeys.SubtitleFontSize]?.let { raw -> + fontSize(raw)?.let { out = out.copy(fontSize = it) } + } + fields[PlaybackSettingsKeys.SubtitleFontFamily]?.let { raw -> + raw.trim().takeIf { it.isNotEmpty() }?.let { out = out.copy(fontFamily = it) } + } + fields[PlaybackSettingsKeys.SubtitleTextColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(fontColor = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(backgroundColor = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundStyle]?.let { raw -> + backgroundStyle(raw)?.let { out = out.copy(backgroundStyle = it) } + } + fields[PlaybackSettingsKeys.SubtitleBackgroundOpacity]?.let { raw -> + raw.trim().toIntOrNull()?.let { out = out.copy(backgroundOpacity = it.coerceIn(0, 100)) } + } + fields[PlaybackSettingsKeys.SubtitleTextOutline]?.let { raw -> + raw.trim().lowercase().toBooleanStrictOrNull()?.let { out = out.copy(textOutline = it) } + } + fields[PlaybackSettingsKeys.SubtitleTextOutlineColor]?.let { raw -> + hexColor(raw)?.let { out = out.copy(textOutlineColor = it) } + } + fields[PlaybackSettingsKeys.SubtitlePosition]?.let { raw -> + position(raw)?.let { out = out.copy(position = it) } + } + return out.sanitized() + } + + /** + * The granular spelling of [appearance] — every key present, so writing + * this back over the local slots leaves nothing stale behind. + */ + fun flatten(appearance: SubtitleAppearance): Map { + val safe = appearance.sanitized() + return mapOf( + PlaybackSettingsKeys.SubtitleFontSize to safe.fontSize.wire, + PlaybackSettingsKeys.SubtitleFontFamily to safe.fontFamily, + PlaybackSettingsKeys.SubtitleTextColor to safe.fontColor, + PlaybackSettingsKeys.SubtitleBackgroundColor to safe.backgroundColor, + PlaybackSettingsKeys.SubtitleBackgroundStyle to safe.backgroundStyle.wire, + PlaybackSettingsKeys.SubtitleBackgroundOpacity to safe.backgroundOpacity.toString(), + PlaybackSettingsKeys.SubtitleTextOutline to safe.textOutline.toString(), + PlaybackSettingsKeys.SubtitleTextOutlineColor to safe.textOutlineColor, + PlaybackSettingsKeys.SubtitlePosition to safe.position.wire, + ) + } + + private fun fontSize(raw: String): SubtitleFontSizePreset? { + val v = raw.trim().lowercase() + return SubtitleFontSizePreset.entries.firstOrNull { it.wire == v } + } + + private fun backgroundStyle(raw: String): SubtitleBackgroundStylePreset? { + val v = raw.trim().lowercase() + return SubtitleBackgroundStylePreset.entries.firstOrNull { it.wire == v } + } + + private fun position(raw: String): SubtitlePositionPreset? { + val v = raw.trim().lowercase() + SubtitlePositionPreset.entries.firstOrNull { it.wire == v }?.let { return it } + // Older Android builds stored the numeric cue-line position. + return when (v.toIntOrNull()) { + null -> null + in Int.MIN_VALUE..34 -> SubtitlePositionPreset.Top + in 35..84 -> SubtitlePositionPreset.LowerThird + else -> SubtitlePositionPreset.Bottom + } + } + + private fun hexColor(raw: String): String? { + val trimmed = raw.trim() + val body = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed + if (body.length != 6) return null + if (!body.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) return null + return "#" + body.lowercase() + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ApiResult.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ApiResult.kt index d3ca6f0f7..21a6d9b70 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ApiResult.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ApiResult.kt @@ -1,5 +1,8 @@ package org.prairieserver.prairie.network +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException import kotlinx.serialization.Serializable sealed class ApiResult { @@ -34,6 +37,22 @@ suspend fun ApiResult.map(transform: suspend (T) -> R): ApiResult = /** Standard copy for failures that never reached the server. */ const val NETWORK_ERROR_MESSAGE = "Network error. Check your connection." +/** Copy for a request the server accepted but did not answer in time. */ +const val REQUEST_TIMEOUT_MESSAGE = "The server took too long to respond. Try again." + +/** + * Whether this failure is a timeout rather than an unreachable server. The + * distinction matters for the copy: "check your connection" is wrong advice + * when the connection is fine and the server is merely slow. + */ +val ApiResult.NetworkError.isTimeout: Boolean + get() = generateSequence(exception) { it.cause?.takeIf { c -> c !== it } } + .any { + it is HttpRequestTimeoutException || + it is SocketTimeoutException || + it is ConnectTimeoutException + } + /** * User-facing error text for a failed [ApiResult]: the server-provided * message when present, [fallback] when it is blank, and the standard @@ -44,5 +63,5 @@ const val NETWORK_ERROR_MESSAGE = "Network error. Check your connection." fun ApiResult<*>.errorMessage(fallback: String): String = when (this) { is ApiResult.Success -> fallback is ApiResult.Error -> message.ifBlank { fallback } - is ApiResult.NetworkError -> NETWORK_ERROR_MESSAGE + is ApiResult.NetworkError -> if (isTimeout) REQUEST_TIMEOUT_MESSAGE else NETWORK_ERROR_MESSAGE } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt deleted file mode 100644 index b8dde1189..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptor.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.prairieserver.prairie.network - -import io.ktor.client.plugins.api.* - -/** - * Ktor plugin that attaches auth headers to every request. - * Full implementation provided by Agent 2 in AuthInterceptorImpl.kt. - */ -val PrairieAuth = createClientPlugin("PrairieAuth") { - // Stub - Agent 2 provides the real implementation -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt index 1f4f41c37..dbb58bf47 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt @@ -1,5 +1,7 @@ package org.prairieserver.prairie.network +import org.prairieserver.prairie.util.ImageFormats + import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.plugins.api.* @@ -12,7 +14,45 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.prairieserver.prairie.model.auth.RefreshRequest import org.prairieserver.prairie.model.auth.RefreshResponse -import org.prairieserver.prairie.util.ImageFormats + +/** + * How close to expiry an access token may get before a request refreshes it + * rather than spending it. + * + * Wide enough to cover the round trip plus clock skew between client and + * server, narrow enough that it never dominates a short token lifetime. + */ +private const val PROACTIVE_REFRESH_MARGIN_MS = 60_000L + +/** + * What a refresh attempt settled. + * + * [CredentialsDead] is the one the proactive path must not ignore: the server + * repudiated the refresh token, so the session has been torn down or the + * overlay generation flagged. Sending the original request anyway would spend a + * bearer the client has just declared invalid — and if the access token has not + * expired yet the server may well honour it, completing a write for a session + * the app has already ended. + */ +internal enum class RefreshOutcome { + /** New credentials are installed; retry with them. */ + Refreshed, + + /** Nothing changed and the credentials are still usable as-is. */ + NotAttempted, + + /** The server rejected the refresh token; these credentials are finished. */ + CredentialsDead, + + /** + * The refresh could not be completed for a reason that says nothing about + * the credentials — a 5xx, a gateway, a dropped connection. They may still + * be perfectly good, so the caller spends them as before; but it must not + * immediately ask again, or one request becomes two refresh attempts and + * concurrent traffic amplifies the outage it is already suffering. + */ + FailedTransient, +} /** * Configuration for the [PrairieAuthPlugin]. @@ -28,7 +68,7 @@ class PrairieAuthConfig { } /** - * Ktor client plugin that handles Prairie authentication. + * Ktor client plugin that handles Silo authentication. * * Before each request: * - Attaches `Authorization: Bearer ` if an access token is available @@ -59,17 +99,198 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // refreshing dead credentials again on every subsequent 401. val deadCredentialGenerations = MutableStateFlow>(emptySet()) + /** + * One refresh of [refreshScope], serialised on [refreshMutex]. + * + * Lifted verbatim out of the 401 path so the proactive path cannot drift + * from it. Every guard inside — mid-flight server switch, a sign-out + * landing while the round trip is open, a dead temporary generation — + * exists because it was needed once; a second copy would be a second place + * to forget one. + * + * Set [allowNetworkRefresh] to false when a proactive attempt for this same + * request already failed transiently: every check still runs — including + * the one that spots a token a concurrent request installed, which needs no + * network call — but the refresh POST itself is skipped rather than piling + * onto a service that is already failing. + * + * @return [RefreshOutcome.Refreshed] when the caller should retry with a + * token that now differs from [authorizationBeforeRequest]; otherwise the + * outcome describing why not. + */ + suspend fun refreshScopeOnce( + refreshScope: AuthScopeSnapshot, + trustedServerUrl: String, + activeServerIdBeforeRequest: String?, + authorizationBeforeRequest: String?, + temporaryGeneration: String?, + allowNetworkRefresh: Boolean = true, + ): RefreshOutcome = refreshMutex.withLock { + // If the user switched servers between request-send and 401-retry, + // we are now operating against a different server. Don't try to + // "refresh" — the refresh token wouldn't be valid for the new + // server anyway, and we'd risk persisting cross-server tokens. + val serverIdNow = tokenManager.getCurrentServerId() + val serverUrlNow = tokenManager.getServerUrl() + if ( + serverIdNow != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlNow) + ) { + return@withLock RefreshOutcome.NotAttempted + } + + val tokenNow = tokenManager.getAccessTokenForScope(refreshScope) + if ( + tokenManager.getCurrentServerId() != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, tokenManager.getServerUrl()) + ) { + return@withLock RefreshOutcome.NotAttempted + } + if (tokenNow != null && "Bearer $tokenNow" != authorizationBeforeRequest) { + // Another coroutine already refreshed while we were waiting — + // just retry the original request with the new token. + return@withLock RefreshOutcome.Refreshed + } + + if (temporaryGeneration != null && + temporaryGeneration in deadCredentialGenerations.value + ) { + // A 401 that won the race already proved these temporary credentials + // are dead; the token is unchanged, so without this every waiter would + // repeat the same doomed refresh. + return@withLock RefreshOutcome.CredentialsDead + } + + // Scope-bound, not global: a refresh must spend the token belonging to + // the scope this request ran under. + val refreshToken = tokenManager.getRefreshTokenForScope(refreshScope) + if (refreshToken.isNullOrBlank()) { + return@withLock RefreshOutcome.NotAttempted + } + + // A proactive attempt for this same request already failed for a + // transient reason. Everything above still had to run — most + // importantly the double-check, because a CONCURRENT request may have + // installed a working token while this one was in flight, and that + // recovery costs no network call. Only the request below is suppressed. + if (!allowNetworkRefresh) { + return@withLock RefreshOutcome.FailedTransient + } + + try { + diagnosticsObserver.safeAuthRefresh("started") + if (trustedServerUrl.isBlank()) { + return@withLock RefreshOutcome.NotAttempted + } + + val refreshResponse = client.post("$trustedServerUrl/api/v1/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(RefreshRequest(refreshToken)) + } + + // Re-check serverId AFTER the network call as well — the user + // could have switched while we were waiting on the network. + // The token write below targets whichever server is active at + // save time, so a mismatch here means we'd write to the wrong + // slot. + val serverIdAfterCall = tokenManager.getCurrentServerId() + val serverUrlAfterCall = tokenManager.getServerUrl() + if ( + serverIdAfterCall != activeServerIdBeforeRequest || + !isSameHttpOrigin(trustedServerUrl, serverUrlAfterCall) + ) { + return@withLock RefreshOutcome.NotAttempted + } + + // Re-check sign-out state AFTER the network call too. Logout + // revokes the access token server-side before clearTokens() + // runs, so concurrent requests 401 exactly during sign-out and + // start a refresh with the still-valid refresh token; without + // this guard the refresh response lands after clearTokens() + // and saveTokens() silently signs the user back in. + if (tokenManager.getRefreshTokenForScope(refreshScope).isNullOrBlank()) { + return@withLock RefreshOutcome.NotAttempted + } + + if (refreshResponse.status.isSuccess()) { + diagnosticsObserver.safeAuthRefresh("succeeded") + val tokens = refreshResponse.body() + tokenManager.saveTokensForScope( + scope = refreshScope, + accessToken = tokens.accessToken, + refreshToken = tokens.refreshToken, + expiresIn = tokens.expiresIn, + ) + val after = tokenManager.getAccessTokenForScope(refreshScope) + if (after != null && "Bearer $after" != authorizationBeforeRequest) { + RefreshOutcome.Refreshed + } else { + RefreshOutcome.NotAttempted + } + } else { + diagnosticsObserver.safeAuthRefresh("failed") + // Only auth rejection proves the refresh token is bad. + // Gateway/proxy/server failures should keep the session so + // a temporary outage does not sign the user out. + if (!refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { + // Gateway/proxy/server failure: the credentials may well + // still be good, so the caller may spend them as before. + return@withLock RefreshOutcome.FailedTransient + } + run { + val generationNow = tokenManager.temporaryGenerationId() + when { + // The identity changed while the refresh was in flight + // (overlay began or ended): the rejection belongs to a + // credential set that is no longer installed, so it must + // not tear down whatever is installed now. + generationNow != temporaryGeneration -> Unit + + // Remote playback: the rejected credentials are a + // temporary overlay. invalidateSession() would drop that + // overlay, and every later read would fall through to the + // saved OWNER's account — the guest would keep browsing + // and writing history as the owner. Flag the generation + // dead and leave the overlay installed instead; the cast + // teardown path is what removes it. + temporaryGeneration != null -> { + deadCredentialGenerations.update { it + temporaryGeneration } + return@withLock RefreshOutcome.CredentialsDead + } + + // The [TokenManager.sessionExpired] event emitted by + // this call is what the root NavHost observer uses to + // route the user back to the login screen; without it, + // the UI would stay on Home and keep rendering + // "Failed to load..." for every subsequent API call + // that now has no credentials. + else -> { + tokenManager.invalidateSessionForScope(refreshScope) + return@withLock RefreshOutcome.CredentialsDead + } + } + } + RefreshOutcome.NotAttempted + } + } catch (e: Throwable) { + diagnosticsObserver.safeAuthRefresh("failed") + RefreshOutcome.FailedTransient + } + } + onRequest { request, _ -> val skipAuth = request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true val requireAuth = request.attributes.getOrNull(RequirePrairieAuthAttributeKey) == true val diagnosticsScope = request.attributes.getOrNull(DiagnosticsRequestScopeKey) + val diagnosticsAuthorization = request.attributes.getOrNull(DiagnosticsUploadAuthorizationKey) val pinned = request.attributes.getOrNull(AuthScopeAttributeKey) - val activeServerIdBefore = if (pinned == null) tokenManager.getCurrentServerId() else null - val trustedServerUrl = pinned?.serverUrl ?: tokenManager.getServerUrl() + val activeServerIdBefore = + if (diagnosticsAuthorization == null && pinned == null) tokenManager.getCurrentServerId() else null + val trustedServerUrl = diagnosticsAuthorization?.serverUrl ?: pinned?.serverUrl ?: tokenManager.getServerUrl() // Shared calls are normally relative. Resolve those against the exact // server that owns the credential scope before deciding whether any - // Prairie header may be attached. + // Silo header may be attached. if ( request.url.encodedPath.startsWith("/api/") && (request.url.host.isBlank() || request.url.host == "localhost") && @@ -89,17 +310,41 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon throw CleartextOriginNotApprovedException(request.url.toString()) } - val sameOrigin = isSamePrairieHttpOrigin(trustedServerUrl, request.url) + val sameOrigin = isSameSiloHttpOrigin(trustedServerUrl, request.url) if (skipAuth) { request.removePrairieCredentialHeaders() if (sameOrigin) { - request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) + request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) } return@onRequest } if (!sameOrigin) { request.removePrairieCredentialHeaders() + if (diagnosticsAuthorization != null) { + throw PrairieAuthUnavailableException( + PrairieAuthUnavailableException.REQUIRED_AUTH_UNAVAILABLE, + ) + } + return@onRequest + } + + // Diagnostics upload owns an identity-transition lease around this call. + // Use only the exact credential captured before the lease: consulting the + // persistent TokenManager (or refreshing a 401) would re-enter the same + // non-reentrant barrier. A rejected token is surfaced to the uploader and + // retried after the next normal preflight refresh. + if (diagnosticsAuthorization != null) { + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer ${diagnosticsAuthorization.accessToken}") + request.headers.remove("X-Profile-Id") + request.headers.remove("X-Profile-Token") + request.applyProfileHeaders( + diagnosticsScope = diagnosticsScope, + activeProfileId = diagnosticsAuthorization.activeProfileId, + activeProfileToken = null, + ) + request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) return@onRequest } @@ -114,7 +359,9 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon val scopedAccessToken = tokenManager.getAccessTokenForScope(pinned) if (requireAuth && scopedAccessToken.isNullOrBlank()) { request.removePrairieCredentialHeaders() - throw IllegalStateException("required_prairie_auth_unavailable") + throw PrairieAuthUnavailableException( + PrairieAuthUnavailableException.REQUIRED_AUTH_UNAVAILABLE, + ) } scopedAccessToken?.let { token -> request.header(HttpHeaders.Authorization, "Bearer $token") @@ -126,7 +373,7 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon activeProfileId = pinned.profileId, activeProfileToken = pinned.profileToken, ) - request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) + request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) return@onRequest } @@ -135,8 +382,11 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon if (isRefreshRequest) return@onRequest val accessToken = tokenManager.getAccessToken() - val profileId = tokenManager.getProfileId() - val profileToken = tokenManager.getProfileToken() + // One read: taking these separately could pair the old profile id with + // the new profile's token across a switch. + val profileIdentity = tokenManager.getProfileIdentity() + val profileId = profileIdentity.profileId + val profileToken = profileIdentity.profileToken val activeServerIdAfter = tokenManager.getCurrentServerId() val activeServerUrlAfter = tokenManager.getServerUrl() if ( @@ -157,10 +407,22 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon activeProfileToken = profileToken, ) - request.attachPrairieDeviceMetadataHeaders(deviceMetadataProvider) + request.attachSiloDeviceMetadataHeaders(deviceMetadataProvider) } on(Send) { request -> + val diagnosticsAuthorization = request.attributes.getOrNull(DiagnosticsUploadAuthorizationKey) + if (diagnosticsAuthorization != null) { + if (!isSameSiloHttpOrigin(diagnosticsAuthorization.serverUrl, request.url)) { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException( + PrairieAuthUnavailableException.REQUIRED_AUTH_UNAVAILABLE, + ) + } + // Exactly one attempt: never proactively refresh, retry a 401, or + // invalidate credentials while the caller holds the identity lease. + return@on proceed(request) + } // Pinned scope (Track B): refresh against the *captured* scope, never the // active one, and never invalidate the active UI session — a failed // pinned refresh just surfaces the 401 so the outbox keeps the op. @@ -181,12 +443,12 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon return@on proceed(request) } if (request.attributes.getOrNull(SkipPrairieAuthAttributeKey) == true) { - if (!isSamePrairieHttpOrigin(trustedServerUrl, request.url)) { + if (!isSameSiloHttpOrigin(trustedServerUrl, request.url)) { request.removePrairieCredentialHeaders() } return@on proceed(request) } - if (!isSamePrairieHttpOrigin(trustedServerUrl, request.url)) { + if (!isSameSiloHttpOrigin(trustedServerUrl, request.url)) { request.removePrairieCredentialHeaders() return@on proceed(request) } @@ -202,9 +464,9 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // re-refreshing them for every pinned op (progress ticks, teardown). return@on originalCall } - // A redirect can carry the pinned call off the Prairie origin; refreshing + // A redirect can carry the pinned call off the Silo origin; refreshing // then would hand this scope's credentials to whatever answered. - if (!isSamePrairieHttpOrigin(pinnedScope.serverUrl, originalCall.request.url)) { + if (!isSameSiloHttpOrigin(pinnedScope.serverUrl, originalCall.request.url)) { return@on originalCall } diagnosticsObserver.safeAuthRefresh("required") @@ -282,13 +544,114 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // already happened — so N parallel 401s collapse into ONE refresh. val authorizationBeforeRequest = request.headers[HttpHeaders.Authorization] + // Spend a token we already know is about to expire and the server will + // simply reject it: the 401 path below then refreshes and retries, so + // the request costs two round trips instead of one. Refreshing first + // costs the same one refresh and drops the wasted call. + // + // Deliberately narrow: only an authenticated request on the active + // scope, never the auth endpoints themselves (refreshing before a + // login is meaningless and before a refresh is recursive), and never a + // pinned outbox op — that path is handled above and must not disturb + // the active session. Everything else falls through unchanged, so a + // manager that cannot answer the expiry question keeps today's + // behaviour exactly. + val proactivePath = request.url.encodedPath + // Read the installed generation BEFORE asking about expiry. The expiry + // question is answered by whatever identity is installed right now, + // while the refresh spends the token belonging to the scope this + // request captured earlier; if an overlay began or ended in between, + // those are two different identities and a rejection of one would be + // charged against the other. Requiring them to match means the pair is + // only ever evaluated for a single identity. + var proactiveRefreshFailedTransiently = false + val proactiveGeneration = tokenManager.temporaryGenerationId() + if ( + authorizationBeforeRequest != null && + !proactivePath.endsWith("/auth/refresh") && + !proactivePath.endsWith("/auth/login") && + proactiveGeneration == refreshScope.credentialGenerationId && + proactiveGeneration !in deadCredentialGenerations.value && + tokenManager.accessTokenExpiresWithin(PROACTIVE_REFRESH_MARGIN_MS) && + tokenManager.temporaryGenerationId() == proactiveGeneration + ) { + diagnosticsObserver.safeAuthRefresh("required") + val earlyOutcome = refreshScopeOnce( + refreshScope = refreshScope, + trustedServerUrl = trustedServerUrl, + activeServerIdBeforeRequest = activeServerIdBeforeRequest, + authorizationBeforeRequest = authorizationBeforeRequest, + temporaryGeneration = proactiveGeneration, + ) + when (earlyOutcome) { + RefreshOutcome.Refreshed -> + tokenManager.getAccessTokenForScope(refreshScope)?.let { token -> + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $token") + } + + // The refresh token was rejected, so the session is torn + // down. This request does not go out at all. + // + // Not even as an anonymous GET: "safe methods don't change + // state" is not true here — GET /downloads/{id}/file moves the + // download to completed server-side, GET /admin/stats?refresh + // forces a recompute — and an optionally-authenticated read + // would return GUEST data with a 200 that callers cache while + // sessionExpired is signing the user out. Failing the call is + // the honest answer, so this throws rather than sending + // anything. Genuinely public calls opt out with skipPrairieAuth(), + // never receive a bearer, and so never reach this branch. + RefreshOutcome.CredentialsDead -> { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException( + PrairieAuthUnavailableException.CREDENTIALS_REPUDIATED, + ) + } + + RefreshOutcome.NotAttempted, RefreshOutcome.FailedTransient -> Unit + } + proactiveRefreshFailedTransiently = + earlyOutcome == RefreshOutcome.FailedTransient + + // This request's bearer was captured BEFORE the refresh mutex was + // waited on. In that window another coroutine can have signed out, + // switched server, or had these very credentials repudiated — and + // every one of those returns NotAttempted, which says only "no + // refresh happened", not "the scope is still alive". Reading it as + // permission to proceed is how an invalidated bearer gets spent. + // + // So verify what is actually installed rather than trusting the + // outcome. Checked for every non-Refreshed case: FailedTransient is + // meant to spend the existing credentials, but only if they still + // exist. + if (earlyOutcome != RefreshOutcome.Refreshed) { + val installed = tokenManager.getAccessTokenForScope(refreshScope) + val serverNow = tokenManager.getCurrentServerId() + when { + installed == null || serverNow != activeServerIdBeforeRequest -> { + request.removePrairieCredentialHeaders() + throw PrairieAuthUnavailableException( + PrairieAuthUnavailableException.CREDENTIALS_REPUDIATED, + ) + } + // Someone else rotated them while we waited: spend the + // token that is actually installed, not the stale capture. + "Bearer $installed" != authorizationBeforeRequest -> { + request.headers.remove(HttpHeaders.Authorization) + request.header(HttpHeaders.Authorization, "Bearer $installed") + } + } + } + } + val originalCall = proceed(request) // Only attempt refresh on 401 for non-auth endpoints if (originalCall.response.status != HttpStatusCode.Unauthorized) { return@on originalCall } - if (!isSamePrairieHttpOrigin(trustedServerUrl, originalCall.request.url)) { + if (!isSameSiloHttpOrigin(trustedServerUrl, originalCall.request.url)) { return@on originalCall } if ( @@ -327,135 +690,17 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon // the double-check guarantees that only one coroutine HITS the network // for the refresh — subsequent waiters observe the already-refreshed // token and skip straight to retry. - val refreshed = refreshMutex.withLock { - // If the user switched servers between request-send and 401-retry, - // we are now operating against a different server. Don't try to - // "refresh" — the refresh token wouldn't be valid for the new - // server anyway, and we'd risk persisting cross-server tokens. - val serverIdNow = tokenManager.getCurrentServerId() - val serverUrlNow = tokenManager.getServerUrl() - if ( - serverIdNow != activeServerIdBeforeRequest || - !isSameHttpOrigin(trustedServerUrl, serverUrlNow) - ) { - return@withLock false - } - - val tokenNow = tokenManager.getAccessTokenForScope(refreshScope) - if ( - tokenManager.getCurrentServerId() != activeServerIdBeforeRequest || - !isSameHttpOrigin(trustedServerUrl, tokenManager.getServerUrl()) - ) { - return@withLock false - } - if (tokenNow != null && "Bearer $tokenNow" != authorizationBeforeRequest) { - // Another coroutine already refreshed while we were waiting — - // just retry the original request with the new token. - return@withLock true - } - - if (temporaryGeneration != null && - temporaryGeneration in deadCredentialGenerations.value - ) { - // A 401 that won the race already proved these temporary credentials - // are dead; the token is unchanged, so without this every waiter would - // repeat the same doomed refresh. - return@withLock false - } - - // Scope-bound, not global: a refresh must spend the token belonging to - // the scope this request ran under. - val refreshToken = tokenManager.getRefreshTokenForScope(refreshScope) - if (refreshToken.isNullOrBlank()) { - return@withLock false - } - - try { - diagnosticsObserver.safeAuthRefresh("started") - if (trustedServerUrl.isBlank()) { - return@withLock false - } - - val refreshResponse = client.post("$trustedServerUrl/api/v1/auth/refresh") { - contentType(ContentType.Application.Json) - setBody(RefreshRequest(refreshToken)) - } - - // Re-check serverId AFTER the network call as well — the user - // could have switched while we were waiting on the network. - // The token write below targets whichever server is active at - // save time, so a mismatch here means we'd write to the wrong - // slot. - val serverIdAfterCall = tokenManager.getCurrentServerId() - val serverUrlAfterCall = tokenManager.getServerUrl() - if ( - serverIdAfterCall != activeServerIdBeforeRequest || - !isSameHttpOrigin(trustedServerUrl, serverUrlAfterCall) - ) { - return@withLock false - } - - // Re-check sign-out state AFTER the network call too. Logout - // revokes the access token server-side before clearTokens() - // runs, so concurrent requests 401 exactly during sign-out and - // start a refresh with the still-valid refresh token; without - // this guard the refresh response lands after clearTokens() - // and saveTokens() silently signs the user back in. - if (tokenManager.getRefreshTokenForScope(refreshScope).isNullOrBlank()) { - return@withLock false - } - - if (refreshResponse.status.isSuccess()) { - diagnosticsObserver.safeAuthRefresh("succeeded") - val tokens = refreshResponse.body() - tokenManager.saveTokensForScope( - scope = refreshScope, - accessToken = tokens.accessToken, - refreshToken = tokens.refreshToken, - expiresIn = tokens.expiresIn, - ) - val after = tokenManager.getAccessTokenForScope(refreshScope) - after != null && "Bearer $after" != authorizationBeforeRequest - } else { - diagnosticsObserver.safeAuthRefresh("failed") - // Only auth rejection proves the refresh token is bad. - // Gateway/proxy/server failures should keep the session so - // a temporary outage does not sign the user out. - if (refreshResponse.status.shouldInvalidateSessionAfterRefreshFailure()) { - val generationNow = tokenManager.temporaryGenerationId() - when { - // The identity changed while the refresh was in flight - // (overlay began or ended): the rejection belongs to a - // credential set that is no longer installed, so it must - // not tear down whatever is installed now. - generationNow != temporaryGeneration -> Unit - - // Remote playback: the rejected credentials are a - // temporary overlay. invalidateSession() would drop that - // overlay, and every later read would fall through to the - // saved OWNER's account — the guest would keep browsing - // and writing history as the owner. Flag the generation - // dead and leave the overlay installed instead; the cast - // teardown path is what removes it. - temporaryGeneration != null -> - deadCredentialGenerations.update { it + temporaryGeneration } - - // The [TokenManager.sessionExpired] event emitted by - // this call is what the root NavHost observer uses to - // route the user back to the login screen; without it, - // the UI would stay on Home and keep rendering - // "Failed to load..." for every subsequent API call - // that now has no credentials. - else -> tokenManager.invalidateSessionForScope(refreshScope) - } - } - false - } - } catch (e: Throwable) { - diagnosticsObserver.safeAuthRefresh("failed") - false - } - } + val refreshed = refreshScopeOnce( + refreshScope = refreshScope, + trustedServerUrl = trustedServerUrl, + activeServerIdBeforeRequest = activeServerIdBeforeRequest, + authorizationBeforeRequest = authorizationBeforeRequest, + temporaryGeneration = temporaryGeneration, + // A proactive attempt for this request already failed transiently. + // Do not ask the network again — but do still let the double-check + // above pick up a token a concurrent request installed meanwhile. + allowNetworkRefresh = !proactiveRefreshFailedTransiently, + ) == RefreshOutcome.Refreshed if (refreshed) { // Explicitly replace the Authorization header on the request builder @@ -474,7 +719,7 @@ val PrairieAuthPlugin = createClientPlugin("PrairieAuthPlugin", ::PrairieAuthCon retryServerIdAfterToken != activeServerIdBeforeRequest || !isSameHttpOrigin(trustedServerUrl, retryServerUrlBeforeToken) || !isSameHttpOrigin(trustedServerUrl, retryServerUrlAfterToken) || - !isSamePrairieHttpOrigin(trustedServerUrl, request.url) || + !isSameSiloHttpOrigin(trustedServerUrl, request.url) || newAccessToken == null ) { return@on originalCall @@ -532,7 +777,7 @@ private fun NetworkDiagnosticsObserver?.safeAuthRefresh(state: String) { runCatching { this?.authRefresh(state) } } -private suspend fun HttpRequestBuilder.attachPrairieDeviceMetadataHeaders( +private suspend fun HttpRequestBuilder.attachSiloDeviceMetadataHeaders( deviceMetadataProvider: DeviceMetadataProvider?, ) { val device = deviceMetadataProvider?.current() ?: return @@ -542,6 +787,8 @@ private suspend fun HttpRequestBuilder.attachPrairieDeviceMetadataHeaders( header("X-Prairie-Image-Formats", ImageFormats.headerValue()) device.clientName?.takeIf { it.isNotBlank() }?.let { header("X-Prairie-Client", it) } device.clientVersion?.takeIf { it.isNotBlank() }?.let { header("X-Prairie-Client-Version", it) } + device.clientBuild?.takeIf { it.isNotBlank() }?.let { header("X-Prairie-Client-Build", it) } + device.clientChannel?.takeIf { it.isNotBlank() }?.let { header("X-Prairie-Client-Channel", it) } } private fun URLBuilder.rebaseRelativeApiUrl(serverUrl: String) { @@ -567,7 +814,7 @@ private fun HttpRequestBuilder.removePrairieCredentialHeaders() { .forEach(headers::remove) } -private fun isSamePrairieHttpOrigin(serverUrl: String, requestUrl: URLBuilder): Boolean { +private fun isSameSiloHttpOrigin(serverUrl: String, requestUrl: URLBuilder): Boolean { val httpRequestUrl = when (requestUrl.protocol) { URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") @@ -576,7 +823,7 @@ private fun isSamePrairieHttpOrigin(serverUrl: String, requestUrl: URLBuilder): return isSameHttpOrigin(serverUrl, httpRequestUrl) } -private fun isSamePrairieHttpOrigin(serverUrl: String, requestUrl: Url): Boolean { +private fun isSameSiloHttpOrigin(serverUrl: String, requestUrl: Url): Boolean { val httpRequestUrl = when (requestUrl.protocol) { URLProtocol.WS -> requestUrl.toString().replaceSchemeForOriginCheck("http") URLProtocol.WSS -> requestUrl.toString().replaceSchemeForOriginCheck("https") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt index 457f892c0..838c6096f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthScopeSnapshot.kt @@ -24,7 +24,9 @@ import io.ktor.util.AttributeKey * [identityGeneration] changes before every server, account, profile, or * temporary-scope mutation. It distinguishes a later login that happens to * reuse the same server/profile identifiers from the credential identity that - * was active when this snapshot was captured. + * was active when this snapshot was captured. [isIdentityGenerationStamped] + * distinguishes a legitimate capture at generation zero from a legacy, + * hand-built scope that never captured the generation at all. */ data class AuthScopeSnapshot( val serverId: String, @@ -33,6 +35,7 @@ data class AuthScopeSnapshot( val profileToken: String?, val credentialGenerationId: String? = null, val identityGeneration: Long = 0L, + val isIdentityGenerationStamped: Boolean = false, /** * Bumped every time this server's PERSISTENT credentials are written or * cleared — i.e. by sign-in and sign-out, but deliberately NOT by a @@ -54,7 +57,8 @@ data class AuthScopeSnapshot( "AuthScopeSnapshot(" + "serverId=, profileId=, serverUrl=, " + "profileToken=, credentialGenerationId=, " + - "identityGeneration=, credentialEpoch=)" + "identityGeneration=, isIdentityGenerationStamped=, " + + "credentialEpoch=)" } /** Attribute carrying the [AuthScopeSnapshot] that [PrairieAuthPlugin] honors. */ diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DeviceMetadataProvider.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DeviceMetadataProvider.kt index 28807e5ad..54bd9d3ed 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DeviceMetadataProvider.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DeviceMetadataProvider.kt @@ -6,6 +6,17 @@ data class PrairieDeviceMetadata( val platform: String, val clientName: String? = null, val clientVersion: String? = null, + /** + * The build counter behind [clientVersion] (CI's per-marketing-version + * build number), so the server can distinguish two builds that share a + * version name. Null when the platform has no such value. + */ + val clientBuild: String? = null, + /** + * How this build was distributed — "release", "beta", "sideload", "dev". + * Opaque to the server, which stores it as reported. + */ + val clientChannel: String? = null, ) interface DeviceMetadataProvider { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DiagnosticsRequestScope.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DiagnosticsRequestScope.kt index b5fa300d8..41ce110cd 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DiagnosticsRequestScope.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/DiagnosticsRequestScope.kt @@ -3,6 +3,30 @@ package org.prairieserver.prairie.network import io.ktor.client.request.HttpRequestBuilder import io.ktor.util.AttributeKey +/** + * A persistent Silo credential captured before entering an identity send + * lease. The access token is intentionally frozen: the leased request surfaces + * a 401 for a later retry instead of refreshing and re-entering the barrier. + */ +data class DiagnosticsUploadAuthorization( + val serverId: String, + val serverUrl: String, + val accessToken: String, + val activeProfileId: String?, + val identityGeneration: Long, +) { + init { + require(serverId.isNotBlank()) { "diagnostics authorization requires a server id" } + require(serverUrl.isNotBlank()) { "diagnostics authorization requires a server URL" } + require(accessToken.isNotBlank()) { "diagnostics authorization requires an access token" } + } + + override fun toString(): String = + "DiagnosticsUploadAuthorization(" + + "serverId=, serverUrl=, accessToken=, " + + "activeProfileId=, identityGeneration=)" +} + enum class DiagnosticsProfileHeaderMode { ACTIVE, SUPPRESS, @@ -26,6 +50,15 @@ data class DiagnosticsRequestScope( val DiagnosticsRequestScopeKey: AttributeKey = AttributeKey("PrairieDiagnosticsRequestScope") +/** + * Exact, already-captured authorization for a diagnostics upload whose send + * start is serialized with identity mutation. PrairieAuthPlugin must not read or + * refresh TokenManager while handling this request: Android's persistent token + * manager uses that same identity barrier, so doing so would deadlock. + */ +internal val DiagnosticsUploadAuthorizationKey: AttributeKey = + AttributeKey("PrairieDiagnosticsUploadAuthorization") + fun HttpRequestBuilder.diagnosticsProfileScope(capturedProfileId: String?) { attributes.put( DiagnosticsRequestScopeKey, @@ -34,3 +67,9 @@ fun HttpRequestBuilder.diagnosticsProfileScope(capturedProfileId: String?) { } ?: DiagnosticsRequestScope(DiagnosticsProfileHeaderMode.SUPPRESS), ) } + +internal fun HttpRequestBuilder.diagnosticsUploadAuthorization( + authorization: DiagnosticsUploadAuthorization, +) { + attributes.put(DiagnosticsUploadAuthorizationKey, authorization) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt index 2e689ece0..54168411b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrier.kt @@ -14,6 +14,7 @@ enum class IdentityTransitionPhase { WILL_CHANGE, DID_CHANGE } enum class IdentityTransitionKind { SIGN_IN, + ACCOUNT_REPLACE, SIGN_OUT, SERVER_SWITCH, SERVER_REMOVE, @@ -26,6 +27,15 @@ data class IdentityTransition( val phase: IdentityTransitionPhase, val kind: IdentityTransitionKind, val generation: Long, + val targetServerId: String? = null, + val affectsCurrentIdentity: Boolean = true, + val purgesPersistentIdentity: Boolean = true, +) + +data class IdentityTransitionTarget( + val serverId: String? = null, + val affectsCurrentIdentity: Boolean = true, + val purgesPersistentIdentity: Boolean = true, ) interface IdentityTransitionBarrier { @@ -35,7 +45,23 @@ interface IdentityTransitionBarrier { /** Installs the inline privacy gate, which must complete before identity mutation. */ fun installGate(listener: suspend (IdentityTransition) -> Unit) - suspend fun changing(kind: IdentityTransitionKind, block: suspend () -> T): T + /** + * Runs [block] only while [expectedGeneration] is still current, serializing it + * with identity mutation. A null result means the generation already changed. + * + * Keep the guarded block as narrow as possible: callers may suspend an account + * transition until it completes. + */ + suspend fun withCurrentGeneration( + expectedGeneration: Long, + block: suspend () -> T, + ): T? + + suspend fun changing( + kind: IdentityTransitionKind, + target: suspend () -> IdentityTransitionTarget = { IdentityTransitionTarget() }, + block: suspend () -> T, + ): T } class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { @@ -63,13 +89,28 @@ class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { gates = gates + listener } - override suspend fun changing(kind: IdentityTransitionKind, block: suspend () -> T): T = + override suspend fun withCurrentGeneration( + expectedGeneration: Long, + block: suspend () -> T, + ): T? = mutationMutex.withLock { + if (_generation.value != expectedGeneration) null else block() + } + + override suspend fun changing( + kind: IdentityTransitionKind, + target: suspend () -> IdentityTransitionTarget, + block: suspend () -> T, + ): T = mutationMutex.withLock { + val resolvedTarget = target() val nextGeneration = _generation.value + 1 val willChange = IdentityTransition( phase = IdentityTransitionPhase.WILL_CHANGE, kind = kind, generation = nextGeneration, + targetServerId = resolvedTarget.serverId, + affectsCurrentIdentity = resolvedTarget.affectsCurrentIdentity, + purgesPersistentIdentity = resolvedTarget.purgesPersistentIdentity, ) // This callback is the privacy boundary. It runs inline before new identity is visible. gates.forEach { gate -> gate(willChange) } @@ -83,6 +124,9 @@ class DefaultIdentityTransitionBarrier : IdentityTransitionBarrier { phase = IdentityTransitionPhase.DID_CHANGE, kind = kind, generation = nextGeneration, + targetServerId = resolvedTarget.serverId, + affectsCurrentIdentity = resolvedTarget.affectsCurrentIdentity, + purgesPersistentIdentity = resolvedTarget.purgesPersistentIdentity, ), ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt index 03abb43a8..0e1249a89 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt @@ -6,9 +6,14 @@ import io.ktor.client.plugins.websocket.webSocket import io.ktor.http.encodeURLParameter import io.ktor.websocket.Frame import io.ktor.websocket.readText +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -72,7 +77,29 @@ class DefaultPlaybackRealtimeClient( private val json: Json = PrairieJson, ) : PlaybackRealtimeClient { - private var session: DefaultClientWebSocketSession? = null + /** + * The socket paired with the playback session it belongs to. + * + * Holding the id alongside the socket is what makes a send answerable to a + * caller. With a bare socket field, a reconnect overwrites it while the + * outgoing connection's `finally` clears it unconditionally — so a late + * close from session A disables session B's remote control, and an ack for + * A goes out over B's socket and vanishes. Both are indistinguishable from + * a flaky network at the call site. + */ + private data class RealtimeConnection( + val sessionId: String, + val socket: DefaultClientWebSocketSession, + ) + + /** + * Guards [connection]. A volatile read-then-write is not a compare-and-set: + * a newer connection can install itself between the two, and the older one + * then clears it. Every access is a suspend call site, so a mutex is enough + * and needs no atomics dependency. + */ + private val connectionLock = Mutex() + private var connection: RealtimeConnection? = null override fun connect(sessionId: String): Flow = callbackFlow { val token = tokenManager.getAccessToken() @@ -85,8 +112,9 @@ class DefaultPlaybackRealtimeClient( close() return@callbackFlow } - val profileId = tokenManager.getProfileId() - val profileToken = tokenManager.getProfileToken() + val profileIdentity = tokenManager.getProfileIdentity() + val profileId = profileIdentity.profileId + val profileToken = profileIdentity.profileToken val url = buildString { append("/api/v1/playback/sessions/") append(sessionId.encodeURLParameter()) @@ -98,9 +126,27 @@ class DefaultPlaybackRealtimeClient( append("&profile_token=").append(profileToken.encodeURLParameter()) } } + var owned: RealtimeConnection? = null + // Clear on identity under the lock, never a blind null: this connection + // may already have been superseded by a newer one, and clearing that + // would leave the live socket unreachable to every send. + // NonCancellable: every caller below runs on a teardown path, and two of + // the three run while this coroutine is already cancelled. A cancellable + // acquisition simply throws there, leaving a dead socket installed as the + // target of every subsequent send until some later connection happens to + // overwrite it. + suspend fun releaseIfStillOwned() { + withContext(NonCancellable) { + connectionLock.withLock { + if (connection === owned) connection = null + } + } + } try { client.webSocket(urlString = url) { - session = this + val current = RealtimeConnection(sessionId, this) + owned = current + connectionLock.withLock { connection = current } // R2: signal open AFTER the session is assigned, so the // controller's hello can't race ahead of a live socket. trySend(PlaybackRealtimeEvent.Opened) @@ -110,12 +156,17 @@ class DefaultPlaybackRealtimeClient( decodePlaybackFrame(json, frame.readText())?.let { trySend(it) } } } finally { - session = null + releaseIfStillOwned() } } trySend(PlaybackRealtimeEvent.Closed()) + } catch (cancellation: CancellationException) { + // Not a socket failure. Reporting Closed here tells the controller to + // reconnect the very session that is being torn down. + releaseIfStillOwned() + throw cancellation } catch (e: Throwable) { - session = null + releaseIfStillOwned() trySend(PlaybackRealtimeEvent.Closed(e.message)) } finally { close() @@ -123,9 +174,23 @@ class DefaultPlaybackRealtimeClient( awaitClose { } } - private suspend fun sendText(text: String) { session?.send(Frame.Text(text)) } + /** + * Writes only on the connection that belongs to [sessionId]. Every envelope + * already names its session, so a send that cannot be matched to the open + * socket is for a connection that has moved on — dropping it is correct, and + * strictly better than writing it down somebody else's socket. + */ + private suspend fun sendText(sessionId: String, text: String) { + // Resolve under the lock, then send outside it — the send is network I/O + // and must not block a teardown trying to release the field. + val current = connectionLock.withLock { + connection?.takeIf { it.sessionId == sessionId } + } ?: return + current.socket.send(Frame.Text(text)) + } override suspend fun sendHello(sessionId: String) = sendText( + sessionId, json.encodeToString( PlaybackHelloEnvelope.serializer(), PlaybackHelloEnvelope( @@ -137,6 +202,7 @@ class DefaultPlaybackRealtimeClient( ) override suspend fun sendAck(sessionId: String, commandId: String) = sendText( + sessionId, json.encodeToString( PlaybackAckEnvelope.serializer(), PlaybackAckEnvelope(commandId = commandId, sessionId = sessionId), @@ -144,6 +210,7 @@ class DefaultPlaybackRealtimeClient( ) override suspend fun sendResult(sessionId: String, commandId: String, status: String, error: String?) = sendText( + sessionId, json.encodeToString( PlaybackResultEnvelope.serializer(), PlaybackResultEnvelope(commandId = commandId, sessionId = sessionId, status = status, error = error), diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt new file mode 100644 index 000000000..04653f95a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PrairieAuthUnavailableException.kt @@ -0,0 +1,27 @@ +package org.prairieserver.prairie.network + +/** + * Thrown instead of sending a request whose credentials the client cannot or + * must not spend. + * + * Two reasons, both meaning "do not put this on the wire": + * - [REQUIRED_AUTH_UNAVAILABLE] — the request demanded auth and there is no + * usable token for its scope. + * - [CREDENTIALS_REPUDIATED] — a proactive refresh was rejected, so the session + * is already torn down. The access token may still have time left, which is + * exactly the danger: the server could honour a write for a session the app + * has ended. + * + * Subclasses [IllegalStateException] because that is what the required-auth + * path has always thrown, so existing handlers keep working. Callers that + * classify failures should treat this as **retriable after re-authentication**, + * not permanent: `safeApiCall` already maps it to [ApiResult.NetworkError], and + * `DownloadWorker` catches it explicitly so a repudiated session retries rather + * than deleting a part-downloaded file. + */ +class PrairieAuthUnavailableException(val reason: String) : IllegalStateException(reason) { + companion object { + const val REQUIRED_AUTH_UNAVAILABLE = "required_silo_auth_unavailable" + const val CREDENTIALS_REPUDIATED = "silo_auth_credentials_repudiated" + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicy.kt new file mode 100644 index 000000000..30075cdd7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicy.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.network + +/** + * Whether a token with [remainingMs] left should be refreshed before it is + * spent, for a caller wanting [marginMs] of headroom, given the token's total + * [lifetimeMs]. + * + * Two rules, both of them about not refreshing more often than the token is + * actually worth: + * + * - The margin is clamped to half the token's own lifetime. A server issuing + * 30-second access tokens against a 60-second margin would otherwise be + * inside the window from the instant it issued one: every request would + * refresh, and every refresh rotates the refresh token — a storm that + * invites rate limiting and turns one transient rejection into a signed-out + * session. Clamped, such a token refreshes at its half-life instead. + * + * - A null [lifetimeMs] means we do not know what the issuer intended, which + * is the state of any credential stored before this field existed. Guessing + * with the caller's full margin is how the storm above happens, so an + * unknown lifetime keeps the old purely reactive behaviour until the next + * successful token issuance records one. + * + * An already-expired token is always due regardless: there is nothing left to + * conserve, and refreshing first is strictly cheaper than the 401 that would + * otherwise follow. + */ +internal fun shouldRefreshProactively( + remainingMs: Long, + lifetimeMs: Long?, + marginMs: Long, +): Boolean { + if (remainingMs <= 0) return true + val lifetime = lifetimeMs ?: return false + if (lifetime <= 0) return false + val margin = marginMs.coerceAtLeast(0) + return remainingMs <= minOf(margin, lifetime / 2) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt index 4b8e6681a..f15a52e5a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManager.kt @@ -10,7 +10,20 @@ data class TemporaryAuthScope( val refreshToken: String, val profileId: String, val profileToken: String, + /** + * When the temporary SESSION ends — the deadline the cast UI counts down + * to. Not the access token's deadline: the session outlives many access + * tokens, so this must never be used to decide whether to refresh. + */ val expiresAtEpochMs: Long, + /** + * When this overlay's ACCESS TOKEN expires, or null before the first + * refresh has told us. Null means "unknown", which keeps the reactive + * 401 path rather than guessing off the session deadline. + */ + val accessTokenExpiresAtEpochMs: Long? = null, + /** Lifetime the server gave that access token, for the half-life clamp. */ + val accessTokenLifetimeMs: Long? = null, ) { override fun toString(): String = "TemporaryAuthScope(" + @@ -19,6 +32,9 @@ data class TemporaryAuthScope( "profileToken=, expiresAtEpochMs=$expiresAtEpochMs)" } +/** A profile id and the token that proves it, read together. */ +data class ProfileIdentity(val profileId: String?, val profileToken: String?) + /** * Manages JWT access and refresh tokens. * Implementation provided by Agent 2 in TokenManagerImpl.kt. @@ -27,6 +43,30 @@ interface TokenManager { suspend fun getAccessToken(): String? suspend fun getRefreshToken(): String? suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) + + /** + * Installs credentials returned by an explicit login/account-approval flow. + * Unlike [saveTokens], this is always an identity boundary, even when the + * target server is already active and credentials already exist. + * + * Persistent implementations must override this and place server + * activation, profile reset, and all credential writes inside one + * [IdentityTransitionKind.ACCOUNT_REPLACE] mutation. + */ + suspend fun replaceAccountSession( + serverId: String? = null, + serverUrl: String? = null, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String? = null, + profileToken: String? = null, + ) { + if (serverUrl != null) setServerUrl(serverUrl) + if (serverId != null) switchActiveServer(serverId) + setProfileIdentity(profileId, profileToken) + saveTokens(accessToken, refreshToken, expiresIn) + } suspend fun clearTokens() /** @@ -75,6 +115,47 @@ interface TokenManager { suspend fun setProfileId(profileId: String?) suspend fun getProfileToken(): String? suspend fun setProfileToken(token: String?) + + /** + * Read the profile id and its token as ONE identity. + * + * [setProfileIdentity] makes the write atomic, but a reader taking the two + * getters separately can still interleave with a switch and pair the old id + * with the new token — sending headers that claim one profile while + * presenting another's proof, which is exactly what that write fixed. + * Anything assembling both into a request must use this. + * + * The default is the non-atomic pair so simple/test managers keep working; + * managers with real locking override it to read under one lock. + * + * A wrapper using `TokenManager by delegate` MUST override this too: Kotlin + * interface delegation forwards default methods to the delegate, so + * overriding only [getProfileId]/[getProfileToken] leaves this reading the + * delegate's identity instead — silently, with tests still green. + */ + suspend fun getProfileIdentity(): ProfileIdentity = + ProfileIdentity(profileId = getProfileId(), profileToken = getProfileToken()) + + /** + * Commit a profile id and its matching profile token as ONE identity. + * + * A profile token is bound server-side to a single profile id, so the two + * are one fact, not two. Writing them separately means a process death + * between the writes persists a mismatch that survives to the next launch. + * + * This makes the WRITE one operation. It does not make concurrent reads + * consistent: [getProfileId] and [getProfileToken] still take the lock + * separately, so a reader interleaving with a commit can pair an old id + * with a new token. + * + * The default is the non-atomic pair, which keeps simple/test managers + * working; managers with real durable storage override this to do it in a + * single lock and a single edit. + */ + suspend fun setProfileIdentity(profileId: String?, profileToken: String?) { + setProfileId(profileId) + setProfileToken(profileToken) + } suspend fun getServerUrl(): String suspend fun setServerUrl(url: String) @@ -108,6 +189,27 @@ interface TokenManager { // ----- Scoped auth (Track B outbox replay; see [AuthScopeSnapshot]) ----- + /** + * True when the ACTIVE scope's access token expires within [marginMs], so a + * caller can refresh before spending it rather than after the server has + * rejected it. + * + * Every implementation already records an expiry at save time and no caller + * has ever read it, so expiry was only ever discovered by a 401: the first + * request after the deadline paid a wasted round trip, and on a live device + * that was 42 of 351 `/home/sections` calls. + * + * Default false — an implementation that cannot answer must keep today's + * reactive behaviour rather than guess, since a wrong "yes" spends a + * refresh token on every request. + * + * Implementations must clamp [marginMs] to half the token's own lifetime + * (see [shouldRefreshProactively]). Without that, a server issuing tokens + * shorter than the margin is inside the window from the moment it issues + * one, and every single request refreshes. + */ + suspend fun accessTokenExpiresWithin(marginMs: Long): Boolean = false + /** * Capture the currently-active scope for pinning a background request. Returns * null when no server is active or the implementation isn't multi-server-aware. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManagerImpl.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManagerImpl.kt index 1fdee5be2..313661bdd 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManagerImpl.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/TokenManagerImpl.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -31,6 +32,9 @@ class TokenManagerImpl( private var refreshToken: String? = null private var tokenExpiry: TimeSource.Monotonic.ValueTimeMark? = null + /** Lifetime the server gave the current access token, for the half-life clamp. */ + private var tokenLifetimeMs: Long? = null + private var profileId: String? = null private var profileToken: String? = null @@ -71,6 +75,38 @@ class TokenManagerImpl( } } + override suspend fun replaceAccountSession( + serverId: String?, + serverUrl: String?, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String?, + profileToken: String?, + ) { + tokenWriteMutex.withLock { + identityTransitions.changing( + kind = IdentityTransitionKind.ACCOUNT_REPLACE, + target = { + check(mutex.withLock { temporaryScope == null }) { + "cannot replace the account inside a temporary auth scope" + } + IdentityTransitionTarget(serverId = serverId) + }, + ) { + mutex.withLock { + if (serverUrl != null) this.serverUrl = serverUrl.trimEnd('/') + this.profileId = profileId + this.profileToken = profileToken + this.accessToken = accessToken + this.refreshToken = refreshToken + this.tokenExpiry = timeSource.markNow() + expiresIn.seconds + this.tokenLifetimeMs = expiresIn.seconds.inWholeMilliseconds + } + } + } + } + private suspend fun saveTokensLocked(accessToken: String, refreshToken: String, expiresIn: Long) { mutex.withLock { temporaryScope?.let { scope -> @@ -83,12 +119,16 @@ class TokenManagerImpl( this.accessToken = accessToken this.refreshToken = refreshToken this.tokenExpiry = timeSource.markNow() + expiresIn.seconds + this.tokenLifetimeMs = expiresIn.seconds.inWholeMilliseconds } } override suspend fun clearTokens() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { clearTokensLocked() } } } @@ -96,7 +136,10 @@ class TokenManagerImpl( override suspend fun invalidateSession() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { clearTokensLocked() } // Non-suspending emit so this method can be called from anywhere // without caller cooperation. DROP_OLDEST buffer means a rapid @@ -107,6 +150,23 @@ class TokenManagerImpl( } } + /** + * Reads the deadline [saveTokensLocked] has always recorded. A temporary + * overlay is excluded: this impl does not track an expiry for one, and + * answering from the underlying account's deadline would refresh the wrong + * credentials. + */ + override suspend fun accessTokenExpiresWithin(marginMs: Long): Boolean = mutex.withLock { + if (temporaryScope != null) return@withLock false + if (accessToken == null) return@withLock false + val expiry = tokenExpiry ?: return@withLock false + shouldRefreshProactively( + remainingMs = (expiry - timeSource.markNow()).inWholeMilliseconds, + lifetimeMs = tokenLifetimeMs, + marginMs = marginMs, + ) + } + override suspend fun getProfileId(): String? = mutex.withLock { temporaryScope?.profileId ?: profileId } @@ -135,6 +195,24 @@ class TokenManagerImpl( } } + override suspend fun getProfileIdentity(): ProfileIdentity = mutex.withLock { + temporaryScope?.let { scope -> + return@withLock ProfileIdentity(scope.profileId, scope.profileToken) + } + ProfileIdentity(profileId, profileToken) + } + + /** Single lock so the stored pair is written together; see [TokenManager]. */ + override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) { + mutex.withLock { + // See EncryptedTokenManagerImpl: an overlay owns its identity, and + // merging a commit into it recreates the id/token mismatch. + if (temporaryScope != null) return@withLock + this.profileId = profileId + this.profileToken = profileToken + } + } + override suspend fun getServerUrl(): String = mutex.withLock { temporaryScope?.serverUrl ?: serverUrl } @@ -156,7 +234,10 @@ class TokenManagerImpl( } override suspend fun signOutCurrentServer() { tokenWriteMutex.withLock { - identityTransitions.changing(IdentityTransitionKind.SIGN_OUT) { + identityTransitions.changing( + kind = IdentityTransitionKind.SIGN_OUT, + target = { currentSignOutTarget() }, + ) { mutex.withLock { clearTokensLocked() } } } @@ -179,6 +260,14 @@ class TokenManagerImpl( override suspend fun hasTemporaryScope(): Boolean = mutex.withLock { temporaryScope != null } + private suspend fun currentSignOutTarget(): IdentityTransitionTarget = mutex.withLock { + val temporary = temporaryScope + IdentityTransitionTarget( + serverId = temporary?.serverId, + purgesPersistentIdentity = temporary == null, + ) + } + private fun clearTokensLocked() { if (temporaryScope != null) { temporaryScope = null @@ -187,6 +276,7 @@ class TokenManagerImpl( accessToken = null refreshToken = null tokenExpiry = null + tokenLifetimeMs = null profileId = null profileToken = null } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AdminApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AdminApi.kt deleted file mode 100644 index d733f075e..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AdminApi.kt +++ /dev/null @@ -1,231 +0,0 @@ -// shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AdminApi.kt -package org.prairieserver.prairie.network.api - -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import io.ktor.client.HttpClient -import io.ktor.client.request.delete -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType - -/** - * Core-admin surface (stats, users, sessions + controls, logs, scans). Every - * route is gated server-side on acting-admin; the UI mirrors that with - * [org.prairieserver.prairie.model.auth.isActingAdmin]. Behind an interface so the - * repository and its tests can fake the transport (matching - * NotificationsApi/SubtitlesApi). - * - * NOTE: the scan endpoints ([triggerScan]/[cancelScan]) live under - * `/api/v1/libraries`, NOT `/admin` — they are kept on this interface for - * cohesion with the admin "Scans" sub-screen, which is the only admin caller. - */ -interface AdminApi { - - /** GET /api/v1/admin/stats[?refresh=true]. */ - suspend fun getStats(refresh: Boolean = false): ApiResult - - /** GET /api/v1/admin/users — bare array. */ - suspend fun getUsers(): ApiResult> - - /** GET /api/v1/admin/users/{id}. */ - suspend fun getUser(id: Int): ApiResult - - /** POST /api/v1/admin/users. */ - suspend fun createUser(request: CreateUserRequest): ApiResult - - /** PUT /api/v1/admin/users/{id} — partial; null fields omitted. */ - suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult - - /** DELETE /api/v1/admin/users/{id} — 204. */ - suspend fun deleteUser(id: Int): ApiResult - - /** GET /api/v1/admin/sessions — bare array of active sessions. */ - suspend fun getSessions(): ApiResult> - - /** POST /api/v1/admin/sessions/{id}/{action} — body optional per action. */ - suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest = SessionControlRequest(), - ): ApiResult - - /** GET /api/v1/admin/logs/app — cursor-paginated; null filters omitted. */ - suspend fun getAppLogs( - level: String? = null, - component: String? = null, - nodeId: String? = null, - requestId: String? = null, - sessionId: String? = null, - playbackSessionId: String? = null, - userId: Int? = null, - from: String? = null, - to: String? = null, - query: String? = null, - cursor: String? = null, - limit: Int = 100, - ): ApiResult - - /** GET /api/v1/admin/logs/audit — cursor-paginated; null filters omitted. */ - suspend fun getAuditLogs( - method: String? = null, - pathPrefix: String? = null, - statusCode: Int? = null, - clientIp: String? = null, - requestId: String? = null, - sessionId: String? = null, - playbackSessionId: String? = null, - userId: Int? = null, - from: String? = null, - to: String? = null, - cursor: String? = null, - limit: Int = 100, - ): ApiResult - - /** POST /api/v1/libraries/scan (NOT /admin). */ - suspend fun triggerScan(request: ScanRequest): ApiResult - - /** POST /api/v1/libraries/scan/cancel (NOT /admin). */ - suspend fun cancelScan(request: ScanCancelRequest): ApiResult -} - -class DefaultAdminApi(private val client: HttpClient) : AdminApi { - - override suspend fun getStats(refresh: Boolean): ApiResult = safeApiCall { - client.get("/api/v1/admin/stats") { - if (refresh) parameter("refresh", "true") - } - } - - override suspend fun getUsers(): ApiResult> = safeApiCall { - client.get("/api/v1/admin/users") - } - - override suspend fun getUser(id: Int): ApiResult = safeApiCall { - client.get("/api/v1/admin/users/$id") - } - - override suspend fun createUser(request: CreateUserRequest): ApiResult = safeApiCall { - client.post("/api/v1/admin/users") { - contentType(ContentType.Application.Json) - setBody(request) - } - } - - override suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult = safeApiCall { - client.put("/api/v1/admin/users/$id") { - contentType(ContentType.Application.Json) - setBody(request) - } - } - - override suspend fun deleteUser(id: Int): ApiResult = safeApiCall { - client.delete("/api/v1/admin/users/$id") - } - - override suspend fun getSessions(): ApiResult> = safeApiCall { - client.get("/api/v1/admin/sessions") - } - - override suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest, - ): ApiResult = safeApiCall { - client.post("/api/v1/admin/sessions/$sessionId/${action.wire}") { - contentType(ContentType.Application.Json) - setBody(request) - } - } - - override suspend fun getAppLogs( - level: String?, - component: String?, - nodeId: String?, - requestId: String?, - sessionId: String?, - playbackSessionId: String?, - userId: Int?, - from: String?, - to: String?, - query: String?, - cursor: String?, - limit: Int, - ): ApiResult = safeApiCall { - client.get("/api/v1/admin/logs/app") { - level?.let { parameter("level", it) } - component?.let { parameter("component", it) } - nodeId?.let { parameter("node_id", it) } - requestId?.let { parameter("request_id", it) } - sessionId?.let { parameter("session_id", it) } - playbackSessionId?.let { parameter("playback_session_id", it) } - userId?.let { parameter("user_id", it) } - from?.let { parameter("from", it) } - to?.let { parameter("to", it) } - query?.let { parameter("q", it) } - cursor?.let { parameter("cursor", it) } - parameter("limit", limit) - } - } - - override suspend fun getAuditLogs( - method: String?, - pathPrefix: String?, - statusCode: Int?, - clientIp: String?, - requestId: String?, - sessionId: String?, - playbackSessionId: String?, - userId: Int?, - from: String?, - to: String?, - cursor: String?, - limit: Int, - ): ApiResult = safeApiCall { - client.get("/api/v1/admin/logs/audit") { - method?.let { parameter("method", it) } - pathPrefix?.let { parameter("path_prefix", it) } - statusCode?.let { parameter("status_code", it) } - clientIp?.let { parameter("client_ip", it) } - requestId?.let { parameter("request_id", it) } - sessionId?.let { parameter("session_id", it) } - playbackSessionId?.let { parameter("playback_session_id", it) } - userId?.let { parameter("user_id", it) } - from?.let { parameter("from", it) } - to?.let { parameter("to", it) } - cursor?.let { parameter("cursor", it) } - parameter("limit", limit) - } - } - - override suspend fun triggerScan(request: ScanRequest): ApiResult = safeApiCall { - client.post("/api/v1/libraries/scan") { - contentType(ContentType.Application.Json) - setBody(request) - } - } - - override suspend fun cancelScan(request: ScanCancelRequest): ApiResult = safeApiCall { - client.post("/api/v1/libraries/scan/cancel") { - contentType(ContentType.Application.Json) - setBody(request) - } - } -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt index 8d04833b4..7e47125bc 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/AuthApi.kt @@ -45,7 +45,10 @@ class AuthApi(private val client: HttpClient) { } suspend fun getSetupStatus(): ApiResult = safeApiCall { - client.get("/api/v1/auth/setup") + // Public, exactly like the explicit-server variant below — which + // already opted out. Without this the relative form carries a bearer + // it never needed, and a dead session would fail it. + client.get("/api/v1/auth/setup") { skipPrairieAuth() } } suspend fun getSetupStatus(serverUrl: String): ApiResult = safeApiCall { @@ -55,7 +58,7 @@ class AuthApi(private val client: HttpClient) { } suspend fun getSignupStatus(): ApiResult = safeApiCall { - client.get("/api/v1/auth/signup") + client.get("/api/v1/auth/signup") { skipPrairieAuth() } } suspend fun getSignupStatus(serverUrl: String): ApiResult = safeApiCall { @@ -64,23 +67,45 @@ class AuthApi(private val client: HttpClient) { } } - suspend fun getMe(): ApiResult = safeApiCall { - client.get("/api/v1/auth/me") + /** + * Resolves an emailed-invitation claim token against the given server. + * Unauthenticated: this runs before any account exists. + */ + suspend fun lookupInvitation( + serverUrl: String, + token: String, + ): ApiResult = safeApiCall { + // The token arrives from an emailed link and is not ours to trust as + // path-safe: a '/' or '?' in it would otherwise re-shape the request. + client.get("${serverUrl.trimEnd('/')}/api/v1/invitations/${token.encodeURLPathPart()}") { + skipPrairieAuth() + } } - suspend fun logout(): ApiResult = safeApiCall { - client.post("/api/v1/auth/logout") + /** + * Accepts an invitation: creates the account (username = the invitation's + * email) and returns a normal login response. + */ + suspend fun acceptInvitation( + serverUrl: String, + token: String, + password: String, + ): ApiResult = safeApiCall { + client.post("${serverUrl.trimEnd('/')}/api/v1/invitations/${token.encodeURLPathPart()}/accept") { + skipPrairieAuth() + contentType(ContentType.Application.Json) + setBody(AcceptInvitationRequest(password = password)) + } } - suspend fun getSessions(): ApiResult = safeApiCall { - client.get("/api/v1/auth/sessions") + suspend fun getMe(): ApiResult = safeApiCall { + client.get("/api/v1/auth/me") } - suspend fun revokeSession(id: String): ApiResult = safeApiCall { - client.delete("/api/v1/auth/sessions/$id") + suspend fun logout(): ApiResult = safeApiCall { + client.post("/api/v1/auth/logout") } - suspend fun deleteSession(id: String): ApiResult = revokeSession(id) } /** diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt new file mode 100644 index 000000000..37f51a3bd --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/BrandingApi.kt @@ -0,0 +1,37 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.timeout +import io.ktor.client.request.get +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.skipPrairieAuth + +@Serializable +data class BrandingStatus( + @SerialName("server_name") + val serverName: String? = null, +) + +open class BrandingApi(private val client: HttpClient) { + open suspend fun getBranding(): ApiResult = safeApiCall { + client.get("/api/v1/theme/branding") { + // Public identity probe, like checkHealth() which it replaced as + // the primary source of a server's display name. Without this it + // carries a bearer it never needed, so a dead session would fail + // it and silently fall back to the compatibility name this + // endpoint exists to stop using. + skipPrairieAuth() + timeout { + connectTimeoutMillis = BRANDING_TIMEOUT_MS + requestTimeoutMillis = BRANDING_TIMEOUT_MS + socketTimeoutMillis = BRANDING_TIMEOUT_MS + } + } + } + + private companion object { + const val BRANDING_TIMEOUT_MS = 6_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt index fab184605..d1521acbf 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/CatalogApi.kt @@ -42,20 +42,7 @@ class CatalogApi(private val client: HttpClient) { yearMax?.let { parameter("year_max", it) } snapshotAt?.let { parameter("snapshot", it) } match?.let { parameter("match", it) } - queryGroups.forEachIndexed { groupIndex, group -> - parameter("groups[$groupIndex][match]", group.match) - group.rules.forEachIndexed { ruleIndex, rule -> - parameter("groups[$groupIndex][rules][$ruleIndex][field]", rule.field) - parameter("groups[$groupIndex][rules][$ruleIndex][op]", rule.op) - if (rule.values.isNotEmpty()) { - rule.values.forEachIndexed { valueIndex, value -> - parameter("groups[$groupIndex][rules][$ruleIndex][value][$valueIndex]", value) - } - } else { - parameter("groups[$groupIndex][rules][$ruleIndex][value]", rule.value) - } - } - } + catalogQueryGroupParameters(queryGroups) } } @@ -79,13 +66,22 @@ class CatalogApi(private val client: HttpClient) { } } + /** + * Facet vocabularies. [source]/[collectionId] scope the options to one + * catalog source (e.g. `source=library_collection`) so a collection's + * filter panel only offers values its own members actually have. + */ suspend fun getFilters( libraryId: Int? = null, includeTechnical: Boolean = false, + source: String? = null, + collectionId: String? = null, ): ApiResult = safeApiCall { client.get("/api/v1/catalog/filters") { libraryId?.let { parameter("library_id", it) } if (includeTechnical) parameter("include_technical", "true") + source?.let { parameter("source", it) } + collectionId?.let { parameter("collection_id", it) } } } @@ -154,3 +150,25 @@ class CatalogApi(private val client: HttpClient) { } } } + +/** + * Encodes structured catalog filter groups as the server's bracketed query + * params (`groups[g][rules][r][field]`, …). Range ops carry indexed values. + * Shared by every `/api/v1/catalog` caller so the encoding has one definition. + */ +internal fun HttpRequestBuilder.catalogQueryGroupParameters(groups: List) { + groups.forEachIndexed { groupIndex, group -> + parameter("groups[$groupIndex][match]", group.match) + group.rules.forEachIndexed { ruleIndex, rule -> + parameter("groups[$groupIndex][rules][$ruleIndex][field]", rule.field) + parameter("groups[$groupIndex][rules][$ruleIndex][op]", rule.op) + if (rule.values.isNotEmpty()) { + rule.values.forEachIndexed { valueIndex, value -> + parameter("groups[$groupIndex][rules][$ruleIndex][value][$valueIndex]", value) + } + } else { + parameter("groups[$groupIndex][rules][$ruleIndex][value]", rule.value) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApi.kt index 9e4a56ae8..6ab64930f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApi.kt @@ -21,7 +21,9 @@ import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResult import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResponse import org.prairieserver.prairie.network.ApiErrorBody import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DiagnosticsUploadAuthorization import org.prairieserver.prairie.network.diagnosticsProfileScope +import org.prairieserver.prairie.network.diagnosticsUploadAuthorization interface DiagnosticsApi { suspend fun getStatus(): ApiResult @@ -31,6 +33,18 @@ interface DiagnosticsApi { bundleBytes: ByteArray, capturedProfileId: String?, ): DiagnosticsUploadResult + + /** + * Sends against one exact server credential without auth refresh or request + * rebasing. Implementations that do not own a Silo transport may delegate to + * [upload]; the production implementation overrides this boundary. + */ + suspend fun upload( + manifestJson: ByteArray, + bundleBytes: ByteArray, + capturedProfileId: String?, + authorization: DiagnosticsUploadAuthorization, + ): DiagnosticsUploadResult = upload(manifestJson, bundleBytes, capturedProfileId) } class DefaultDiagnosticsApi( @@ -45,9 +59,36 @@ class DefaultDiagnosticsApi( manifestJson: ByteArray, bundleBytes: ByteArray, capturedProfileId: String?, + ): DiagnosticsUploadResult = performUpload( + manifestJson = manifestJson, + bundleBytes = bundleBytes, + capturedProfileId = capturedProfileId, + authorization = null, + ) + + override suspend fun upload( + manifestJson: ByteArray, + bundleBytes: ByteArray, + capturedProfileId: String?, + authorization: DiagnosticsUploadAuthorization, + ): DiagnosticsUploadResult = performUpload( + manifestJson = manifestJson, + bundleBytes = bundleBytes, + capturedProfileId = capturedProfileId, + authorization = authorization, + ) + + private suspend fun performUpload( + manifestJson: ByteArray, + bundleBytes: ByteArray, + capturedProfileId: String?, + authorization: DiagnosticsUploadAuthorization?, ): DiagnosticsUploadResult = try { - val response = client.post("/api/v1/diagnostics/reports") { + val endpoint = authorization?.let { "${it.serverUrl.trimEnd('/')}/api/v1/diagnostics/reports" } + ?: "/api/v1/diagnostics/reports" + val response = client.post(endpoint) { diagnosticsProfileScope(capturedProfileId) + authorization?.let { diagnosticsUploadAuthorization(it) } setBody( MultiPartFormDataContent( formData { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HealthApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HealthApi.kt index e1668890e..585d9738d 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HealthApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HealthApi.kt @@ -1,12 +1,12 @@ package org.prairieserver.prairie.network.api import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.skipPrairieAuth import io.ktor.client.HttpClient import io.ktor.client.plugins.timeout import io.ktor.client.request.get import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import org.prairieserver.prairie.network.skipPrairieAuth @Serializable data class HealthStatus( @@ -21,6 +21,10 @@ open class HealthApi(private val client: HttpClient) { open suspend fun checkHealth(): ApiResult = safeApiCall { client.get("/api/v1/health") { + // Public: never send credentials, so a dead session cannot make a + // reachability check fail. Matches the explicit-server variants of + // the other public endpoints. + skipPrairieAuth() timeout { connectTimeoutMillis = HEALTH_TIMEOUT_MS requestTimeoutMillis = HEALTH_TIMEOUT_MS diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApi.kt new file mode 100644 index 000000000..dd930a9dc --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApi.kt @@ -0,0 +1,326 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.timeout +import io.ktor.client.request.accept +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.http.URLProtocol +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prairieserver.prairie.model.diagnostics.DiagnosticsManifest +import org.prairieserver.prairie.network.ApiErrorBody +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.createPlatformHttpClient +import org.prairieserver.prairie.network.httpOrigin + +const val DEFAULT_HOSTED_DIAGNOSTICS_BASE_URL = "https://diagnostics.prairieserver.org" + +/** + * Builds the public collector transport without installing PrairieAuthPlugin, cookies, + * profile headers, or any source-server default request state. + */ +fun createHostedDiagnosticsClient( + baseUrl: String = DEFAULT_HOSTED_DIAGNOSTICS_BASE_URL, + platformClient: HttpClient = createPlatformHttpClient(), +): HttpClient { + val normalizedBaseUrl = validateHostedDiagnosticsBaseUrl(baseUrl) + return platformClient.config { + followRedirects = false + install(ContentNegotiation) { json(PrairieJson) } + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 60_000 + socketTimeoutMillis = 60_000 + } + defaultRequest { + url(normalizedBaseUrl) + accept(ContentType.Application.Json) + } + } +} + +internal fun validateHostedDiagnosticsBaseUrl(baseUrl: String): String { + require(baseUrl == baseUrl.trim() && baseUrl.isNotBlank()) { "invalid hosted diagnostics origin" } + require('?' !in baseUrl && '#' !in baseUrl) { + "hosted diagnostics origin must not contain a query or fragment" + } + val parsed = runCatching { Url(baseUrl) }.getOrElse { throw IllegalArgumentException("invalid hosted diagnostics origin", it) } + require(httpOrigin(baseUrl) != null) { "invalid hosted diagnostics origin" } + require(parsed.user == null && parsed.password == null) { "hosted diagnostics origin must not contain userinfo" } + require(parsed.encodedPath.isEmpty() || parsed.encodedPath == "/") { + "hosted diagnostics origin must not contain a path" + } + require(parsed.parameters.isEmpty() && parsed.fragment.isEmpty()) { "invalid hosted diagnostics origin" } + val isLoopback = parsed.host.lowercase() in setOf("localhost", "127.0.0.1", "::1") + require(parsed.protocol == URLProtocol.HTTPS || (parsed.protocol == URLProtocol.HTTP && isLoopback)) { + "hosted diagnostics requires HTTPS (except loopback tests)" + } + return baseUrl.trimEnd('/') +} + +@Serializable +data class HostedDiagnosticsCapabilities( + val status: HostedDiagnosticsAvailability, + @SerialName("collector_id") val collectorId: String, + @SerialName("accepted_schema_versions") val acceptedSchemaVersions: List, + @SerialName("max_bundle_bytes") val maxBundleBytes: Long, + @SerialName("max_manifest_bytes") val maxManifestBytes: Long, + @SerialName("retention_days") val retentionDays: Int, + @SerialName("consent_notice_version") val consentNoticeVersion: Int, +) + +@Serializable +enum class HostedDiagnosticsAvailability { + @SerialName("available") AVAILABLE, + @SerialName("disabled") DISABLED, + @SerialName("storage_unavailable") STORAGE_UNAVAILABLE, +} + +@Serializable +data class HostedDiagnosticsInstallationRequest( + val platform: String, + @SerialName("app_id") val appId: String, + @SerialName("app_version") val appVersion: String, + @SerialName("app_build") val appBuild: String, +) + +@Serializable +data class HostedDiagnosticsInstallationResponse( + @SerialName("installation_id") val installationId: String, + @SerialName("installation_token") val installationToken: String, +) + +@Serializable +data class HostedDiagnosticsCreateReportRequest( + @SerialName("report_id") val reportId: String, + val manifest: DiagnosticsManifest, + @SerialName("bundle_bytes") val bundleBytes: Long, + @SerialName("bundle_sha256") val bundleSha256: String, +) + +@Serializable +data class HostedDiagnosticsCreateReportResponse( + @SerialName("report_id") val reportId: String, + @SerialName("short_id") val shortId: String, + @SerialName("upload_token") val uploadToken: String, + @SerialName("expires_at") val expiresAt: String, +) + +@Serializable +data class HostedDiagnosticsReportStatusResponse( + @SerialName("report_id") val reportId: String, + @SerialName("short_id") val shortId: String? = null, + val state: HostedDiagnosticsReportState, + @SerialName("error_code") val errorCode: String? = null, +) + +@Serializable +enum class HostedDiagnosticsReportState { + @SerialName("receiving") RECEIVING, + @SerialName("uploaded") UPLOADED, + @SerialName("processing") PROCESSING, + @SerialName("ready") READY, + @SerialName("rejected") REJECTED, + @SerialName("deleting") DELETING, + @SerialName("deleted") DELETED, + ; + + val wireValue: String get() = name.lowercase() +} + +sealed interface HostedDiagnosticsApiResult { + data class Success(val value: T) : HostedDiagnosticsApiResult + + data class Failure( + val httpStatus: Int, + val errorCode: String, + val message: String, + val retryAfterSeconds: Long? = null, + ) : HostedDiagnosticsApiResult + + data class NetworkError(val exception: Throwable) : HostedDiagnosticsApiResult +} + +interface HostedDiagnosticsApi { + suspend fun capabilities(): HostedDiagnosticsApiResult + + suspend fun createInstallation( + request: HostedDiagnosticsInstallationRequest, + ): HostedDiagnosticsApiResult + + suspend fun createReport( + installationToken: String, + request: HostedDiagnosticsCreateReportRequest, + ): HostedDiagnosticsApiResult + + suspend fun uploadBundle( + installationToken: String, + reportId: String, + uploadToken: String, + bundle: ByteArray, + ): HostedDiagnosticsApiResult + + suspend fun reportStatus( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult + + suspend fun deleteReport( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult +} + +class DefaultHostedDiagnosticsApi( + private val client: HttpClient, +) : HostedDiagnosticsApi { + override suspend fun capabilities(): HostedDiagnosticsApiResult = request( + expected = HttpStatusCode.OK, + call = { client.get("/v1/capabilities") }, + ) + + override suspend fun createInstallation( + request: HostedDiagnosticsInstallationRequest, + ): HostedDiagnosticsApiResult = request( + expected = HttpStatusCode.Created, + call = { + client.post("/v1/installations") { + contentType(ContentType.Application.Json) + setBody(request) + } + }, + ) + + override suspend fun createReport( + installationToken: String, + request: HostedDiagnosticsCreateReportRequest, + ): HostedDiagnosticsApiResult = request( + expected = HttpStatusCode.Created, + call = { + client.post("/v1/reports") { + bearerAuth(installationToken) + contentType(ContentType.Application.Json) + setBody(request) + } + }, + ) + + override suspend fun uploadBundle( + installationToken: String, + reportId: String, + uploadToken: String, + bundle: ByteArray, + ): HostedDiagnosticsApiResult = try { + val response = client.put("/v1/reports/$reportId/bundle") { + bearerAuth(installationToken) + header(UPLOAD_TOKEN_HEADER, uploadToken) + header(HttpHeaders.ContentLength, bundle.size.toString()) + header(HttpHeaders.ContentType, "application/gzip") + setBody(bundle) + timeout { + requestTimeoutMillis = UPLOAD_TIMEOUT_MS + socketTimeoutMillis = UPLOAD_TIMEOUT_MS + } + } + if (response.status == HttpStatusCode.Accepted) { + try { + HostedDiagnosticsApiResult.Success(response.body()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + HostedDiagnosticsApiResult.Failure( + httpStatus = response.status.value, + errorCode = "invalid_response", + message = "Collector returned an invalid upload receipt", + ) + } + } else { + response.failure() + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + HostedDiagnosticsApiResult.NetworkError(error) + } + + override suspend fun reportStatus( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult = request( + expected = HttpStatusCode.OK, + call = { + client.get("/v1/reports/$reportId") { + bearerAuth(installationToken) + } + }, + ) + + override suspend fun deleteReport( + installationToken: String, + reportId: String, + ): HostedDiagnosticsApiResult = try { + val response = client.delete("/v1/reports/$reportId") { + bearerAuth(installationToken) + } + if (response.status == HttpStatusCode.NoContent) { + HostedDiagnosticsApiResult.Success(Unit) + } else { + response.failure() + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + HostedDiagnosticsApiResult.NetworkError(error) + } + + private suspend inline fun request( + expected: HttpStatusCode, + crossinline call: suspend () -> io.ktor.client.statement.HttpResponse, + ): HostedDiagnosticsApiResult = try { + val response = call() + if (response.status == expected) { + HostedDiagnosticsApiResult.Success(response.body()) + } else { + response.failure() + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + HostedDiagnosticsApiResult.NetworkError(error) + } + + private suspend fun io.ktor.client.statement.HttpResponse.failure(): HostedDiagnosticsApiResult.Failure { + val error = try { + body() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + ApiErrorBody() + } + return HostedDiagnosticsApiResult.Failure( + httpStatus = status.value, + errorCode = error.error.ifBlank { "unknown" }, + message = error.message, + retryAfterSeconds = headers[HttpHeaders.RetryAfter]?.toLongOrNull()?.coerceAtLeast(0), + ) + } + + private companion object { + const val UPLOAD_TOKEN_HEADER = "X-Upload-Token" + const val UPLOAD_TIMEOUT_MS = 300_000L + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt new file mode 100644 index 000000000..1ba0f6576 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/OnboardingApi.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.model.onboarding.OnboardingState +import org.prairieserver.prairie.network.ApiResult + +/** + * Server-driven onboarding tour. All endpoints are profile-scoped — the auth + * interceptor attaches X-Profile-Id, so these are only callable once a + * profile is active. + */ +class OnboardingApi(private val client: HttpClient) { + + /** surface is "phone" or "tv"; the server filters unsuitable steps. */ + suspend fun getFlow(surface: String): ApiResult = safeApiCall { + client.get("/api/v1/onboarding/flow") { + parameter("surface", surface) + } + } + + suspend fun getState(): ApiResult = safeApiCall { + client.get("/api/v1/onboarding/state") + } + + suspend fun postProgress(request: OnboardingProgressRequest): ApiResult = safeApiCall { + client.post("/api/v1/onboarding/progress") { + contentType(ContentType.Application.Json) + setBody(request) + } + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt index 906dc5531..41a4ecd92 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt @@ -7,22 +7,11 @@ import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.model.playback.ProgressRequest -import org.prairieserver.prairie.model.playback.StartPlaybackRequest -import org.prairieserver.prairie.model.playback.TranscodeStartRequest -import org.prairieserver.prairie.model.playback.TranscodeStartResponse import org.prairieserver.prairie.network.ApiResult class PlaybackApi(private val client: HttpClient) { - suspend fun startPlayback(request: StartPlaybackRequest): ApiResult = safeApiCall { - client.post("/api/v1/playback/start") { - contentType(ContentType.Application.Json) - setBody(request) - } - } - suspend fun startPlaybackV3(request: PlaybackStartRequestV3): ApiResult = safeApiCall { client.post("/api/v1/playback/start") { contentType(ContentType.Application.Json) @@ -60,11 +49,4 @@ class PlaybackApi(private val client: HttpClient) { suspend fun stopPlayback(sessionId: String): ApiResult = safeApiCall { client.delete("/api/v1/playback/$sessionId") } - - suspend fun startTranscode(request: TranscodeStartRequest): ApiResult = safeApiCall { - client.post("/api/v1/playback/transcode/start") { - contentType(ContentType.Application.Json) - setBody(request) - } - } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt index ac9f53bcb..314ac0512 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SectionApi.kt @@ -4,6 +4,7 @@ import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* +import org.prairieserver.prairie.model.catalog.CatalogQueryGroup import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.model.section.* import org.prairieserver.prairie.network.ApiErrorBody @@ -67,22 +68,35 @@ class SectionApi(private val client: HttpClient) { /** * Library-collection items via the paginated catalog resolver - * (`source=library_collection`), matching prairie-apple's + * (`source=library_collection`), matching silo-apple's * `libraryCollectionItems`. The raw * `/library/{id}/collections/{id}/items` route serves full membership * in one response — a 10k-item language collection in a single body — * and is being phased out client-side so the server can bound it. + * + * A null [sort] omits both sort params, which is what makes the server + * fall back to the collection's own order (manual / MDBList / smart). */ suspend fun getLibraryCollectionItems( collectionId: String, offset: Int = 0, limit: Int = 60, + sort: String? = null, + order: String? = null, + queryGroups: List = emptyList(), + match: String? = null, ): ApiResult = safeApiCall { client.get("/api/v1/catalog") { parameter("source", "library_collection") parameter("collection_id", collectionId) parameter("offset", offset) parameter("limit", limit) + if (sort != null) { + parameter("sort", sort) + order?.let { parameter("order", it) } + } + match?.let { parameter("match", it) } + catalogQueryGroupParameters(queryGroups) } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt index d16d0f0db..b70d3b47a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/SettingsApi.kt @@ -1,22 +1,33 @@ package org.prairieserver.prairie.network.api +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse import org.prairieserver.prairie.model.settings.EffectiveSettingsResponse import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys import org.prairieserver.prairie.model.settings.SettingEntry +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingValueWriteRequest +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities import org.prairieserver.prairie.model.settings.SettingsListResponse +import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.model.settings.UpdateSettingRequest import org.prairieserver.prairie.network.ApiResult import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.put import io.ktor.client.request.setBody import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Admin-configured card-overlay baseline. `enabled` is the global @@ -31,6 +42,49 @@ data class OverlayConfigResponse( val defaults: String? = null, ) +/** + * Result of probing the canonical settings contract. + * + * A server that predates the canonical settings API has no + * `/api/v1/settings/contract` routes at all, so the probe 404s. That is a + * distinct, actionable state — the UI must say "this server needs an + * upgrade" rather than render an empty settings screen — so it is a typed + * case here instead of dissolving into the generic error path. + */ +sealed class SettingsCapabilitiesResult { + /** The server speaks the canonical settings API. */ + data class Available( + val capabilities: SettingsContractCapabilities, + ) : SettingsCapabilitiesResult() + + /** + * The connected server does not serve `/api/v1/settings/contract` + * (HTTP 404): it is too old for the canonical settings API. + */ + data object ServerUpgradeRequired : SettingsCapabilitiesResult() + + /** Any other HTTP failure, with the server's error body when present. */ + data class Error( + val code: Int, + val error: String, + val message: String, + ) : SettingsCapabilitiesResult() + + /** The request never reached the server. */ + data class NetworkError(val exception: Throwable) : SettingsCapabilitiesResult() +} + +/** + * A fresh idempotency key for one settings write. + * + * Generate one per logical write and hold it across retries: the server + * replays the recorded receipt for a repeated id with identical content, and + * rejects the id with 409 `mutation_id_conflict` when it was used for + * different content. Generating a new id per retry would defeat both. + */ +@OptIn(ExperimentalUuidApi::class) +fun newSettingMutationId(): String = Uuid.random().toString() + open class SettingsApi(private val client: HttpClient) { open suspend fun getSettings(): ApiResult = safeApiCall { @@ -101,4 +155,139 @@ open class SettingsApi(private val client: HttpClient) { open suspend fun deleteDeviceSubtitleAppearanceOverride(): ApiResult = deleteDeviceSetting(PlaybackSettingsKeys.SubtitleAppearance) + + // ------------------------------------------------------------------ + // Canonical settings API (/settings/contract, /settings/values/*). + // Typed JSON values with explicit scopes; the endpoints above speak the + // legacy string-only registry and remain for not-yet-migrated call sites. + // ------------------------------------------------------------------ + + /** + * What the connected server's settings contract supports, or + * [SettingsCapabilitiesResult.ServerUpgradeRequired] when the server + * predates the canonical settings API entirely. + * + * Not every 404 on this path means an old server. The route sits behind + * the viewer-access middleware, which answers a JSON + * `{"error":"not_found"}` when the `X-Profile-Id` this client sends names + * a profile the household deleted elsewhere. Telling the user their + * server is too old — and to go ask its admin — when the real fix is + * re-selecting a profile is worse than saying nothing, so the two are + * separated on the wire: a server with no `/settings/contract` routes + * falls through to the router's plain-text `404 page not found`, which + * leaves the parsed error code empty. + */ + open suspend fun getContractCapabilities(): SettingsCapabilitiesResult = + when (val result = safeApiCall { + client.get("/api/v1/settings/contract/capabilities") + }) { + is ApiResult.Success -> SettingsCapabilitiesResult.Available(result.data) + is ApiResult.Error -> + if (result.code == HttpStatusCode.NotFound.value && result.error.isEmpty()) { + SettingsCapabilitiesResult.ServerUpgradeRequired + } else { + SettingsCapabilitiesResult.Error(result.code, result.error, result.message) + } + is ApiResult.NetworkError -> SettingsCapabilitiesResult.NetworkError(result.exception) + } + + /** + * Resolve settings the way the server does, including the scope each + * answer came from. + * + * Batched on purpose: one request serves a whole settings screen or a + * season view spanning several series. Passing no [keys] resolves every + * remote definition in the server's contract. [libraryIds] and + * [seriesIds] widen the resolution to those content scopes; the profile + * and device parts of the context come from the session headers the auth + * interceptor already attaches. + */ + open suspend fun getEffectiveValues( + keys: List = emptyList(), + libraryIds: List = emptyList(), + seriesIds: List = emptyList(), + ): ApiResult = safeApiCall { + client.get("/api/v1/settings/values/effective") { + url { + if (keys.isNotEmpty()) parameters.append("keys", keys.joinToString(",")) + if (libraryIds.isNotEmpty()) { + parameters.append("library_ids", libraryIds.joinToString(",")) + } + if (seriesIds.isNotEmpty()) { + parameters.append("series_ids", seriesIds.joinToString(",")) + } + } + } + } + + /** + * Write one typed value at one scope. + * + * [mutationId] (sent as `X-Prairie-Mutation-Id`) makes retries safe: create + * it once per logical write with [newSettingMutationId] and reuse it for + * every retry of that write. A retry the server already applied replays + * the recorded receipt instead of re-applying; reusing an id for + * *different* content fails with 409 `mutation_id_conflict`. + * + * A value that exceeds a policy restriction is stored, not rejected — the + * restriction caps it at resolution time — so a 200 receipt does not mean + * the value is what playback will use. Resolve via [getEffectiveValues] + * for that. + */ + open suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String? = null, + ): ApiResult = safeApiCall { + client.put("/api/v1/settings/values/$key") { + applyScopeIdentity(scope, profileId) + if (mutationId.isNotBlank()) { + header("X-Prairie-Mutation-Id", mutationId) + } + contentType(ContentType.Application.Json) + setBody(SettingValueWriteRequest(value)) + } + } + + /** + * Clear the explicit value at one scope, so the setting inherits again. + * + * 204 on success; 404 `not_found` when nothing was set there, which a + * retrying caller should treat as already done. + */ + open suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String? = null, + ): ApiResult = safeApiCall { + client.delete("/api/v1/settings/values/$key") { + applyScopeIdentity(scope, profileId) + } + } + + /** + * Applies the parts of a scope identity that travel with the request. + * + * Scope and the content ids go in the query. The profile normally rides + * the session's `X-Profile-Id` header; an explicit [profileId] overrides + * it (the interceptor only fills the header when absent), matching how + * [setDeviceSetting] lets a parent act for a child profile. The device id + * is never set here — the interceptor always attaches + * `X-Prairie-Device-Id`, and appending it again would send two values. + */ + private fun HttpRequestBuilder.applyScopeIdentity( + scope: SettingScopeIdentity, + profileId: String?, + ) { + url { + parameters.append("scope", scope.scope.wire) + scope.libraryId?.let { parameters.append("library_id", it.toString()) } + scope.seriesId?.let { parameters.append("series_id", it) } + } + if (!profileId.isNullOrBlank() && scope.scope != SettingScope.ACCOUNT) { + header("X-Profile-Id", profileId) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt index 25bff0f9d..11c7cfc42 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/WatchTogetherApi.kt @@ -29,7 +29,7 @@ import io.ktor.http.encodeURLPathPart * second token (the **room JWT**) distinct from the auth JWT; every * room-scoped call passes it as the `room_token` query param. Behind an * interface so the repository's tests fake the transport (matching - * NotificationsApi/AdminApi). + * NotificationsApi). * * The room WS is a separate transport (see WatchTogetherRealtimeClient); this * is REST only. 204 (close) maps to Unit; 409 (vote dup / not-voted) and 410 diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.kt new file mode 100644 index 000000000..710ee878e --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayLanguageName.kt @@ -0,0 +1,10 @@ +package org.prairieserver.prairie.overlays + +/** + * English display name for a language tag, matching web's `formatLanguage` + * (English CLDR names, so "en" → "English" not "EN") and the Apple + * clients' `formatLanguageName`. A tag the platform can't name falls back + * to the uppercased tag rather than web's "Unknown language (…)" sentence, + * which doesn't fit a badge. + */ +internal expect fun overlayLanguageName(tag: String): String? diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayRegistry.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayRegistry.kt index dc4998256..955f8fb48 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayRegistry.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayRegistry.kt @@ -90,13 +90,23 @@ object OverlayRegistry { return if (value.contains("DV")) "DV" else "HDR" } + // Mirrors web's `hdrIcon`: only exact wordmark matches get a brand + // mark; anything else (HLG, combined strings like "HDR10+") renders + // as plain text with no icon rather than a wrong generic mark. private fun hdrIcon(value: String?): OverlayIconId? { if (value.isNullOrEmpty()) return null if (value.contains("DV")) return OverlayIconId.DolbyVision - if (value.contains("HDR10")) return OverlayIconId.Hdr10 - return OverlayIconId.Hdr + if (value == "HDR10") return OverlayIconId.Hdr10 + if (value == "HDR") return OverlayIconId.Hdr + return null } + // The combined badge's label already carries an "HDR" suffix, so the + // only icon worth doubling up is the Dolby Vision mark — mirrors + // web's `resolution_hdr.getIcon`. + private fun resolutionHdrIcon(value: String?): OverlayIconId? = + if (value != null && value.contains("DV")) OverlayIconId.DolbyVision else null + private fun audioIcon(value: String?): OverlayIconId? { if (value.isNullOrEmpty()) return null // `contains` instead of equality so "TrueHD Atmos" still picks the @@ -119,7 +129,9 @@ object OverlayRegistry { defaultEnabled = true, iconId = OverlayIconId.Monitor, iconCapable = true, - getValue = { it.resolution?.uppercase() }, + // `prettyResolution`, not raw uppercase: web renders "4K" for a + // `2160p` payload and the standalone badge must match it. + getValue = { prettyResolution(it.resolution) }, ), OverlayDef( id = OverlayId.Hdr, @@ -145,7 +157,7 @@ object OverlayRegistry { val hdr = compactHdrSuffix(data.hdr) if (hdr != null) "$res $hdr" else res }, - getIcon = { hdrIcon(it.hdr) }, + getIcon = { resolutionHdrIcon(it.hdr) }, ), OverlayDef( id = OverlayId.Audio, @@ -368,7 +380,7 @@ object OverlayRegistry { defaultEnabled = false, iconId = OverlayIconId.Globe, iconCapable = true, - getValue = { it.originalLanguage?.uppercase() }, + getValue = { data -> data.originalLanguage?.let { overlayLanguageName(it) } }, ), OverlayDef( id = OverlayId.Studio, @@ -396,13 +408,18 @@ object OverlayRegistry { // MARK: - Ribbons helpers + // Mirrors web's `formatShowStatus` — the recognized spellings and the + // pass-through default must stay identical or the same library renders + // different ribbons per platform. private fun formatShowStatus(value: String?): String? { - if (value == null) return null - return when (value.lowercase()) { - "returning", "returning series", "in_production", "in production" -> "Returning" + val normalized = value?.trim().takeUnless { it.isNullOrEmpty() } ?: return null + return when (normalized.lowercase()) { + "returning", "returning series", "continuing", "in_production", "in production" -> + "Returning" "ended" -> "Ended" "cancelled", "canceled" -> "Cancelled" - else -> value + "upcoming", "planned" -> "Upcoming" + else -> normalized } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt index 065d4a64d..cee4b8422 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlaySchema.kt @@ -15,8 +15,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** - * Encodes/decodes [CardOverlayPrefs] to/from the JSON string the server - * stores under the `card_overlays` user setting. The shape MUST stay + * Encodes/decodes [CardOverlayPrefs] to/from the JSON object stored in the + * canonical `ui.card_overlays` profile setting. The shape MUST stay * compatible with web's `parseOverlayPrefs`, iOS `OverlaySchema`, and * tvOS, since clients share the setting. * @@ -55,7 +55,7 @@ object OverlaySchema { } /** - * Parse a JSON string into a fully populated [CardOverlayPrefs]. + * Parse a JSON object string into a fully populated [CardOverlayPrefs]. * Unknown overlay IDs and malformed entries are dropped — the * remaining fields fall back to registry defaults so the user's real * overrides survive a schema upgrade. V1 docs (flat diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt index cd1c76b48..53b65f63e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/overlays/OverlayTypes.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable /** * Stable identifiers for every overlay the system knows about. The wire - * format (server `defaults.card_overlays`, user `card_overlays`) uses + * format (server `defaults.card_overlays`, profile `ui.card_overlays`) uses * these as keys, so they MUST stay in sync with web's `OverlayId`, * iOS `OverlayId`, and tvOS. Adding a new overlay requires updating the * registry; renaming an existing one is a breaking change for stored @@ -153,9 +153,9 @@ data class OverlayItemConfig( /** * Versioned root document stored under the user setting key - * `card_overlays`. Serialized as a JSON string and PUT to - * `/api/v1/settings/card_overlays`. Shared across web, iOS, tvOS, and - * Android. + * `ui.card_overlays`. Serialized as a typed JSON object and PUT to + * `/api/v1/settings/values/ui.card_overlays?scope=profile`. Shared across + * web, iOS, tvOS, and Android. */ data class CardOverlayPrefs( val version: Int, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt index db8b7d671..b1f4bfdc8 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdate.kt @@ -7,15 +7,18 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull /** - * Parsed `markers_updated` server event: the server recomputed intro/credits - * ranges for a file (e.g. detection finished mid-playback) and the player should - * adopt the fresh values so skip-intro and the credits-based auto-advance use - * them. Only [intro] and [credits] are surfaced — recap/preview have no client - * feature yet. A `null` range means "no such marker" (the server clears it). + * Parsed `markers_updated` server event: the server recomputed marker ranges + * for a file (e.g. detection finished mid-playback) and the player should adopt + * the fresh values so skip-intro, the credits-based auto-advance, and the + * timeline marker bands use them. All four marker kinds are surfaced; only + * [intro] drives auto-skip and only [credits] drives auto-advance. A `null` + * range means "no such marker" (the server clears it). */ data class PlaybackMarkersUpdate( val intro: TimeRange?, val credits: TimeRange?, + val recap: TimeRange?, + val preview: TimeRange?, ) /** @@ -33,7 +36,12 @@ fun decodeMarkersUpdate(payload: JsonObject): PlaybackMarkersUpdate { val end = num("end") ?: return null return TimeRange(start = start, end = end) } - return PlaybackMarkersUpdate(intro = range("intro"), credits = range("credits")) + return PlaybackMarkersUpdate( + intro = range("intro"), + credits = range("credits"), + recap = range("recap"), + preview = range("preview"), + ) } /** diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentity.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentity.kt new file mode 100644 index 000000000..fe1dd7bb0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentity.kt @@ -0,0 +1,196 @@ +package org.prairieserver.prairie.playback + +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_BURN_IN_ONLY +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR +import org.prairieserver.prairie.model.playback.SUBTITLE_SOURCE_DOWNLOADED +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle + +const val DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX = + "silo-downloaded-subtitle:" + +private val HEARING_IMPAIRED_TOKEN_REGEX = + Regex("""(^|[^a-z0-9])(cc|sdh)([^a-z0-9]|$)""") + +/** + * Builds the canonical typed identity for a subtitle row on both Android + * clients. Protocol-v3 server identity wins over local download metadata. + */ +fun playbackSubtitleIdentity(subtitle: PlayerSubtitleInfo): SubtitleIdentity { + val source = subtitle.source?.trim()?.lowercase() + val catalogSource = subtitle.catalogSource?.trim()?.lowercase() + val media = SubtitleMediaIdentity( + trackId = subtitle.serverTrackId + ?: subtitle.downloadId?.let(::downloadedSubtitleArtifactTrackId) + ?: subtitle.mediaTrackId, + label = subtitle.catalogLabel ?: subtitle.label, + language = canonicalSubtitleLanguage(subtitle.language), + codecFamily = canonicalSubtitleCodecFamily( + subtitle.codec ?: subtitle.url.subtitleCodecFromUrl(), + ), + forced = subtitle.forced, + hearingImpaired = subtitleLabelIndicatesHearingImpaired( + subtitle.catalogLabel ?: subtitle.label, + ).takeIf { it }, + ) + + when (subtitle.serverDelivery) { + SUBTITLE_DELIVERY_BURN_IN_ONLY -> + return SubtitleIdentity.ServerBurnIn(subtitle.index, media) + SUBTITLE_DELIVERY_SIDECAR -> + return SubtitleIdentity.ServerSidecar(subtitle.index, media) + } + if (subtitle.isLocalDownloadedSubtitle()) { + return subtitle.downloadId + ?.let { SubtitleIdentity.Downloaded(it, media) } + ?: SubtitleIdentity.LocalMedia3(media) + } + + val embedded = subtitle.url.isBlank() && + (source == "embedded" || (source == null && catalogSource == "embedded")) + if (embedded) { + return if ( + isBitmapSubtitleCodecFamily(media.codecFamily) && + !isClientMountableBitmapCodecFamily(media.codecFamily) + ) { + SubtitleIdentity.ServerBurnIn(subtitle.index, media) + } else { + SubtitleIdentity.Embedded(subtitle.index, media) + } + } + + val external = source == "external" || + catalogSource == "external" || + source == "server_artifact" || + subtitle.url.isNotBlank() + val mountableBitmapArtifact = subtitle.url.isNotBlank() && + isClientMountableBitmapCodecFamily(media.codecFamily) + return if ( + external && + isBitmapSubtitleCodecFamily(media.codecFamily) && + !mountableBitmapArtifact + ) { + SubtitleIdentity.ServerBurnIn(subtitle.index, media) + } else { + SubtitleIdentity.ServerSidecar(subtitle.index, media) + } +} + +/** + * Resolves a persisted local-download identity against the current row list. + * + * New rows retain the durable database id and match it exactly. A legacy + * synthetic Media3 id is not a protocol identity, so it is removed before a + * unique metadata match against authoritative downloaded inventory. Negative + * booleans are also treated as unknown: by themselves they are not enough to + * identify a track, and older projections did not preserve them consistently. + */ +fun resolveDownloadedSubtitlePreferenceOrdinal( + identity: SubtitleIdentity.Downloaded, + subtitles: List, +): Int? { + val directMatches = subtitles.indices.filter { index -> + subtitles[index].downloadId == identity.downloadId + } + if (directMatches.size == 1) return directMatches.single() + if (directMatches.size > 1) return null + + val syntheticTrackId = downloadedSubtitleArtifactTrackId(identity.downloadId) + val isLegacySyntheticIdentity = identity.media.trackId == syntheticTrackId + val expected = identity.media.copy( + trackId = identity.media.trackId.takeUnless { isLegacySyntheticIdentity }, + forced = identity.media.forced.takeUnless { + isLegacySyntheticIdentity && it == false + }, + hearingImpaired = identity.media.hearingImpaired.takeUnless { + isLegacySyntheticIdentity && it == false + }, + ) + if (!expected.hasPositiveSubtitleDiscriminator()) return null + + return subtitles.indices.filter { index -> + val row = subtitles[index] + if ( + row.serverTrackId.isNullOrBlank() || + row.serverDelivery.isNullOrBlank() || + !row.source.equals(SUBTITLE_SOURCE_DOWNLOADED, ignoreCase = true) + ) { + return@filter false + } + val actual = playbackSubtitleIdentity(row).subtitleMediaIdentityOrNull() + ?: return@filter false + actual.matchesSubtitleMediaIdentity(expected) + }.singleOrNull() +} + +fun SubtitleIdentity.subtitleMediaIdentityOrNull(): SubtitleMediaIdentity? = when (this) { + is SubtitleIdentity.ServerSidecar -> media + is SubtitleIdentity.ServerBurnIn -> media + is SubtitleIdentity.Embedded -> media + is SubtitleIdentity.Downloaded -> media + is SubtitleIdentity.LocalMedia3 -> media + SubtitleIdentity.Off -> null +} + +fun SubtitleMediaIdentity.matchesSubtitleMediaIdentity( + expected: SubtitleMediaIdentity, +): Boolean { + val expectedTrackId = expected.trackId.normalizedSubtitleValue() + if (expectedTrackId != null && trackId.normalizedSubtitleValue() != expectedTrackId) { + return false + } + val expectedLabel = expected.label.normalizedSubtitleValue()?.lowercase() + if (expectedLabel != null && label.normalizedSubtitleValue()?.lowercase() != expectedLabel) { + return false + } + val expectedLanguage = canonicalSubtitleLanguage(expected.language) + if (expectedLanguage != null && canonicalSubtitleLanguage(language) != expectedLanguage) { + return false + } + val expectedCodec = canonicalSubtitleCodecFamily(expected.codecFamily) + if (expectedCodec != null && canonicalSubtitleCodecFamily(codecFamily) != expectedCodec) { + return false + } + if (expected.forced != null && forced != expected.forced) return false + if ( + expected.hearingImpaired != null && + hearingImpaired != expected.hearingImpaired + ) { + return false + } + return true +} + +fun SubtitleMediaIdentity.hasPositiveSubtitleDiscriminator(): Boolean = + !trackId.isNullOrBlank() || + !label.isNullOrBlank() || + canonicalSubtitleLanguage(language) != null || + canonicalSubtitleCodecFamily(codecFamily) != null || + forced == true || + hearingImpaired == true + +fun downloadedSubtitleArtifactTrackId(downloadId: Int): String = + "$DOWNLOADED_SUBTITLE_ARTIFACT_TRACK_ID_PREFIX$downloadId" + +private fun String.subtitleCodecFromUrl(): String? = + substringBefore('?') + .substringBefore('#') + .substringAfterLast('.', missingDelimiterValue = "") + .takeIf(String::isNotBlank) + +fun subtitleLabelIndicatesHearingImpaired(label: String?): Boolean { + val value = label?.lowercase() ?: return false + if ( + value.contains("closed caption") || + value.contains("hearing impaired") || + value.contains("hearing-impaired") + ) { + return true + } + return HEARING_IMPAIRED_TOKEN_REGEX.containsMatchIn(value) +} + +private fun String?.normalizedSubtitleValue(): String? = + this?.trim()?.takeIf(String::isNotBlank) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt new file mode 100644 index 000000000..2eb7bf5c2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReady.kt @@ -0,0 +1,81 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3 +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_BURN_IN_ONLY +import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR +import org.prairieserver.prairie.model.playback.SUBTITLE_SOURCE_DOWNLOADED +import org.prairieserver.prairie.network.PlaybackRealtimeEvent +import org.prairieserver.prairie.network.PrairieJson + +/** Exact authoritative inventory addition carried by `subtitle_ready`. */ +data class PlaybackSubtitleReady( + val sessionId: String?, + val mediaFileId: Int?, + val subtitleId: Int?, + val track: PlaybackSubtitleInventoryItemV3?, +) + +fun decodePlaybackSubtitleReady(event: PlaybackRealtimeEvent.ServerEvent): PlaybackSubtitleReady = + decodePlaybackSubtitleReady(event.payload) + +fun decodePlaybackSubtitleReady(payload: JsonObject): PlaybackSubtitleReady { + val track = payload["track"]?.let { element -> + runCatching { + PrairieJson.decodeFromJsonElement(PlaybackSubtitleInventoryItemV3.serializer(), element) + }.getOrNull() + } + return PlaybackSubtitleReady( + sessionId = (payload["session_id"] as? JsonPrimitive)?.contentOrNull, + mediaFileId = (payload["file_id"] as? JsonPrimitive)?.intOrNull, + subtitleId = (payload["subtitle_id"] as? JsonPrimitive)?.intOrNull, + track = track, + ) +} + +/** + * Applies a server-supplied inventory row without manufacturing an ordinal, + * identity, delivery mode, or URL. Returns null when the event is malformed or + * would introduce a gap, in which case the caller must obtain a fresh plan. + */ +fun applyAuthoritativeSubtitleReadyTrack( + existing: List, + update: PlaybackSubtitleReady, +): List? { + val item = update.track ?: return null + if (item.trackId.isBlank() || item.combinedIndex < 0) return null + val validDelivery = when (item.delivery) { + SUBTITLE_DELIVERY_SIDECAR -> !item.url.isNullOrBlank() + SUBTITLE_DELIVERY_BURN_IN_ONLY -> item.url.isNullOrBlank() + else -> false + } + if (!validDelivery) return null + + val previous = existing.firstOrNull { + it.serverTrackId == item.trackId || it.index == item.combinedIndex + } + val replacement = PlayerSubtitleInfo( + index = item.combinedIndex, + language = item.language, + codec = item.codec, + label = item.label, + source = item.source, + forced = item.forced, + url = item.url.orEmpty(), + catalogLabel = previous?.catalogLabel ?: item.label, + catalogSource = previous?.catalogSource ?: item.source, + isDefault = item.isDefault, + downloadId = update.subtitleId.takeIf { item.source == SUBTITLE_SOURCE_DOWNLOADED }, + serverTrackId = item.trackId, + serverDelivery = item.delivery, + ) + val rows = (existing.filterNot { + it.serverTrackId == item.trackId || it.index == item.combinedIndex + } + replacement).sortedBy(PlayerSubtitleInfo::index) + if (rows.map(PlayerSubtitleInfo::index) != rows.indices.toList()) return null + return rows +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt index 6fd7d0996..3f210762b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleCodecFamily.kt @@ -40,6 +40,13 @@ fun isTextSubtitleCodecFamily(family: String?): Boolean = else -> false } +/** Whether [family] is an image-based subtitle codec or MIME alias. */ +fun isBitmapSubtitleCodecFamily(family: String?): Boolean = + when (canonicalSubtitleCodecFamily(family)) { + "pgs", "vobsub", "dvbsub" -> true + else -> false + } + /** * Whether the server can hand this bitmap family to the client as a sidecar * instead of burning it into the picture. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt index 9b8228dfc..11aa37a61 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/SubtitleLanguage.kt @@ -8,6 +8,19 @@ package org.prairieserver.prairie.playback * ISO 639-1 tags. Region/script suffixes do not identify a different subtitle * artifact for the selection fallback, so matching uses the primary language. */ +/** + * A resolved subtitle preference, with "" collapsed back to null. + * + * The two representations mean the same thing in the settings store — the + * contract spells "no preference" as JSON null, the store spells it as the + * empty string — but they mean opposite things to subtitle auto-selection: a + * blank-but-present language is read as an explicit "off", while null means + * "nothing chosen, decide normally". Any preference crossing from settings + * into playback goes through here so the store's spelling cannot be mistaken + * for a user's choice. + */ +fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() } + fun canonicalSubtitleLanguage(language: String?): String? { val primary = language ?.trim() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt index 05cb33ef3..cc309a8b0 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprint.kt @@ -287,14 +287,6 @@ private fun String?.isCatalogBitmapSubtitle(): Boolean { normalized.contains("vobsub") } -private fun catalogLabelIndicatesHearingImpaired(label: String): Boolean { - val normalized = label.lowercase() - return normalized.contains("closed caption") || - normalized.contains("hearing impaired") || - normalized.contains("hearing-impaired") || - Regex("""(^|[^a-z0-9])(cc|sdh|hi)([^a-z0-9]|$)""").containsMatchIn(normalized) -} - private fun SubtitleTrack.catalogMediaIdentity(): SubtitleMediaIdentity = SubtitleMediaIdentity( label = title, @@ -302,7 +294,7 @@ private fun SubtitleTrack.catalogMediaIdentity(): SubtitleMediaIdentity = codecFamily = canonicalSubtitleCodecFamily(codec), forced = forced, hearingImpaired = title - ?.takeIf(::catalogLabelIndicatesHearingImpaired) + ?.takeIf(::subtitleLabelIndicatesHearingImpaired) ?.let { true }, ) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AdminRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AdminRepository.kt deleted file mode 100644 index 32eec03ad..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AdminRepository.kt +++ /dev/null @@ -1,103 +0,0 @@ -// shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AdminRepository.kt -package org.prairieserver.prairie.repository - -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.api.AdminApi - -/** - * Thin pass-through over [AdminApi] for the mobile admin sub-screens and the - * TV stats dashboard. Stateless (no cached flows): each admin screen owns its - * ViewModel state and refreshes via pull-to-refresh, so the repository just - * surfaces the typed [ApiResult] from the transport. - * - * Every admin route is gated server-side on acting-admin; the UI gates entry - * with [org.prairieserver.prairie.model.auth.isActingAdmin]. - * - * NOTE: [triggerScan]/[cancelScan] hit `/api/v1/libraries/scan[/cancel]`, NOT - * `/admin/` routes (the scan endpoints live on the libraries handler server-side). - * They are exposed here so the admin "Scans" sub-screen has a single - * repository dependency. - */ -class AdminRepository(private val api: AdminApi) { - - suspend fun getStats(refresh: Boolean = false): ApiResult = - api.getStats(refresh) - - suspend fun getUsers(): ApiResult> = api.getUsers() - - suspend fun getUser(id: Int): ApiResult = api.getUser(id) - - suspend fun createUser(request: CreateUserRequest): ApiResult = - api.createUser(request) - - suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult = - api.updateUser(id, request) - - suspend fun deleteUser(id: Int): ApiResult = api.deleteUser(id) - - suspend fun getSessions(): ApiResult> = api.getSessions() - - suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest = SessionControlRequest(), - ): ApiResult = api.sessionControl(sessionId, action, request) - - suspend fun getAppLogs( - level: String? = null, - component: String? = null, - nodeId: String? = null, - requestId: String? = null, - sessionId: String? = null, - playbackSessionId: String? = null, - userId: Int? = null, - from: String? = null, - to: String? = null, - query: String? = null, - cursor: String? = null, - limit: Int = 100, - ): ApiResult = api.getAppLogs( - level, component, nodeId, requestId, sessionId, playbackSessionId, - userId, from, to, query, cursor, limit, - ) - - suspend fun getAuditLogs( - method: String? = null, - pathPrefix: String? = null, - statusCode: Int? = null, - clientIp: String? = null, - requestId: String? = null, - sessionId: String? = null, - playbackSessionId: String? = null, - userId: Int? = null, - from: String? = null, - to: String? = null, - cursor: String? = null, - limit: Int = 100, - ): ApiResult = api.getAuditLogs( - method, pathPrefix, statusCode, clientIp, requestId, sessionId, - playbackSessionId, userId, from, to, cursor, limit, - ) - - /** POST /api/v1/libraries/scan (not /admin) — see class KDoc. */ - suspend fun triggerScan(request: ScanRequest): ApiResult = - api.triggerScan(request) - - /** POST /api/v1/libraries/scan/cancel (not /admin) — see class KDoc. */ - suspend fun cancelScan(request: ScanCancelRequest): ApiResult = - api.cancelScan(request) -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt index 7de4e058f..9f0464fd4 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/AuthRepository.kt @@ -1,6 +1,6 @@ package org.prairieserver.prairie.repository -import org.prairieserver.prairie.model.auth.AuthSession +import org.prairieserver.prairie.model.auth.InvitationLookupResponse import org.prairieserver.prairie.model.auth.LoginResponse import org.prairieserver.prairie.model.auth.LoginRequest import org.prairieserver.prairie.model.auth.SetupStatusResponse @@ -11,6 +11,7 @@ import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.ServerRegistry import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.BrandingApi import org.prairieserver.prairie.network.api.HealthApi import org.prairieserver.prairie.network.map @@ -19,17 +20,24 @@ class AuthRepository( private val tokenManager: TokenManager, private val serverRegistry: ServerRegistry? = null, private val healthApi: HealthApi? = null, + private val brandingApi: BrandingApi? = null, ) { /** - * Logs in with username and password. - * On success, persists tokens via [TokenManager] and returns the [User]. + * Persists a successful auth response's tokens into the active server's + * scope and unwraps the [User] — the shared tail of every path that ends + * a signed-out state (login, signup, setup, invitation claim). */ - suspend fun login(username: String, password: String): ApiResult { - val result = authApi.login(LoginRequest(username = username, password = password)) - return when (result) { + private suspend fun persistSession( + result: ApiResult, + targetServerId: String? = null, + targetServerUrl: String? = null, + ): ApiResult = + when (result) { is ApiResult.Success -> { val data = result.data - tokenManager.saveTokens( + tokenManager.replaceAccountSession( + serverId = targetServerId ?: tokenManager.getCurrentServerId(), + serverUrl = targetServerUrl, accessToken = data.accessToken, refreshToken = data.refreshToken, expiresIn = data.expiresIn, @@ -39,7 +47,13 @@ class AuthRepository( is ApiResult.Error -> result is ApiResult.NetworkError -> result } - } + + /** + * Logs in with username and password. + * On success, persists tokens via [TokenManager] and returns the [User]. + */ + suspend fun login(username: String, password: String): ApiResult = + persistSession(authApi.login(LoginRequest(username = username, password = password))) /** * Credential login without persistence. TV keeps QR and password sign-in @@ -59,27 +73,16 @@ class AuthRepository( password: String, inviteCode: String, ): ApiResult { - val result = authApi.signup( - SignupRequest( - username = username, - email = email, - password = password, - inviteCode = inviteCode, + return persistSession( + authApi.signup( + SignupRequest( + username = username, + email = email, + password = password, + inviteCode = inviteCode, + ), ), ) - return when (result) { - is ApiResult.Success -> { - val data = result.data - tokenManager.saveTokens( - accessToken = data.accessToken, - refreshToken = data.refreshToken, - expiresIn = data.expiresIn, - ) - ApiResult.Success(data.user) - } - is ApiResult.Error -> result - is ApiResult.NetworkError -> result - } } /** @@ -90,22 +93,7 @@ class AuthRepository( username: String, email: String, password: String, - ): ApiResult { - val result = authApi.setup(username, email, password) - return when (result) { - is ApiResult.Success -> { - val data = result.data - tokenManager.saveTokens( - accessToken = data.accessToken, - refreshToken = data.refreshToken, - expiresIn = data.expiresIn, - ) - ApiResult.Success(data.user) - } - is ApiResult.Error -> result - is ApiResult.NetworkError -> result - } - } + ): ApiResult = persistSession(authApi.setup(username, email, password)) /** Checks whether the server requires initial setup. */ suspend fun getSetupStatus(): ApiResult = @@ -114,6 +102,43 @@ class AuthRepository( suspend fun getSetupStatus(serverUrl: String): ApiResult = authApi.getSetupStatus(serverUrl) + /** + * Resolves an emailed-invitation claim token against a server the app is + * not signed into yet. + */ + suspend fun lookupInvitation( + serverUrl: String, + token: String, + ): ApiResult = authApi.lookupInvitation(serverUrl, token) + + /** + * Accepts an emailed invitation: the account is created with the + * invitation's email as username, tokens are persisted, and the new + * [User] is returned — same post-conditions as [signup]. + * + * The claim request goes to [serverUrl] directly (it needs no auth), and + * the app only adopts that server as active once the claim has actually + * succeeded. Switching first would strand a user whose claim fails — + * expired token, already used, network error — on a server they have no + * account on, with their previous session no longer active. + */ + suspend fun acceptInvitation( + serverUrl: String, + token: String, + password: String, + ): ApiResult { + val result = authApi.acceptInvitation(serverUrl, token, password) + if (result !is ApiResult.Success) return persistSession(result) + val targetServerId = serverRegistry?.addOrUpdate(serverUrl) + val persisted = persistSession( + result = result, + targetServerId = targetServerId, + targetServerUrl = serverUrl.takeIf { serverRegistry == null }, + ) + if (persisted is ApiResult.Success) refreshActiveServerName() + return persisted + } + /** Checks whether public signups are enabled. */ suspend fun getSignupStatus(): ApiResult = authApi.getSignupStatus() @@ -135,22 +160,10 @@ class AuthRepository( try { authApi.logout() } finally { - val activeId = tokenManager.getCurrentServerId() tokenManager.signOutCurrentServer() - if (activeId != null) { - serverRegistry?.signOut(activeId) - } } } - /** Lists active sessions for the current user. */ - suspend fun getSessions(): ApiResult> = - authApi.getSessions().map { it.sessions } - - /** Revokes a specific session by ID. */ - suspend fun deleteSession(id: String): ApiResult = - authApi.revokeSession(id) - /** Returns true when a refresh token is present (user has previously logged in). */ suspend fun isLoggedIn(): Boolean = tokenManager.getRefreshToken() != null @@ -179,20 +192,26 @@ class AuthRepository( } /** - * Best-effort: hit `/api/v1/health` and update the active registry entry's - * fetched name. Quietly no-ops if there's no registry, no active server, - * or the call fails — this is purely for nicer UX in the server list. + * Best-effort: read the native branding identity and update the active + * registry entry's fetched name. Health is a fallback for older servers + * without the branding endpoint. Quietly no-ops if no usable name can be + * resolved — this is purely for nicer UX in the server list. */ suspend fun refreshActiveServerName() { val registry = serverRegistry ?: return - val api = healthApi ?: return val activeId = registry.activeServerId.value ?: return - val result = api.checkHealth() - if (result is ApiResult.Success && registry.activeServerId.value == activeId) { - result.data.serverName - ?.trim() - ?.takeIf { it.isNotBlank() } - ?.let { registry.setFetchedName(activeId, it) } + val brandingName = (brandingApi?.getBranding() as? ApiResult.Success) + ?.data + ?.serverName + .usableServerName() + val resolvedName = brandingName ?: (healthApi?.checkHealth() as? ApiResult.Success) + ?.data + ?.serverName + .usableServerName() + if (resolvedName != null && registry.activeServerId.value == activeId) { + registry.setFetchedName(activeId, resolvedName) } } + + private fun String?.usableServerName(): String? = this?.trim()?.takeIf { it.isNotBlank() } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt index 1b697d11e..c5c89188a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/CatalogRepository.kt @@ -11,8 +11,11 @@ import org.prairieserver.prairie.model.catalog.Person import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.model.catalog.WatchDetail import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.canServeCache @@ -20,6 +23,7 @@ class CatalogRepository( private val catalogApi: CatalogApi, /** Offline read cache for a library's default first page (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) { /** Browse the catalog with optional filters, sorting, and pagination. */ suspend fun browse( @@ -40,6 +44,7 @@ class CatalogRepository( queryGroups: List = emptyList(), match: String? = null, ): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getCatalog( source = source, query = query, @@ -71,7 +76,9 @@ class CatalogRepository( } ?: return result if (result is ApiResult.Success) { - catalogCache.cacheDefaultLibraryPage(cacheableLibraryId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheDefaultLibraryPage(cacheableLibraryId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -84,8 +91,15 @@ class CatalogRepository( suspend fun getFilters( libraryId: Int? = null, includeTechnical: Boolean = false, + source: String? = null, + collectionId: String? = null, ): ApiResult = - catalogApi.getFilters(libraryId, includeTechnical) + catalogApi.getFilters( + libraryId = libraryId, + includeTechnical = includeTechnical, + source = source, + collectionId = collectionId, + ) /** Groups audiobook libraries by author, narrator, or series for book-native browsing. */ suspend fun getAudiobookGroups( @@ -109,9 +123,12 @@ class CatalogRepository( /** Fetches full metadata for a single catalog item (offline: last cached detail). */ suspend fun getItemDetail(contentId: String): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getItemDetail(contentId) if (result is ApiResult.Success) { - catalogCache.cacheItemDetail(contentId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheItemDetail(contentId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -124,15 +141,27 @@ class CatalogRepository( suspend fun getCachedItemDetail(contentId: String): ItemDetail? = catalogCache.getCachedItemDetail(contentId) + /** + * Cache-first detail for speculative UI enrichment. Unlike a detail screen, + * prefetch must not re-download metadata that is already durable locally. + */ + suspend fun getItemDetailForPrefetch(contentId: String): ApiResult { + catalogCache.getCachedItemDetail(contentId)?.let { return ApiResult.Success(it) } + return getItemDetail(contentId) + } + /** Fetches playback-oriented detail (versions, user progress, intro/credits markers). */ suspend fun getWatchDetail(contentId: String): ApiResult = catalogApi.getWatchDetail(contentId) /** Lists seasons for a series (offline: last cached seasons). */ suspend fun getSeasons(seriesId: String): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getSeasons(seriesId) if (result is ApiResult.Success) { - catalogCache.cacheSeasons(seriesId, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheSeasons(seriesId, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -143,9 +172,12 @@ class CatalogRepository( /** Lists episodes for a specific season of a series (offline: last cached episodes). */ suspend fun getEpisodes(seriesId: String, seasonNumber: Int): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value val result = catalogApi.getEpisodes(seriesId, seasonNumber) if (result is ApiResult.Success) { - catalogCache.cacheEpisodes(seriesId, seasonNumber, result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheEpisodes(seriesId, seasonNumber, result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -189,4 +221,13 @@ class CatalogRepository( limit = limit, snapshotAt = snapshotAt, ) + + private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt new file mode 100644 index 000000000..746ae910f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/OnboardingRepository.kt @@ -0,0 +1,31 @@ +package org.prairieserver.prairie.repository + +import org.prairieserver.prairie.model.onboarding.OnboardingFlow +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.model.onboarding.OnboardingState +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.OnboardingApi + +/** + * First-run tour state and manifest. Completion is per profile and lives on + * the server, so finishing on any device silences every other one. + */ +class OnboardingRepository(private val api: OnboardingApi) { + + suspend fun getFlow(surface: String): ApiResult = api.getFlow(surface) + + suspend fun getState(): ApiResult = api.getState() + + suspend fun recordStep(tourId: String, stepId: String): ApiResult = + api.postProgress(OnboardingProgressRequest(tourId = tourId, lastStep = stepId)) + + suspend fun complete(tourId: String, lastStep: String?): ApiResult = + api.postProgress( + OnboardingProgressRequest(tourId = tourId, lastStep = lastStep, completed = true), + ) + + suspend fun skip(tourId: String, lastStep: String?): ApiResult = + api.postProgress( + OnboardingProgressRequest(tourId = tourId, lastStep = lastStep, skipped = true), + ) +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt index 687fd1cf9..9e497492e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PersonalDataRepository.kt @@ -7,9 +7,12 @@ import org.prairieserver.prairie.model.personal.SyncProgressItem import org.prairieserver.prairie.model.personal.SyncProgressRequest import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.PersonalDataApi import org.prairieserver.prairie.network.map import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.UserItemStatePort @@ -27,14 +30,18 @@ open class PersonalDataRepository( private val userItemStatePort: UserItemStatePort = NoOpUserItemStatePort, /** Offline read cache for the library list (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) { // -- Libraries -- /** Lists the libraries visible to the current user (offline: last cached list). */ suspend fun listUserLibraries(): ApiResult> { + val requestIdentityGeneration = identityTransitions.generation.value val result = personalDataApi.listUserLibraries() if (result is ApiResult.Success) { - catalogCache.cacheLibraries(result.data) + writeIfIdentityUnchanged(requestIdentityGeneration) { cacheWriteLease -> + catalogCache.cacheLibraries(result.data, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -164,4 +171,13 @@ open class PersonalDataRepository( open suspend fun dismissNextUp(itemId: String, seriesId: String): ApiResult = personalDataApi.dismissNextUp(itemId, seriesId) + + private suspend fun writeIfIdentityUnchanged( + requestGeneration: Long, + write: suspend (CatalogCacheWriteLease) -> Unit, + ) { + if (requestGeneration == identityTransitions.generation.value) { + write(CatalogCacheWriteLease(requestGeneration)) + } + } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt index 423a2a5a0..f945055a3 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt @@ -1,17 +1,10 @@ package org.prairieserver.prairie.repository -import org.prairieserver.prairie.model.playback.ClientCodecCapabilities -import org.prairieserver.prairie.model.playback.ClientPlaybackContext -import org.prairieserver.prairie.model.playback.PlayMethod import org.prairieserver.prairie.model.playback.PlaybackDecisionResponseV3 import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 -import org.prairieserver.prairie.model.playback.PlaybackSessionResponse import org.prairieserver.prairie.model.playback.ProgressRequest -import org.prairieserver.prairie.model.playback.StartPlaybackRequest -import org.prairieserver.prairie.model.playback.TranscodeStartRequest -import org.prairieserver.prairie.model.playback.TranscodeStartResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.PlaybackApi @@ -32,47 +25,6 @@ class PlaybackRepository( suspend fun reportRouteEventV3(request: PlaybackRouteEventV3): ApiResult = playbackApi.reportRouteEventV3(request) - /** - * Starts a new playback session. - * The server decides whether to direct-play or transcode based on client capabilities. - */ - suspend fun startPlayback( - fileId: Int, - profileId: String, - qualityPreference: String? = null, - audioTrackIndex: Int? = null, - subtitleTrackIndex: Int? = null, - startPosition: Double? = null, - capabilities: ClientCodecCapabilities, - clientPlaybackContext: ClientPlaybackContext? = null, - preserveDirectAudioSelection: Boolean = false, - playMethod: PlayMethod? = null, - disableProgressPersistence: Boolean = false, - seekableStreamsOnly: Boolean = false, - ): ApiResult = - playbackApi.startPlayback( - StartPlaybackRequest( - fileId = fileId, - profileId = profileId, - playMethod = playMethod?.wireValue(), - startPosition = startPosition, - audioTrackIndex = audioTrackIndex, - subtitleTrackIndex = subtitleTrackIndex, - qualityPreference = qualityPreference, - preserveDirectAudioSelection = preserveDirectAudioSelection, - codecsVideo = capabilities.codecsVideo, - codecsAudio = capabilities.codecsAudio, - containers = capabilities.containers, - maxResolution = capabilities.maxResolution, - hdr = capabilities.hdr, - hdrDetails = capabilities.hdrDetails, - audioPassthrough = capabilities.audioPassthrough, - clientPlaybackContext = clientPlaybackContext, - disableProgressPersistence = disableProgressPersistence, - seekableStreamsOnly = seekableStreamsOnly, - ), - ) - /** Reports current playback position and paused state to the server. */ suspend fun updateProgress( sessionId: String, @@ -87,19 +39,4 @@ class PlaybackRepository( /** Stops an active playback session. */ suspend fun stopPlayback(sessionId: String): ApiResult = playbackApi.stopPlayback(sessionId) - - /** Explicitly requests a transcode session (e.g. for quality changes). */ - suspend fun startTranscode(request: TranscodeStartRequest): ApiResult = - playbackApi.startTranscode(request) - -} - -// Mirror the enum's @SerialName wire values explicitly (not name.lowercase()), -// so adding a constant whose serial name differs from its lowercased name is a -// compile error here rather than a silently wrong wire value. Exhaustive on -// purpose — no `else`. -private fun PlayMethod.wireValue(): String = when (this) { - PlayMethod.DIRECT -> "direct" - PlayMethod.REMUX -> "remux" - PlayMethod.TRANSCODE -> "transcode" } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt index d64de3205..cd2ff338e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/ProfileRepository.kt @@ -5,6 +5,7 @@ import org.prairieserver.prairie.model.profile.Profile import org.prairieserver.prairie.model.profile.UpdateProfileRequest import org.prairieserver.prairie.model.profile.VerifyPinResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.AuthScopeSnapshot import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.IdentityTransitionKind @@ -13,6 +14,19 @@ import org.prairieserver.prairie.network.TokenManager import org.prairieserver.prairie.network.api.ProfileApi import org.prairieserver.prairie.network.map +/** Outcome of a scope-guarded profile commit. */ +enum class ProfileCommitResult { + /** Identity was written. */ + Committed, + + /** + * The commit was refused because this identity is not ours to write: + * either the scope moved between capture and commit, or a temporary + * remote-playback overlay owns identity right now. Nothing was written. + */ + ScopeChanged, +} + open class ProfileRepository( private val profileApi: ProfileApi, private val tokenManager: TokenManager, @@ -49,17 +63,17 @@ open class ProfileRepository( /** * Verifies a profile's PIN. - * On success, persists the profile token via [TokenManager]. + * + * This deliberately does NOT touch [TokenManager]. Verification is a + * question, not a commitment: the answer is only worth acting on if the + * caller still wants this profile when it arrives. Persisting the token + * here wrote it into whatever server slot happened to be active by then, + * so cancelling mid-flight (or switching servers) could install one + * server's profile token as another's. Callers commit the result through + * [selectProfile], which binds id and token together in one transition. */ - suspend fun verifyPin(profileId: String, pin: String): ApiResult { - val result = profileApi.verifyPin(profileId, pin) - if (result is ApiResult.Success) { - result.data.profileToken?.let { token -> - tokenManager.setProfileToken(token) - } - } - return result - } + suspend fun verifyPin(profileId: String, pin: String): ApiResult = + profileApi.verifyPin(profileId, pin) /** * Selects a profile as the active profile. @@ -67,18 +81,133 @@ open class ProfileRepository( * Persists the profile id on the active [TokenManager] slot AND on the * matching [ServerRegistry] entry — the latter is what restores the * "last used profile" when the user hops back to this server. + * + * [profileToken] is the artifact `verify-pin` just issued for *this* + * profile, or null for an unprotected one. Id and token are written as one + * stored identity: a profile token is bound server-side to a single profile + * id, and carrying the previous profile's token into the new selection made + * every request claim one profile while presenting another's proof. Phone + * hit that on the ordinary protected-A → unprotected-B switch (TV cleared + * first, so only one client was wrong). + * + * Scope note: the WRITE is atomic and so is what survives a crash, but + * readers still fetch id and token through separate calls + * ([TokenManager.getProfileId] / [TokenManager.getProfileToken]), so a + * request assembled exactly across a switch can still pair an old id with a + * new token. Closing that needs a combined accessor and a migration of + * every paired reader. */ - suspend fun selectProfile(profileId: String) { + suspend fun selectProfile( + profileId: String, + profileToken: String? = null, + expectedScope: AuthScopeSnapshot? = null, + ): ProfileCommitResult { + var result = ProfileCommitResult.Committed + // Read the barrier generation before entering the transition. `changing` + // runs its gates, then bumps the generation, then runs this block — so + // by the time we are inside, the live generation is already this + // transition's own. Comparing the captured scope against it directly + // would report "changed" on every single selection. + val generationBeforeTransition = identityTransitions.generation.value identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { - tokenManager.setProfileId(profileId) + // Re-check INSIDE the transition: a scope captured before the PIN + // round trip proves nothing unless it still holds at the moment of + // the write. Remote playback can install a temporary identity + // mid-flight, and committing there would put this server's profile + // proof into an overlay that belongs to a different session. + // A remote-playback overlay owns identity while it exists, and it + // is not this user's session to repoint. This check is deliberately + // independent of [expectedScope]: an unprotected selection carries + // no scope to compare, and an already-dispatched tap can land after + // the overlay installs. + if (tokenManager.hasTemporaryScope()) { + result = ProfileCommitResult.ScopeChanged + return@changing + } + if (!identityScopeStillHolds(expectedScope, generationBeforeTransition)) { + result = ProfileCommitResult.ScopeChanged + return@changing + } + tokenManager.setProfileIdentity(profileId, profileToken) val activeServerId = tokenManager.getCurrentServerId() if (activeServerId != null) { + // Known, accepted window: this is a SECOND durable edit, so a + // process death between it and the identity write above leaves + // the registry naming the old profile while the token manager + // (and therefore every request header) names the new one. + // Startup prefers the registry, so the next launch can look + // like the old profile while authenticating as the new one. + // Not the same class as the id/token mismatch fixed above — + // that one sent mismatched credentials on every request — but + // closing it means making one of the two authoritative. serverRegistry?.setProfileId(activeServerId, profileId) } notificationsRepository?.reset() requestsRepository?.reset() _profileSwitches.tryEmit(Unit) } + return result + } + + /** + * Capture the identity scope a PIN verification is about to be asked + * against, for later hand-off to [selectProfile]. Null when the manager + * does not model scopes, which keeps the guard inert rather than failing + * closed on something it never recorded. + */ + suspend fun captureIdentityScope(): AuthScopeSnapshot? = + tokenManager.snapshotCurrentScope() + + /** + * Whether [expected] is still the live identity, for callers that are not + * inside an identity transition (so no generation offset applies). + * + * Used to discard a profile list fetched under an identity that has since + * been replaced — a stale grid lets the user pick a profile belonging to a + * session the app no longer holds. + */ + suspend fun identityScopeUnchanged(expected: AuthScopeSnapshot?): Boolean { + if (expected == null) return true + val current = tokenManager.snapshotCurrentScope() ?: return false + return current.serverId == expected.serverId && + current.identityGeneration == expected.identityGeneration && + current.credentialEpoch == expected.credentialEpoch + } + + /** + * Whether the identity active *now* is still the one [expected] was + * captured from. + * + * [generationBeforeTransition] is the barrier generation read immediately + * before the enclosing `changing` block, which bumps it on entry. So: + * + * - `expected.identityGeneration == generationBeforeTransition` says + * nothing moved between capturing the scope and starting this commit; + * - `current.identityGeneration == generationBeforeTransition + 1` says + * the only transition since is this one, so nobody slipped in while we + * were waiting on the barrier's mutex. + * + * A null [expected] means the caller never captured a scope (or the manager + * does not model them), so the guard stays inert rather than failing closed + * on information it never recorded. But once a scope WAS captured, a + * missing current scope means it is gone, not unsupported — that fails + * closed. + * + * Deliberately a repository function rather than a `TokenManager` default + * method: a default that calls another overridable member runs against the + * delegate under interface delegation, so wrappers would silently get the + * base behaviour. + */ + private suspend fun identityScopeStillHolds( + expected: AuthScopeSnapshot?, + generationBeforeTransition: Long, + ): Boolean { + if (expected == null) return true + if (expected.identityGeneration != generationBeforeTransition) return false + val current = tokenManager.snapshotCurrentScope() ?: return false + return current.serverId == expected.serverId && + current.identityGeneration == generationBeforeTransition + 1 && + current.credentialEpoch == expected.credentialEpoch } private val _profileSwitches = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) @@ -136,8 +265,7 @@ open class ProfileRepository( suspend fun clearProfile() { identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { val activeServerId = tokenManager.getCurrentServerId() - tokenManager.setProfileId(null) - tokenManager.setProfileToken(null) + tokenManager.setProfileIdentity(null, null) if (activeServerId != null) { serverRegistry?.setProfileId(activeServerId, null) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt index e76bf1458..448b4b982 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SectionRepository.kt @@ -1,5 +1,6 @@ package org.prairieserver.prairie.repository +import org.prairieserver.prairie.model.catalog.CatalogQueryGroup import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.model.section.HomeLayoutResponse import org.prairieserver.prairie.model.section.HomeSectionItemsResponse @@ -7,34 +8,101 @@ import org.prairieserver.prairie.model.section.LibraryCollection import org.prairieserver.prairie.model.section.LibraryCollectionsResponse import org.prairieserver.prairie.model.section.SectionsResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.network.map import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpCatalogCachePort import org.prairieserver.prairie.repository.port.canServeCache +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class SectionRepository( private val sectionApi: SectionApi, /** Offline read cache for a library's Recommended sections (Track B). No-op by default. */ private val catalogCache: CatalogCachePort = NoOpCatalogCachePort, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + private val homeRequestDispatcher: CoroutineDispatcher = Dispatchers.Default, ) { + private val homeRequestScope = CoroutineScope(SupervisorJob() + homeRequestDispatcher) + private val homeRequestMutex = Mutex() + private val homeSectionsInFlight = + mutableMapOf>>() + private val homeSectionItemsInFlight = + mutableMapOf, Deferred>>() + /** Fetches the home screen layout configuration. */ suspend fun getHomeLayout(): ApiResult = sectionApi.getHomeLayout() /** Fetches all home screen sections (with items pre-resolved). */ - suspend fun getHomeSections(): ApiResult = - sectionApi.getHomeSections() + suspend fun getHomeSections(): ApiResult { + val identityGeneration = identityTransitions.generation.value + val request = homeRequestMutex.withLock { + homeSectionsInFlight[identityGeneration] ?: run { + lateinit var created: Deferred> + created = homeRequestScope.async(start = CoroutineStart.LAZY) { + try { + sectionApi.getHomeSections() + } finally { + homeRequestMutex.withLock { + if (homeSectionsInFlight[identityGeneration] === created) { + homeSectionsInFlight.remove(identityGeneration) + } + } + } + } + homeSectionsInFlight[identityGeneration] = created + created.start() + created + } + } + return request.await() + } /** Fetches the items within a specific home section. */ - suspend fun getHomeSectionItems(sectionId: String): ApiResult = - sectionApi.getHomeSectionItems(sectionId) + suspend fun getHomeSectionItems(sectionId: String): ApiResult { + val requestKey = identityTransitions.generation.value to sectionId + val request = homeRequestMutex.withLock { + homeSectionItemsInFlight[requestKey] ?: run { + lateinit var created: Deferred> + created = homeRequestScope.async(start = CoroutineStart.LAZY) { + try { + sectionApi.getHomeSectionItems(sectionId) + } finally { + homeRequestMutex.withLock { + if (homeSectionItemsInFlight[requestKey] === created) { + homeSectionItemsInFlight.remove(requestKey) + } + } + } + } + homeSectionItemsInFlight[requestKey] = created + created.start() + created + } + } + return request.await() + } /** Fetches a library's resolved sections (offline: last cached sections). */ suspend fun getLibrarySections(libraryId: Int): ApiResult { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = CatalogCacheWriteLease(requestIdentityGeneration) val result = sectionApi.getLibrarySections(libraryId) if (result is ApiResult.Success) { - catalogCache.cacheLibrarySections(libraryId, result.data.sections) + if (requestIdentityGeneration == identityTransitions.generation.value) { + catalogCache.cacheLibrarySections(libraryId, result.data.sections, cacheWriteLease) + } return result } if (result.canServeCache()) { @@ -66,6 +134,18 @@ class SectionRepository( collectionId: String, offset: Int = 0, limit: Int = 60, + sort: String? = null, + order: String? = null, + queryGroups: List = emptyList(), + match: String? = null, ): ApiResult = - sectionApi.getLibraryCollectionItems(collectionId, offset, limit) + sectionApi.getLibraryCollectionItems( + collectionId = collectionId, + offset = offset, + limit = limit, + sort = sort, + order = order, + queryGroups = queryGroups, + match = match, + ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt index be218bec7..6a29bf654 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/SettingsRepository.kt @@ -1,12 +1,18 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.settings.EffectiveSetting +import org.prairieserver.prairie.model.settings.EffectiveSettingValue import org.prairieserver.prairie.model.settings.EffectiveSubtitleAppearance +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.StoredSettingValue import org.prairieserver.prairie.model.settings.SubtitleAppearance import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.OverlayConfigResponse import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.network.api.newSettingMutationId import org.prairieserver.prairie.network.map +import kotlinx.serialization.json.JsonElement class SettingsRepository( private val settingsApi: SettingsApi, @@ -42,6 +48,63 @@ class SettingsRepository( response.settings.associateBy { it.key } } + /** + * Batched canonical resolution (`GET /api/v1/settings/values/effective`): + * typed JSON values, each with the scope it resolved from. A key the + * server's contract does not know is simply absent from the map. + */ + suspend fun getEffectiveValues( + keys: List = emptyList(), + libraryIds: List = emptyList(), + seriesIds: List = emptyList(), + ): ApiResult> = + settingsApi.getEffectiveValues(keys, libraryIds, seriesIds).map { response -> + response.settings.associateBy { it.key } + } + + /** + * What the connected server's settings contract supports, or + * [SettingsCapabilitiesResult.ServerUpgradeRequired] when it predates the + * canonical settings API. Screens surface that case as an explanation + * rather than as an empty list of settings. + */ + suspend fun contractCapabilities(): SettingsCapabilitiesResult = + settingsApi.getContractCapabilities() + + /** + * Write one profile-scoped value (`scope=profile`) — the household + * preference that applies on every device until a device overrides it. + * + * A fresh mutation id per call is correct here because one call is one + * logical write: these callers are settings pickers that roll their UI + * back on failure, so a user re-picking is genuinely new content and must + * not replay an id (that is exactly the 409 `mutation_id_conflict` case). + * A caller that retries the *same* write must pass the id it already used. + */ + suspend fun setProfileValue( + key: String, + value: JsonElement, + mutationId: String = newSettingMutationId(), + ): ApiResult = + settingsApi.putValue( + key = key, + scope = SettingScopeIdentity.profile(), + value = value, + mutationId = mutationId, + ) + + /** + * Clear the profile-scoped value so the setting inherits again. 404 means + * nothing was stored there, which is the state the caller asked for, so it + * reports success rather than an error the UI would have to special-case. + */ + suspend fun clearProfileValue(key: String): ApiResult = + when (val result = settingsApi.deleteValue(key, SettingScopeIdentity.profile())) { + is ApiResult.Error -> + if (result.code == 404) ApiResult.Success(Unit) else result + else -> result + } + suspend fun getEffectiveSubtitleAppearance(): ApiResult = settingsApi.getEffectiveSubtitleAppearance() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt index eeb3d49c2..55ba3488b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepository.kt @@ -18,7 +18,9 @@ import org.prairieserver.prairie.network.RoomRealtimeEvent import org.prairieserver.prairie.network.WatchTogetherRealtimeClient import org.prairieserver.prairie.network.api.WatchTogetherApi import org.prairieserver.prairie.util.parseRfc3339ToEpochMillis +import org.prairieserver.prairie.watchtogether.RoomDeliveryEcho import org.prairieserver.prairie.watchtogether.RoomDeliveryLatch +import org.prairieserver.prairie.watchtogether.WatchTogetherEntryGateway import org.prairieserver.prairie.watchtogether.RoomSessionRepository import org.prairieserver.prairie.watchtogether.RoomTransportIntent import org.prairieserver.prairie.watchtogether.roomTransportAuthorized @@ -97,7 +99,7 @@ class WatchTogetherRepository( private val realtimeFactory: () -> WatchTogetherRealtimeClient? = { null }, private val monotonicNowMs: () -> Long = { MONOTONIC_ORIGIN.elapsedNow().inWholeMilliseconds }, private val authScopeProvider: suspend () -> AuthScopeSnapshot? = { null }, -) : RoomSessionRepository { +) : RoomSessionRepository, WatchTogetherEntryGateway { /** Successful delivery state follows the process connection, not a UI controller. */ val roomDeliveryLatch = RoomDeliveryLatch() @@ -119,9 +121,11 @@ class WatchTogetherRepository( private var activeConnectionOwner: Long? = null private val _roomSnapshot = MutableStateFlow(null) private val _suggestions = MutableStateFlow>(emptyList()) + private val _roomDeliveryEcho = MutableStateFlow(null) override val roomSnapshot: StateFlow = _roomSnapshot.asStateFlow() val suggestions: StateFlow> = _suggestions.asStateFlow() + val roomDeliveryEcho: StateFlow = _roomDeliveryEcho.asStateFlow() private val _connectionState = MutableStateFlow(WatchTogetherConnectionState()) val connectionState: StateFlow = _connectionState.asStateFlow() @kotlin.concurrent.Volatile @@ -192,7 +196,7 @@ class WatchTogetherRepository( // ---- REST: create / join (store the room token) --------------------------- - suspend fun createRoom(request: CreateRoomRequest): ApiResult { + override suspend fun createRoom(request: CreateRoomRequest): ApiResult { val scope = authScopeProvider() ?: return missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.createRoom(request, scope) @@ -200,7 +204,7 @@ class WatchTogetherRepository( return if (r is ApiResult.Success) installRoomResponse(r.data, scope, requestGeneration) else r } - suspend fun joinRoom(request: JoinRoomRequest): ApiResult { + override suspend fun joinRoom(request: JoinRoomRequest): ApiResult { val scope = authScopeProvider() ?: return missingAuthScope() val requestGeneration = beginRoomRequest() val r = api.joinRoom(request, scope) @@ -256,6 +260,7 @@ class WatchTogetherRepository( _suggestions.value = emptyList() _roomClosedReason.value = null _roomSnapshot.value = data.room + _roomDeliveryEcho.value = null _connectionState.value = WatchTogetherConnectionState(generation = installed.generation) realtimeConnectionId = null refreshTransportAuthorizationLocked() @@ -265,7 +270,7 @@ class WatchTogetherRepository( // ---- REST: host management ------------------------------------------------ - suspend fun setSelection(request: SetSelectionRequest): ApiResult { + override suspend fun setSelection(request: SetSelectionRequest): ApiResult { val lease = activeBinding() ?: return missingRoom() val r = api.setSelection(lease.roomId, lease.roomToken, request, lease.authScope) return publishRoomResponse(lease, r) @@ -310,7 +315,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false votedIds.add(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = false) + applySuggestions(r.data.suggestions, fromBroadcast = true) true } return if (published) r else obsoleteRoomRequest() @@ -323,7 +328,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false votedIds.remove(suggestionId) - applySuggestions(r.data.suggestions, fromBroadcast = false) + applySuggestions(r.data.suggestions, fromBroadcast = true) true } return if (published) r else obsoleteRoomRequest() @@ -374,6 +379,7 @@ class WatchTogetherRepository( val published = stateMutex.withLock { if (!isCurrentLocked(lease)) return@withLock false _roomSnapshot.value = result.data.room + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() true } @@ -384,12 +390,14 @@ class WatchTogetherRepository( lease: RoomBinding, result: ApiResult, expectedRoomRequest: Long? = null, + expectedConnectionOwner: Long? = null, ): ApiResult { if (result !is ApiResult.Success) return result val published = stateMutex.withLock { if ( !isCurrentLocked(lease) || - (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) + (expectedRoomRequest != null && latestRoomRequest != expectedRoomRequest) || + (expectedConnectionOwner != null && activeConnectionOwner != expectedConnectionOwner) ) { return@withLock false } @@ -413,12 +421,14 @@ class WatchTogetherRepository( /** * Publish suggestions, re-merging `voted_by_me` from the local [votedIds] - * set. For REST results we also seed [votedIds] from authoritative - * voted_by_me; broadcasts force false, so we only OR the local set in. + * set. Authoritative REST lists replace [votedIds]; broadcasts and + * optimistic vote mutation responses preserve the local set because their + * per-recipient vote flags are not authoritative. */ private fun applySuggestions(list: List, fromBroadcast: Boolean) { if (!fromBroadcast) { - list.forEach { if (it.votedByMe) votedIds.add(it.id) } + votedIds.clear() + votedIds.addAll(list.filter { it.votedByMe }.map { it.id }) } _suggestions.value = list.map { s -> if (s.id in votedIds) s.copy(votedByMe = true) else s @@ -558,6 +568,7 @@ class WatchTogetherRepository( terminalGeneration = lease.generation _roomClosedReason.value = event.reason ?: "room_closed" _roomSnapshot.value = null + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() } } @@ -568,6 +579,15 @@ class WatchTogetherRepository( } else if (event is RoomRealtimeEvent.Opened) { openedAtMs = monotonicNowMs() markOpened(lease, owner, client.currentConnectionId()) + publishSuggestionsResponse( + lease = lease, + result = api.listSuggestions( + lease.roomId, + lease.roomToken, + lease.authScope, + ), + expectedConnectionOwner = owner, + ) } else if ( event is RoomRealtimeEvent.SnapshotEvent && event.room.roomId == lease.roomId @@ -607,6 +627,7 @@ class WatchTogetherRepository( terminalGeneration = lease.generation _roomClosedReason.value = "connection_lost" _roomSnapshot.value = null + _roomDeliveryEcho.value = null refreshTransportAuthorizationLocked() } } @@ -694,6 +715,16 @@ class WatchTogetherRepository( is RoomRealtimeEvent.SnapshotEvent -> if (event.room.roomId == lease.roomId) { _roomSnapshot.value = event.room + _roomDeliveryEcho.value = event.room.attachedSessionId + ?.takeIf { it.isNotBlank() } + ?.let { sessionId -> + val connection = _connectionState.value + RoomDeliveryEcho( + connectionGeneration = connection.generation, + connectionEpoch = connection.epoch, + playbackSessionId = sessionId, + ) + } refreshTransportAuthorizationLocked() } is RoomRealtimeEvent.SuggestionsEvent -> applySuggestions(event.suggestions, fromBroadcast = true) @@ -729,6 +760,7 @@ class WatchTogetherRepository( binding = null terminalGeneration = null _roomSnapshot.value = null + _roomDeliveryEcho.value = null _suggestions.value = emptyList() _roomClosedReason.value = null votedIds.clear() diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt index 65687bb68..3f264660c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/CatalogCachePort.kt @@ -8,6 +8,12 @@ import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +/** + * Ownership captured before a cacheable network request starts. Identity-scoped + * cache implementations must reject the write when this generation is stale. + */ +data class CatalogCacheWriteLease(val identityGeneration: Long) + /** * Offline read cache for catalog browse (Track B). Backs repository-level * cache-with-fallback in [org.prairieserver.prairie.repository.PersonalDataRepository] @@ -22,24 +28,63 @@ import org.prairieserver.prairie.network.ApiResult */ interface CatalogCachePort { suspend fun cacheLibraries(libraries: List) {} + suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) { + cacheLibraries(libraries) + } suspend fun getCachedLibraries(): List? = null /** Cache the default (unfiltered, first-page) browse for a library. */ suspend fun cacheDefaultLibraryPage(libraryId: Int, response: CatalogResponse) {} + suspend fun cacheDefaultLibraryPage( + libraryId: Int, + response: CatalogResponse, + lease: CatalogCacheWriteLease, + ) { + cacheDefaultLibraryPage(libraryId, response) + } suspend fun getCachedDefaultLibraryPage(libraryId: Int): CatalogResponse? = null /** Cache a library's resolved "Recommended" sections (for the offline landing tab). */ suspend fun cacheLibrarySections(libraryId: Int, sections: List) {} + suspend fun cacheLibrarySections( + libraryId: Int, + sections: List, + lease: CatalogCacheWriteLease, + ) { + cacheLibrarySections(libraryId, sections) + } suspend fun getCachedLibrarySections(libraryId: Int): List? = null /** Cache an item's detail page (tap-a-title-offline). */ suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) {} + suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) { + cacheItemDetail(contentId, detail) + } suspend fun getCachedItemDetail(contentId: String): ItemDetail? = null /** Cache a series' season list + a season's episode list (offline series detail). */ suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) {} + suspend fun cacheSeasons( + seriesId: String, + response: SeasonsResponse, + lease: CatalogCacheWriteLease, + ) { + cacheSeasons(seriesId, response) + } suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = null suspend fun cacheEpisodes(seriesId: String, seasonNumber: Int, response: EpisodesResponse) {} + suspend fun cacheEpisodes( + seriesId: String, + seasonNumber: Int, + response: EpisodesResponse, + lease: CatalogCacheWriteLease, + ) { + cacheEpisodes(seriesId, seasonNumber, response) + } suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int): EpisodesResponse? = null } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt index 5dfd4e939..89f56444f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/port/HomeCachePort.kt @@ -8,6 +8,8 @@ data class HomeCacheSnapshot( val cachedAtMs: Long, ) +data class HomeCacheWriteLease(val identityGeneration: Long) + /** * Offline read cache for the home screen (Track B). [HomeViewModel] serves the * cached layout instantly (stale-while-revalidate) so the app opens to content @@ -23,6 +25,9 @@ data class HomeCacheSnapshot( */ interface HomeCachePort { suspend fun cacheHome(sections: List) {} + suspend fun cacheHome(sections: List, lease: HomeCacheWriteLease) { + cacheHome(sections) + } suspend fun getCachedHome(): HomeCacheSnapshot? = null } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModel.kt deleted file mode 100644 index a20671357..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModel.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class AdminStatsUiState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val stats: AdminStats? = null, - val error: String? = null, -) - -/** - * Shared admin dashboard ViewModel. Mirrors CalendarViewModel: generation-gated - * fetches, pull-to-refresh, server-message error surfacing. `refresh()` asks the - * server to recompute (`?refresh=true`); the initial load reads the cached stats. - */ -class AdminStatsViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(AdminStatsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation, refresh = false) - } - } - - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetch(generation, refresh = true) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } - } - - private suspend fun fetch(generation: Int, refresh: Boolean) { - val result = repository.getStats(refresh = refresh) - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoading = false, stats = result.data, error = null) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load admin stats")) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModel.kt deleted file mode 100644 index 1a9213587..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModel.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** Known role options for the create/edit form's role picker. */ -val ADMIN_USER_ROLES: List = listOf("user", "admin") - -data class AdminUserEditUiState( - /** null in create mode; the existing user id in edit mode. */ - val userId: Int? = null, - val username: String = "", - val email: String = "", - /** Create: the new password. Edit: optional reset value (blank = unchanged). */ - val password: String = "", - val role: String = "user", - val enabled: Boolean = true, - /** Comma-separated library id list, surfaced verbatim for editing. */ - val libraryIdsText: String = "", - val maxStreamsText: String = "", - val maxTranscodesText: String = "", - val maxProfilesText: String = "", - val downloadAllowed: Boolean = false, - val downloadTranscodeAllowed: Boolean = false, - val isLoading: Boolean = false, - val isSaving: Boolean = false, - /** Inline validation / load / save error. */ - val error: String? = null, - /** Set once the save succeeds so the screen can pop back. */ - val saveSuccess: Boolean = false, -) { - val isEditMode: Boolean get() = userId != null -} - -/** - * Shared ViewModel for the admin user create/edit form. Constructed without an - * id (mirroring EditProfileViewModel); the screen calls [load] once with the - * target id (or null for create). [submit] validates via the pure helpers in - * AdminUserForm and routes to create/update, omitting unset optional fields so - * the server's pointer "keep current value" semantics apply. - */ -class AdminUserEditViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private val _uiState = MutableStateFlow(AdminUserEditUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - private var loaded = false - - /** Loads the user for edit, or initialises create mode. Idempotent. */ - fun load(userId: Int?) { - if (loaded) return - loaded = true - if (userId == null) return // create mode keeps defaults - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - when (val result = repository.getUser(userId)) { - is ApiResult.Success -> _uiState.update { it.fromUser(result.data) } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load user")) - } - } - } - } - - fun onUsernameChange(value: String) = _uiState.update { it.copy(username = value, error = null) } - fun onEmailChange(value: String) = _uiState.update { it.copy(email = value, error = null) } - fun onPasswordChange(value: String) = _uiState.update { it.copy(password = value, error = null) } - fun onRoleChange(value: String) = _uiState.update { it.copy(role = value) } - fun onEnabledChange(value: Boolean) = _uiState.update { it.copy(enabled = value) } - fun onLibraryIdsChange(value: String) = _uiState.update { it.copy(libraryIdsText = value) } - fun onMaxStreamsChange(value: String) = _uiState.update { it.copy(maxStreamsText = value) } - fun onMaxTranscodesChange(value: String) = _uiState.update { it.copy(maxTranscodesText = value) } - fun onMaxProfilesChange(value: String) = _uiState.update { it.copy(maxProfilesText = value) } - fun onDownloadAllowedChange(value: Boolean) = _uiState.update { it.copy(downloadAllowed = value) } - fun onDownloadTranscodeAllowedChange(value: Boolean) = - _uiState.update { it.copy(downloadTranscodeAllowed = value) } - - fun submit() { - val state = _uiState.value - val validation = if (state.isEditMode) { - validatePasswordReset(state.password) - } else { - validateCreateUser(state.username, state.email, state.password) - } - if (validation != null) { - _uiState.update { it.copy(error = validation) } - return - } - viewModelScope.launch { - _uiState.update { it.copy(isSaving = true, error = null) } - val result = if (state.userId == null) state.create() else state.update(state.userId) - when (result) { - is ApiResult.Success -> _uiState.update { it.copy(isSaving = false, saveSuccess = true) } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isSaving = false, error = result.errorMessage("Failed to save user")) - } - } - } - } - - private suspend fun AdminUserEditUiState.create(): ApiResult = - repository.createUser( - CreateUserRequest( - username = username.trim(), - email = email.trim(), - password = password, - role = role, - libraryIds = parseLibraryIds(libraryIdsText), - maxStreams = parseQuota(maxStreamsText), - maxTranscodes = parseQuota(maxTranscodesText), - maxProfiles = parseQuota(maxProfilesText), - downloadAllowed = downloadAllowed, - downloadTranscodeAllowed = downloadTranscodeAllowed, - ), - ) - - private suspend fun AdminUserEditUiState.update(id: Int): ApiResult = - repository.updateUser( - id, - UpdateUserRequest( - role = role, - enabled = enabled, - password = password.ifBlank { null }, - libraryIds = parseLibraryIdsOrNull(libraryIdsText), - maxStreams = parseQuota(maxStreamsText), - maxTranscodes = parseQuota(maxTranscodesText), - maxProfiles = parseQuota(maxProfilesText), - downloadAllowed = downloadAllowed, - downloadTranscodeAllowed = downloadTranscodeAllowed, - ), - ) -} - -private fun AdminUserEditUiState.fromUser(user: AdminUser): AdminUserEditUiState = copy( - userId = user.id, - username = user.username, - email = user.email, - password = "", - role = user.role, - enabled = user.enabled, - libraryIdsText = user.libraryIds.joinToString(", "), - maxStreamsText = user.maxStreams.takeIf { it > 0 }?.toString().orEmpty(), - maxTranscodesText = user.maxTranscodes.takeIf { it > 0 }?.toString().orEmpty(), - maxProfilesText = user.maxProfiles.takeIf { it > 0 }?.toString().orEmpty(), - downloadAllowed = user.downloadAllowed, - downloadTranscodeAllowed = user.downloadTranscodeAllowed, - isLoading = false, - error = null, -) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserForm.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserForm.kt deleted file mode 100644 index 39797ac55..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUserForm.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -/** - * Pure, testable helpers for the admin user create/edit form. Kept free of any - * Compose/Android types so the validation and parsing rules can be unit-tested - * in commonTest and reused by both the mobile screen and (later) TV. - */ - -/** Display label for a role string (e.g. "admin" -> "Admin"); blank -> "Unknown". */ -fun roleDisplayName(role: String): String = when { - role.isBlank() -> "Unknown" - else -> role.replaceFirstChar { it.uppercase() } -} - -private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") -private const val MIN_PASSWORD_LENGTH = 6 - -/** Returns an error message, or null when the create form is valid. */ -fun validateCreateUser(username: String, email: String, password: String): String? = when { - username.isBlank() -> "Username is required" - !EMAIL_REGEX.matches(email.trim()) -> "Enter a valid email" - password.length < MIN_PASSWORD_LENGTH -> "Password must be at least $MIN_PASSWORD_LENGTH characters" - else -> null -} - -/** - * Validates an optional password-reset value on the edit form. Blank means - * "leave the password unchanged" and is always valid; a non-blank value must - * meet the minimum length. Returns an error message, or null when valid. - */ -fun validatePasswordReset(password: String): String? = when { - password.isBlank() -> null - password.length < MIN_PASSWORD_LENGTH -> "Password must be at least $MIN_PASSWORD_LENGTH characters" - else -> null -} - -/** - * Parses an optional quota field. Blank -> null (unlimited / unchanged); - * non-numeric or negative -> null. Zero is preserved (server treats 0 as - * "unlimited"/disabled depending on the field). - */ -fun parseQuota(raw: String): Int? = raw.trim().toIntOrNull()?.takeIf { it >= 0 } - -/** - * Parses a comma-separated list of library ids, tolerating surrounding - * whitespace and silently dropping non-numeric / negative entries. - */ -fun parseLibraryIds(raw: String): List = - raw.split(',') - .mapNotNull { it.trim().toIntOrNull()?.takeIf { id -> id >= 0 } } - -/** - * Like [parseLibraryIds] but returns null when the raw string is blank, - * signalling "keep current value" (omit from the request) rather than - * "revoke all libraries" (send an empty list). - */ -fun parseLibraryIdsOrNull(raw: String): List? = - if (raw.isBlank()) null else parseLibraryIds(raw) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModel.kt deleted file mode 100644 index e8768781b..000000000 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModel.kt +++ /dev/null @@ -1,119 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.errorMessage -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class AdminUsersUiState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val users: List = emptyList(), - val error: String? = null, - /** One-shot user-facing message after a mutation (toast/snackbar). */ - val message: String? = null, -) - -/** - * Shared admin users list ViewModel. Mirrors [AdminStatsViewModel]: - * generation-gated fetches, pull-to-refresh and server-message error surfacing. - * Owns the list + delete; create/edit are driven by [AdminUserEditViewModel], - * after which the list re-loads on screen re-entry. - */ -class AdminUsersViewModel( - private val repository: AdminRepository, -) : ViewModel() { - - private var loadGeneration = 0 - private val _uiState = MutableStateFlow(AdminUsersUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - init { load() } - - fun load() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, error = null) } - fetch(generation) - } - } - - fun refresh() { - val generation = ++loadGeneration - viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } - fetch(generation) - if (generation == loadGeneration) { - _uiState.update { it.copy(isRefreshing = false) } - } - } - } - - fun deleteUser(id: Int) { - viewModelScope.launch { - when (val result = repository.deleteUser(id)) { - is ApiResult.Success -> _uiState.update { s -> - s.copy(users = s.users.filter { it.id != id }, message = "User deleted") - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(message = result.errorMessage("Failed to delete user")) - } - } - } - } - - /** Update a user's role ("admin"/"user") via the admin update endpoint. */ - fun setRole(id: Int, role: String) { - viewModelScope.launch { - when (val result = repository.updateUser(id, UpdateUserRequest(role = role))) { - is ApiResult.Success -> _uiState.update { s -> - s.copy(users = s.users.map { if (it.id == id) result.data else it }, message = "Role updated") - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(message = result.errorMessage("Failed to update role")) - } - } - } - } - - /** Enable or disable a user account. */ - fun setEnabled(id: Int, enabled: Boolean) { - viewModelScope.launch { - when (val result = repository.updateUser(id, UpdateUserRequest(enabled = enabled))) { - is ApiResult.Success -> _uiState.update { s -> - s.copy( - users = s.users.map { if (it.id == id) result.data else it }, - message = if (enabled) "User enabled" else "User disabled", - ) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(message = result.errorMessage("Failed to update user")) - } - } - } - } - - /** Clears the one-shot [AdminUsersUiState.message] after it has been shown. */ - fun consumeMessage() = _uiState.update { it.copy(message = null) } - - private suspend fun fetch(generation: Int) { - val result = repository.getUsers() - if (generation != loadGeneration) return - when (result) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoading = false, users = result.data, error = null) - } - is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load users")) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModel.kt index 8e1ab48dd..44123416e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModel.kt @@ -52,17 +52,45 @@ data class CalendarUiState( days.firstOrNull { it.date == date }?.items.orEmpty() } +/** + * Remembers the user's Following / Trending / All choice across launches + * (iOS: `UserDefaults["calendar.filter"]`). Platforms back it with their + * preference store; tests use [InMemory]. + */ +interface CalendarFilterStore { + fun read(): String? + fun write(filter: String) + + class InMemory(private var value: String? = null) : CalendarFilterStore { + override fun read(): String? = value + override fun write(filter: String) { value = filter } + } +} + /** * Shared calendar/upcoming ViewModel (pattern: RequestsViewModels). The * platform supplies "today" and the IANA timezone so week math stays * deterministic in commonTest — no Clock.System defaults baked in. + * + * Responses are cached per (week, filter, library) for the ViewModel's + * lifetime and served stale-while-revalidate (iOS `CalendarViewModel`): + * paging back to a week or flipping a filter you have already seen renders + * instantly and quietly refreshes behind, instead of blanking the agenda. */ class CalendarViewModel( private val repository: CalendarRepository, private val timezoneId: String, private val todayProvider: () -> String, + private val filterStore: CalendarFilterStore = CalendarFilterStore.InMemory(), ) : ViewModel() { + private data class CacheKey(val weekStart: String, val filter: String, val libraryId: Int?) + + private val cache = HashMap>() + + private val CalendarUiState.cacheKey: CacheKey + get() = CacheKey(weekStart, filter, libraryId) + /** * Monotonically increasing counter incremented on every fetch start. * Each in-flight coroutine captures the value at launch time and skips @@ -81,6 +109,7 @@ class CalendarViewModel( today = today, weekStart = IsoDate.weekStart(today), selectedDay = today, + filter = filterStore.read()?.takeIf { it.isNotBlank() } ?: CalendarFilter.Following, ), ) uiState = _uiState.asStateFlow() @@ -95,7 +124,22 @@ class CalendarViewModel( // isCurrentWeek (which gates the Today button) stay accurate without // requiring the user to press Today first. weekStart is untouched so // the visible week — and thus the fetched range — doesn't shift. - _uiState.update { it.copy(isLoading = true, error = null, today = todayProvider()) } + _uiState.update { + // Stale-while-revalidate: a cached week renders immediately and + // is not "loading"; an unseen week clears the previous week's + // rows so they cannot show under the new strip while it loads. + val cached = cache[it.cacheKey] + it.copy( + isLoading = cached == null, + // A load that supersedes an in-flight refresh takes over + // the refresh flag too; the refresh coroutine will refuse + // to clear it once its generation is stale. + isRefreshing = false, + days = cached.orEmpty(), + error = null, + today = todayProvider(), + ) + } fetch(generation) } } @@ -103,6 +147,9 @@ class CalendarViewModel( fun refresh() { val generation = ++loadGeneration viewModelScope.launch { + // Pull-to-refresh is an explicit "get me fresh data": evict the + // cache entry so a failure cannot fall back to the stale copy. + cache.remove(_uiState.value.cacheKey) _uiState.update { it.copy(isRefreshing = true, error = null, today = todayProvider()) } fetch(generation) if (generation == loadGeneration) { @@ -134,6 +181,7 @@ class CalendarViewModel( fun setFilter(filter: String) { if (filter == _uiState.value.filter) return + filterStore.write(filter) _uiState.update { it.copy(filter = filter) } load() } @@ -167,11 +215,19 @@ class CalendarViewModel( // Discard the result if a newer fetch has already started. if (generation != loadGeneration) return when (result) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoading = false, days = result.data.events, error = null) + is ApiResult.Success -> { + cache[state.cacheKey] = result.data.events + _uiState.update { + it.copy(isLoading = false, days = result.data.events, error = null) + } } is ApiResult.Error, is ApiResult.NetworkError -> _uiState.update { - it.copy(isLoading = false, error = result.errorMessage("Failed to load calendar")) + // Keep showing cached rows on failure; only an empty screen + // becomes an error screen (iOS: error set only if days.isEmpty). + it.copy( + isLoading = false, + error = if (it.days.isEmpty()) result.errorMessage("Failed to load calendar") else null, + ) } } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt index 1c97b191f..59b704964 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingViewModel.kt @@ -20,8 +20,21 @@ data class DevicePairingUiState( val completedStatus: String? = null, val error: String? = null, ) { - val canSubmit: Boolean - get() = !isSubmitting && (token?.isNotBlank() == true || code.isNotBlank()) + /** + * Whether approving or denying is a real, informed decision right now. + * + * The identifier being present is not the test. A `prairie://device?token=…` + * deep link arrives with a token already set and starts its lookup + * automatically, so "there is something to submit" is true from + * construction — before the server has said which device is asking, from + * where, or with which match code. Those details are the entire content of + * the decision, and they only exist once [lookup] resolves. Gating on the + * identifier let a viewer approve a sign-in they could not see, and kept + * approving available after a failed lookup had cleared [lookup] and + * reported the request invalid or expired. + */ + val canDecide: Boolean + get() = lookup != null && !isSubmitting } class DevicePairingViewModel( @@ -45,6 +58,11 @@ class DevicePairingViewModel( } fun onCodeChanged(value: String) { + // Retires any lookup in flight. Without this a late answer for the + // previous code repopulates the details after the viewer has typed a + // different one — showing them a device that is not the one they are + // being asked about. + lookupGeneration++ _uiState.update { it.copy( code = value.trim().uppercase(), @@ -55,6 +73,20 @@ class DevicePairingViewModel( } } + /** + * Bumped by every lookup, and by every decision. + * + * canDecide stays true while an EXISTING lookup refreshes — the previous + * result is deliberately left on screen rather than blanked — so a viewer + * can approve while a lookup is still in flight. If that lookup lands last + * it overwrites the outcome: a lookup error painted over a successful + * approval, or a decision's error quietly cleared. A decision is the more + * authoritative event, so starting one retires any lookup already running. + */ + private var lookupGeneration = 0 + /** Which lookup generation raised [DevicePairingUiState.isLoading]. */ + private var loadingOwner = 0 + fun lookup() { val current = _uiState.value val token = current.token?.takeIf { it.isNotBlank() } @@ -64,9 +96,24 @@ class DevicePairingViewModel( return } + val generation = ++lookupGeneration + loadingOwner = generation viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null, completedStatus = null) } - when (val result = repository.lookup(token = token, code = code)) { + val lookupResult = repository.lookup(token = token, code = code) + // Retired while in flight: a newer lookup or, more importantly, a + // decision has superseded this answer. Clearing isLoading is still + // this request's job, but nothing else it has to say is current. + if (generation != lookupGeneration) { + // Clear the loading flag only while this lookup still owns it. + // A newer lookup has raised it again for itself, and clearing + // it here would report that one as finished while it runs. + if (loadingOwner == generation) { + _uiState.update { it.copy(isLoading = false) } + } + return@launch + } + when (val result = lookupResult) { is ApiResult.Success -> { _uiState.update { it.copy(isLoading = false, lookup = result.data, error = null) @@ -107,6 +154,12 @@ class DevicePairingViewModel( return } + // Retires any lookup already running, before it can report back over + // the decision this is about to make. The retired lookup will not clear + // its own loading flag once it no longer owns the generation, so drop + // it here — otherwise the screen shows a spinner nothing will finish. + lookupGeneration++ + _uiState.update { it.copy(isLoading = false) } viewModelScope.launch { _uiState.update { it.copy(isSubmitting = true, error = null, completedStatus = null) } val result = if (approve) { diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt new file mode 100644 index 000000000..4a4fc5418 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydrator.kt @@ -0,0 +1,70 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.section.HomeSectionItemsResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** Complete-or-partial result shared by startup warmup and [HomeViewModel]. */ +data class HomeSectionHydration( + val sections: List, + val fullyResolved: Boolean, +) + +/** + * Keeps aggregate Home sections that already contain items and resolves only + * genuinely missing section payloads, with bounded fallback concurrency. + */ +suspend fun hydrateHomeSections( + sections: List, + maxConcurrency: Int = 4, + fetchItems: suspend (String) -> ApiResult, +): HomeSectionHydration { + require(maxConcurrency > 0) { "maxConcurrency must be positive" } + + val unresolved = sections.filter { it.items.isEmpty() && it.totalCount > 0 } + val semaphore = Semaphore(maxConcurrency) + val fallbackById = coroutineScope { + unresolved.map { section -> + async { + section.id to semaphore.withPermit { + when (val result = fetchItems(section.id)) { + is ApiResult.Success -> resolveHomeSectionItems(section, result.data) + is ApiResult.Error, + is ApiResult.NetworkError -> null + } + } + } + }.awaitAll().toMap() + } + + return HomeSectionHydration( + sections = sections.mapNotNull { section -> + when { + section.items.isNotEmpty() -> section + section.totalCount == 0 -> null + else -> fallbackById[section.id]?.takeIf { it.items.isNotEmpty() } + } + }, + fullyResolved = unresolved.all { fallbackById[it.id] != null }, + ) +} + +private fun resolveHomeSectionItems( + original: ResolvedSection, + response: HomeSectionItemsResponse, +): ResolvedSection? { + val responseSection = response.section + return when { + responseSection != null && responseSection.items.isNotEmpty() -> responseSection + responseSection != null && responseSection.totalCount == 0 -> responseSection + responseSection != null && response.items.isNotEmpty() -> + responseSection.copy(items = response.items) + response.items.isNotEmpty() -> original.copy(items = response.items) + else -> null + } +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt index 5059e8516..22a879c6c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModel.kt @@ -7,13 +7,14 @@ import org.prairieserver.prairie.model.catalog.MediaItemUserState import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.model.section.SectionItem import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.repository.SectionRepository import org.prairieserver.prairie.repository.port.HomeCachePort +import org.prairieserver.prairie.repository.port.HomeCacheWriteLease import org.prairieserver.prairie.repository.port.NoOpHomeCachePort import org.prairieserver.prairie.repository.port.NoOpUserItemStatePort import org.prairieserver.prairie.repository.port.UserItemStatePort -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,6 +25,18 @@ data class HomeUiState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, val sections: List = emptyList(), + /** + * Whether [sections] is the whole picture, or rows are still arriving. + * + * Surfaces that restore focus by identity need this: while hydration is + * still filling rows, a launch row can simply be absent, and "absent" has + * to mean "not here YET" rather than "gone" — otherwise focus is driven to + * the nearest survivor, which is a card the viewer never opened. + * + * Defaults true because a caller that does not know is describing a + * finished list; only a partial publish sets it false. + */ + val sectionsFullyResolved: Boolean = true, val error: String? = null, ) @@ -45,6 +58,7 @@ class HomeViewModel( // Live-home accelerator (Apple realtime-updates spec). Null keeps // commonMain/tests network-only; the apps inject the shared coordinator. private val homeRealtime: org.prairieserver.prairie.repository.HomeRealtimeCoordinator? = null, + private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -61,6 +75,19 @@ class HomeViewModel( private var realtimeRefreshInFlight = false + /** + * Bumped by every fetch, checked before any of them publishes. + * + * loadSections(), refresh() and refreshFromRealtime() can all be in flight + * at once — a resume observer fires while an initial load is still + * running — and each captures hadSections BEFORE its network call. Without + * ordering, an older partial response lands after a newer complete one, + * replaces good sections and marks them not fully resolved, which now also + * tells the TV's focus restoration to keep waiting for rows that already + * arrived. + */ + private var fetchGeneration = 0 + /** * Debounced realtime refetch: quiet (no spinner) and single-flight — * an in-flight realtime or manual refresh already delivers the fresh @@ -82,7 +109,15 @@ class HomeViewModel( viewModelScope.launch { // Stale-while-revalidate: serve the cached home instantly (offline- // capable), then refresh from the network below. + // Captured BEFORE the cached read, which suspends: a refresh can + // start and publish fresh sections while we are in there, and + // overlaying the cache on top would put stale rows back on screen. + val bootstrapGeneration = fetchGeneration val cached = homeCache.getCachedHome() + if (fetchGeneration != bootstrapGeneration) { + fetchSections() + return@launch + } if (cached != null && cached.sections.isNotEmpty()) { val overlaid = overlayLocalState(cached.sections) _uiState.update { it.copy(isLoading = false, sections = overlaid, error = null) } @@ -96,8 +131,14 @@ class HomeViewModel( fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true, error = null) } - fetchSections() - _uiState.update { it.copy(isRefreshing = false) } + val generation = fetchSections() + // Only the newest fetch may clear the flag. A superseded refresh + // clearing it hides the spinner while a newer fetch is still + // running, and re-opens refreshFromRealtime's single-flight gate so + // it fires a redundant request. + if (generation == fetchGeneration) { + _uiState.update { it.copy(isRefreshing = false) } + } } } @@ -116,9 +157,16 @@ class HomeViewModel( ) } - private suspend fun fetchSections() { + /** + * Runs one home fetch and returns the generation it ran as, so callers can + * tell whether their own work is still the newest before acting on it. + */ + private suspend fun fetchSections(): Int { + val requestIdentityGeneration = identityTransitions.generation.value + val cacheWriteLease = HomeCacheWriteLease(requestIdentityGeneration) // Whether we already have something to show (cached or prior fetch) — if a // refresh fails we keep it rather than replacing it with a blocking error. + val generation = ++fetchGeneration val hadSections = _uiState.value.sections.isNotEmpty() when (val result = sectionRepository.getHomeSections()) { is ApiResult.Success -> { @@ -130,61 +178,55 @@ class HomeViewModel( // re-downloading data already in hand. Defensive fallback resolves // only sections the server left un-inlined (older deployments / a // section type that reports a non-zero total but ships no items). - val needsFetch = sections.filter { it.items.isEmpty() && it.totalCount > 0 } - val resolvedPairs: List> = if (needsFetch.isEmpty()) { - sections.map { it to true } - } else { - val byId = needsFetch.map { section -> - viewModelScope.async { - section.id to when (val itemsResult = sectionRepository.getHomeSectionItems(section.id)) { - is ApiResult.Success -> { - // The response carries items either nested under - // `section` or as a sibling top-level `items` list. - // Honor both — using only `.section` silently drops - // a successful refetch that returned items at the top - // level, leaving the section empty and filtered out. - val data = itemsResult.data - val responseSection = data.section - val hydrated = when { - responseSection != null && responseSection.items.isNotEmpty() -> - responseSection - responseSection != null && responseSection.totalCount == 0 -> - responseSection - responseSection != null && data.items.isNotEmpty() -> - responseSection.copy(items = data.items) - data.items.isNotEmpty() -> - section.copy(items = data.items) - else -> null - } - if (hydrated != null) hydrated to true else section to false - } - else -> section to false - } - } - }.awaitAll().toMap() - sections.map { section -> byId[section.id] ?: (section to true) } + val hydration = hydrateHomeSections(sections) { sectionId -> + sectionRepository.getHomeSectionItems(sectionId) } - val resolved = resolvedPairs.map { it.first }.filter { it.items.isNotEmpty() } + // Superseded while in flight: a newer fetch has already + // answered, so this reply describes a home nobody is looking at. + if (generation != fetchGeneration) return generation + val resolved = hydration.sections // Don't persist a partially-resolved home over a good cached one. - val fullyResolved = resolvedPairs.all { it.second } + val fullyResolved = hydration.fullyResolved // Cache the RAW server sections (snapshot), but display with the // local optimistic overlay applied. - if (fullyResolved) { - homeCache.cacheHome(resolved) + if ( + fullyResolved && + // A superseded fetch must not write its sections to the + // cache either: the next cold start would serve them. + generation == fetchGeneration && + requestIdentityGeneration == identityTransitions.generation.value + ) { + homeCache.cacheHome(resolved, cacheWriteLease) } val overlaid = overlayLocalState(resolved) + // Checked AGAIN, after the cache write and the overlay. Both + // suspend, and a newer fetch can complete and publish during + // either — so a check taken before them proves only that this + // reply was current when it arrived, not that it still is when + // it finally writes. + if (generation != fetchGeneration) return generation _uiState.update { // Only replace what's shown when the fetch fully resolved (or there // was nothing yet) — a partial refresh must not clobber a good Home. if (fullyResolved || !hadSections) { - it.copy(isLoading = false, sections = overlaid, error = null) + it.copy( + isLoading = false, + sections = overlaid, + error = null, + sectionsFullyResolved = fullyResolved, + ) } else { + // The partial result is discarded and the previous, good + // sections stay on screen — so the flag keeps describing + // THOSE, which were complete when they were published. it.copy(isLoading = false, error = null) } } } is ApiResult.Error -> { + // A superseded fetch's failure is not this home's failure. + if (generation != fetchGeneration) return generation _uiState.update { it.copy( isLoading = false, @@ -195,6 +237,7 @@ class HomeViewModel( } } is ApiResult.NetworkError -> { + if (generation != fetchGeneration) return generation _uiState.update { it.copy( isLoading = false, @@ -203,6 +246,7 @@ class HomeViewModel( } } } + return generation } // -- Card context-menu actions -- diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModels.kt index d4790f1d6..67bbc225a 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModels.kt @@ -3,8 +3,11 @@ package org.prairieserver.prairie.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.CatalogEffectiveSort +import org.prairieserver.prairie.model.catalog.CatalogQueryGroup import org.prairieserver.prairie.model.catalog.CatalogResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.repository.CatalogRepository import org.prairieserver.prairie.repository.PersonalDataRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -12,6 +15,24 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** + * Sort + filter applied to a personal list. + * + * The default — no sort, no facet groups — is the server's stored list order + * (most recently saved first). Favorites and watchlist fetch through + * `/catalog?source=…`, the only route that accepts sort and facets. + */ +data class PersonalListQuery( + /** null = send no sort, i.e. keep the server's stored list order. */ + val sort: String? = null, + val order: String? = null, + val queryGroups: List = emptyList(), + /** "all" | "any"; only meaningful when [queryGroups] is non-empty. */ + val match: String? = null, +) { + val isDefault: Boolean get() = sort == null && queryGroups.isEmpty() +} + /** * Shared UI state for paginated personal lists (favorites, watchlist, history). */ @@ -23,6 +44,9 @@ data class PersonalListUiState( val error: String? = null, val hasMore: Boolean = false, val total: Int = 0, + val query: PersonalListQuery = PersonalListQuery(), + /** What the server says it sorted by, when it reports one. */ + val effectiveSort: CatalogEffectiveSort? = null, ) /** @@ -47,33 +71,115 @@ abstract class PersonalListViewModel( var hasLoadedOnce: Boolean = false private set - protected abstract suspend fun fetchPage(offset: Int, limit: Int): ApiResult + /** + * The sort/filter every fetch runs under. Mirrored into the UI state so + * screens can render the controls from one source of truth. + */ + protected var query: PersonalListQuery = PersonalListQuery() + private set + + protected abstract suspend fun fetchPage( + offset: Int, + limit: Int, + query: PersonalListQuery, + ): ApiResult protected fun loadInitial() { load(reset = true) } + /** + * Swap the sort/filter and reload from zero. The reset bumps the content + * generation, so any page still in flight under the previous query is + * dropped when it lands rather than mixed into the new list. + */ + fun applyQuery(newQuery: PersonalListQuery) { + if (newQuery == query) return + query = newQuery + // The old query's rows must not stand in for the new one — not while + // it loads, and not if it fails (the grid would silently keep showing + // cards that do not match the selected sort/filters). + _uiState.update { it.copy(query = newQuery, items = emptyList(), total = 0, hasMore = false) } + load(reset = true) + } + fun loadMore() { val state = _uiState.value - if (state.isLoading || state.isLoadingMore || !state.hasMore) return + // isRefreshing too: refresh reloads from offset zero, so a page fetched + // alongside it uses an offset the replacement invalidates. + if (state.isLoading || state.isLoadingMore || state.isRefreshing || !state.hasMore) return load(reset = false) } + /** + * Bumped by every load that REPLACES the list — a reset or a refresh. + * + * Gating the triggers is not enough on its own. A page can already be in + * flight when a refresh starts, and refresh has no way to cancel it; when + * that page lands it appends items fetched at `offset = N` on top of a list + * that is now page one, leaving a hole where the middle used to be. Checking + * the generation on the way OUT is what makes a superseded page harmless, + * whichever order the two requests finish in. + */ + private var contentGeneration = 0 + + /** + * Which request currently owns each loading flag. + * + * Generation alone cannot answer this. A reset owns isLoading and a refresh + * owns isRefreshing, so when one supersedes the other the newer request + * clears a DIFFERENT flag from the one the superseded request set — and the + * superseded one, told that "the newer replacement owns those flags", + * cleared nothing. A reset overtaken by a refresh therefore left isLoading + * true forever, and the surface spinning. + * + * Every request releases exactly the flag it claimed, and only while it is + * still the claimant. + */ + private var requestSequence = 0 + private var loadingOwner = 0 + private var refreshingOwner = 0 + private var loadingMoreOwner = 0 + fun retry() = load(reset = true) fun refresh() { + // Claimed synchronously, for the same reason as load(). + val generation = ++contentGeneration + val requestId = ++requestSequence + refreshingOwner = requestId + _uiState.update { it.copy(isRefreshing = true, error = null) } viewModelScope.launch { - _uiState.update { it.copy(isRefreshing = true, error = null) } val offset = 0 - when (val r = fetchPage(offset, pageSize)) { - is ApiResult.Success -> _uiState.update { + val result = fetchPage(offset, pageSize, query) + // A newer replacement started while this refresh was in flight. + // Release isRefreshing unless a newer REFRESH has re-claimed it — + // a superseding reset owns isLoading instead and would not clear + // this one on its way past. + if (generation != contentGeneration) { + if (refreshingOwner == requestId) { + _uiState.update { it.copy(isRefreshing = false) } + } + return@launch + } + when (val r = result) { + is ApiResult.Success -> { + // A refresh that publishes content has loaded once, whatever + // the initial load did. Screens gate their resume re-fetch + // on this flag, so leaving it false when a refresh overtakes + // that load disables the resume refresh for the whole life + // of the view model. + hasLoadedOnce = true + _uiState.update { it.copy( items = r.data.items, hasMore = r.data.hasMore, total = r.data.total, + effectiveSort = r.data.effectiveSort, isRefreshing = false, error = null, ) + } } is ApiResult.Error, is ApiResult.NetworkError -> { _uiState.update { it.copy(isRefreshing = false) } @@ -83,14 +189,48 @@ abstract class PersonalListViewModel( } private fun load(reset: Boolean) { + // Offset, generation and loading flag are all claimed SYNCHRONOUSLY, + // before the coroutine is launched. Doing it inside the launch left a + // window where loadMore() could see an idle list, queue itself, and + // have refresh() run first — the paging coroutine would then capture + // the refresh's generation, look current, and append its old-offset + // page anyway. Claiming here also makes the guard in loadMore() mean + // something: the flag is set by the time a second call can read it. + val state = _uiState.value + val offset = if (reset) 0 else state.items.size + val generation = if (reset) ++contentGeneration else contentGeneration + val requestId = ++requestSequence + if (reset) loadingOwner = requestId else loadingMoreOwner = requestId + _uiState.update { + if (reset) it.copy(isLoading = true, error = null) + else it.copy(isLoadingMore = true) + } + // Captured with the offset: a query swap mid-flight must not make this + // page's items describe a different list from the one it asked for. + val requestQuery = query viewModelScope.launch { - val state = _uiState.value - val offset = if (reset) 0 else state.items.size - _uiState.update { - if (reset) it.copy(isLoading = true, error = null) - else it.copy(isLoadingMore = true) + val result = fetchPage(offset, pageSize, requestQuery) + // Superseded WHILE IN FLIGHT: something replaced the list, so this + // page's offset no longer describes anything. Checked here rather + // than before the fetch — before it, there is nothing to be stale + // about. Dropping it silently is right: the replacement already + // published a coherent list, and applying this one's items or its + // error on top would only undo that. The loading flag still has to + // be released, because this request really has finished. + if (generation != contentGeneration) { + // Release this request's own flag, and only while it still owns + // it. A later request of the same kind has already re-claimed + // it and will clear it itself. + _uiState.update { + when { + reset && loadingOwner == requestId -> it.copy(isLoading = false) + !reset && loadingMoreOwner == requestId -> it.copy(isLoadingMore = false) + else -> it + } + } + return@launch } - when (val r = fetchPage(offset, pageSize)) { + when (val r = result) { is ApiResult.Success -> { hasLoadedOnce = true _uiState.update { @@ -100,6 +240,7 @@ abstract class PersonalListViewModel( items = if (reset) r.data.items else it.items + r.data.items, hasMore = r.data.hasMore, total = r.data.total, + effectiveSort = r.data.effectiveSort ?: it.effectiveSort, error = null, ) } @@ -131,14 +272,26 @@ abstract class PersonalListViewModel( class FavoritesViewModel( private val personalDataRepository: PersonalDataRepository, + private val catalogRepository: CatalogRepository, ) : PersonalListViewModel() { init { loadInitial() } - override suspend fun fetchPage(offset: Int, limit: Int) = - personalDataRepository.listFavorites(offset = offset, limit = limit) + // Always the catalog resolver, even for the default query: it returns the + // same stored list order as the legacy `/favorites` route but also reports + // `total`, so an item count is available before any sort is applied. + override suspend fun fetchPage(offset: Int, limit: Int, query: PersonalListQuery) = + catalogRepository.browse( + source = "favorites", + sort = query.sort, + order = query.order, + offset = offset, + limit = limit, + queryGroups = query.queryGroups, + match = query.match, + ) fun toggleFavorite(itemId: String) { viewModelScope.launch { @@ -156,14 +309,26 @@ class FavoritesViewModel( class WatchlistViewModel( private val personalDataRepository: PersonalDataRepository, + private val catalogRepository: CatalogRepository, ) : PersonalListViewModel() { init { loadInitial() } - override suspend fun fetchPage(offset: Int, limit: Int) = - personalDataRepository.listWatchlist(offset = offset, limit = limit) + // Always the catalog resolver, even for the default query: it returns the + // same stored list order as the legacy `/watchlist` route but also reports + // `total`, so an item count is available before any sort is applied. + override suspend fun fetchPage(offset: Int, limit: Int, query: PersonalListQuery) = + catalogRepository.browse( + source = "watchlist", + sort = query.sort, + order = query.order, + offset = offset, + limit = limit, + queryGroups = query.queryGroups, + match = query.match, + ) fun removeFromWatchlist(itemId: String) { viewModelScope.launch { @@ -186,6 +351,7 @@ class HistoryViewModel( loadInitial() } - override suspend fun fetchPage(offset: Int, limit: Int) = + // History has no sort/filter surface, so the query is always the default. + override suspend fun fetchPage(offset: Int, limit: Int, query: PersonalListQuery) = personalDataRepository.listHistory(offset = offset, limit = limit) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt index 8c9ce4241..4572e880c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsViewModel.kt @@ -64,10 +64,7 @@ class RecommendationsViewModel( when (discoverResult) { is ApiResult.Success -> { - val sections = discoverResult.data.rows - .mapIndexed { index, row -> row.toResolvedSection(index) } - .filter { it.items.isNotEmpty() } - .sortedByDescending { it.title.equals("For You", ignoreCase = true) } + val sections = discoverResult.data.rows.toResolvedSections() _uiState.update { it.copy( @@ -103,12 +100,84 @@ class RecommendationsViewModel( } } - private fun DiscoverRow.toResolvedSection(index: Int): ResolvedSection = ResolvedSection( - id = "discover_${index}_${type}", - sectionType = type, - title = label, - itemLimit = items.size, - totalCount = items.size, - items = items, - ) } + +internal fun List.toResolvedSections(): List = + map(DiscoverRow::toResolvedSection) + .filter { it.items.isNotEmpty() } + .distinctBy(ResolvedSection::id) + .sortedByDescending { it.title.equals("For You", ignoreCase = true) } + +/** + * Modern servers provide a stable section kind, and for the kinds that can + * repeat (clusters, genres) a key alongside it. The other kinds are singletons + * and the server sends no key at all, so the kind alone IS their stable + * identity. + * + * Requiring a key would push exactly those rows onto the legacy path below, + * whose identity includes the row's contents — and "Popular" and "Recently + * Added" change contents constantly. Their section id would then change on + * every refresh, which is what the For You detail return matches on. + * + * Accepting a *bare* kind is not safe either: two keyless rows sharing a kind + * encode identically, and [toResolvedSections] resolves duplicates by dropping + * them, so a row would silently vanish from the feed. So the kind alone is + * trusted only for kinds this client knows to be singletons — see + * [SingletonServerSectionKinds]. A repeatable or unrecognised kind arriving + * without a key falls back to content identity, which is unique by + * construction. + * + * Servers that send no kind at all fall back the same way, because type+label + * alone is not unique. Length-prefixing every component keeps the encoding + * unambiguous even when a label contains separators. + */ +private fun DiscoverRow.toResolvedSection(): ResolvedSection = ResolvedSection( + id = stableSectionId(), + sectionType = type, + title = label, + // Discover rows carry no `featured` flag of their own, but the personalised + // "for-you-main" row is the one the server ranks highest for this profile, + // so it is the natural hero. Marking it here lets any client hero-render it + // through the shared [splitFeatured] path; clients that want a flat feed + // simply ignore the flag. + featured = sectionKind?.equals(ForYouMainSectionKind, ignoreCase = true) == true, + itemLimit = items.size, + totalCount = items.size, + items = items, +) + +private const val ForYouMainSectionKind = "for-you-main" + +/** + * Section kinds the server emits at most once per discover response, and + * therefore sends with no key. Mirrors `discoverRowSectionKey` in the server's + * `internal/api/handlers/recommendations.go`; the repeatable kinds it can + * return — `cluster` and `genre` — are deliberately absent, because those + * always carry a key and must never be identified by kind alone. + */ +private val SingletonServerSectionKinds = setOf( + ForYouMainSectionKind, + "similar-users", + "popular", + "recently-added", + "top-rated", +) + +private fun DiscoverRow.stableSectionId(): String { + val kind = sectionKind?.takeIf(String::isNotBlank) + val key = sectionKey?.takeIf(String::isNotBlank) + if (kind != null && (key != null || kind in SingletonServerSectionKinds)) { + return "discover:server:${encodeIdentityPart(kind)}${encodeIdentityPart(key.orEmpty())}" + } + + val itemIdentities = items + .map { item -> encodeIdentityPart(item.type) + encodeIdentityPart(item.contentId) } + .sorted() + .joinToString(separator = "") + return "discover:legacy:" + + encodeIdentityPart(type) + + encodeIdentityPart(label) + + encodeIdentityPart(itemIdentities) +} + +private fun encodeIdentityPart(value: String): String = "${value.length}:$value" diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt index fc9ef3705..abef171ee 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/RequestsViewModels.kt @@ -148,7 +148,23 @@ class RequestSearchViewModel( _uiState.update { it.copy(mediaType = value, error = null) } } - fun search(page: Int = 1) { + /** + * Re-run the current search WITHOUT clearing what is on screen. + * + * [search] blanks results before refetching, which is right for a new query + * and wrong for a refresh: a viewer returning from a request detail would + * watch the row empty and refill, and anything relying on those cards + * staying put — a focus restoration, most obviously — loses its target for + * the duration. Returning is also exactly when the results ARE stale, + * because creating a request in the detail changes the status this row + * shows, so skipping the refresh is not an option either. + */ + fun refreshInPlace() { + if (_uiState.value.submittedQuery.isBlank()) return + search(page = _uiState.value.page, preserveResults = true) + } + + fun search(page: Int = 1, preserveResults: Boolean = false) { val submittedState = _uiState.value val query = submittedState.query.trim() val mediaType = submittedState.mediaType?.takeUnless { it == RequestMediaType.All } @@ -174,10 +190,10 @@ class RequestSearchViewModel( it.copy( isLoading = true, submittedQuery = query, - results = emptyList(), + results = if (preserveResults) it.results else emptyList(), page = page, - totalPages = 1, - totalResults = 0, + totalPages = if (preserveResults) it.totalPages else 1, + totalResults = if (preserveResults) it.totalResults else 0, error = null, ) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt index fa4c97b58..2bb4c122b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatch.kt @@ -16,6 +16,12 @@ data class RoomDeliveryKey( val playbackSessionId: String, ) +data class RoomDeliveryEcho( + val connectionGeneration: Long, + val connectionEpoch: Long, + val playbackSessionId: String, +) + /** * Successful-delivery latches for one player controller. * @@ -45,6 +51,18 @@ class RoomDeliveryLatch { fun isAttached(key: RoomDeliveryKey?): Boolean = key != null && attached == key + /** + * Session-scoped traffic is safe only after the attach frame was delivered + * and the server echoed that exact session in a room snapshot. + */ + fun isServerAttached(key: RoomDeliveryKey?, echo: RoomDeliveryEcho?): Boolean = + key != null && + isAttached(key) && + echo != null && + echo.connectionGeneration == key.connectionGeneration && + echo.connectionEpoch == key.connectionEpoch && + echo.playbackSessionId == key.playbackSessionId + fun recordAttach(key: RoomDeliveryKey, delivered: Boolean) { if (delivered) attached = key } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt new file mode 100644 index 000000000..2968ea95a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryGateway.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest +import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.flow.StateFlow + +interface WatchTogetherEntryGateway { + val roomSnapshot: StateFlow + suspend fun createRoom(request: CreateRoomRequest): ApiResult + suspend fun joinRoom(request: JoinRoomRequest): ApiResult + suspend fun setSelection(request: SetSelectionRequest): ApiResult +} diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt new file mode 100644 index 000000000..d1b3a791f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicy.kt @@ -0,0 +1,23 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot + +enum class WatchTogetherEntryTarget { + Lobby, + Player, +} + +fun watchTogetherEntryTarget(room: RoomSnapshot): WatchTogetherEntryTarget = + if ( + !room.selectedContentId.isNullOrBlank() && + !(room.selfRole == MemberRole.Host && room.memberCount <= 1) + ) { + WatchTogetherEntryTarget.Player + } else { + WatchTogetherEntryTarget.Lobby + } + +fun resumableWatchTogetherRoom(room: RoomSnapshot?): RoomSnapshot? = + room?.takeIf { it.roomId.isNotBlank() && it.phase != RoomPhase.Ended } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipControllerTest.kt index 9e197a28d..43faa682c 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipControllerTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/IntroAutoSkipControllerTest.kt @@ -9,12 +9,25 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +/** + * The conformance suite for the intro-skip prompt. + * + * The oracle is the `never` / `ask` / `always` tables in the server repo's + * `docs/design/2026-08-16-intro-skip-mode.md` ("Prompt behaviour"); every row of + * them should be findable here by name. The same tables drive web, iOS and + * tvOS, so a divergence is meant to fail here rather than arrive as a bug + * report. + * + * Rebuffer filtering is deliberately absent: `playbackActive` reaches the + * controller already settled (see `SettlingFalseEdges`), so a stall shorter than + * the grace window never becomes a pause here at all. + */ @OptIn(ExperimentalCoroutinesApi::class) class IntroAutoSkipControllerTest { @@ -23,166 +36,495 @@ class IntroAutoSkipControllerTest { private lateinit var position: MutableStateFlow private lateinit var range: MutableStateFlow - private lateinit var enabled: MutableStateFlow + private lateinit var mode: MutableStateFlow private lateinit var introKey: MutableStateFlow - private lateinit var fired: MutableList + private lateinit var playing: MutableStateFlow - @BeforeTest - fun setup() { + /** Positions the controller seeked to on its own (the `always` skip). */ + private lateinit var seeks: MutableList + + private fun setup(startMode: IntroSkipMode) { position = MutableStateFlow(0.0) range = MutableStateFlow(introRange) - enabled = MutableStateFlow(true) + mode = MutableStateFlow(startMode) introKey = MutableStateFlow(key) - fired = mutableListOf() - } - - @AfterTest - fun teardown() { - // No explicit cleanup — TestScope structured concurrency handles it. + playing = MutableStateFlow(true) + seeks = mutableListOf() } - private fun TestScope.newController(countdown: Int = 5): IntroAutoSkipController { + private fun TestScope.newController( + startMode: IntroSkipMode, + countdown: Int = 5, + ): IntroAutoSkipController { + setup(startMode) val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) return IntroAutoSkipController(scope = scope, countdownSeconds = countdown).also { it.observe( position = position, introRange = range, - autoSkipEnabled = enabled, + mode = mode, introKey = introKey, - onAutoSkipFire = { to -> fired += to }, + // The real players move the position as a result of the seek, + // which is exactly what the `Skipped` pill has to survive. + onSeek = { to -> seeks += to; position.value = to }, + playbackActive = playing, ) } } + // ---- never --------------------------------------------------------- + + @Test + fun `never - entering an intro does nothing at all`() = runTest { + val controller = newController(IntroSkipMode.NEVER, countdown = 3) + runCurrent() + + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + + advanceTimeBy(10_000) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertTrue(seeks.isEmpty()) + } + + // ---- ask ----------------------------------------------------------- + @Test - fun `position inside intro with auto-skip enabled - emits CountingDown progression then fires`() = runTest { - val controller = newController(countdown = 3) + fun `ask - entering an intro offers the pill and it ticks down`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) runCurrent() assertEquals(IntroAutoSkipState.Hidden, controller.state.value) position.value = 35.0 runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(3), controller.state.value) + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) advanceTimeBy(1_000) runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(2), controller.state.value) + assertEquals(IntroAutoSkipState.Asking(2), controller.state.value) advanceTimeBy(1_000) runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(1), controller.state.value) + assertEquals(IntroAutoSkipState.Asking(1), controller.state.value) + assertTrue(seeks.isEmpty(), "ask never seeks on its own") + } + + @Test + fun `ask - the timer running out hides the pill without resolving the intro`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) + position.value = 35.0 + runCurrent() + + advanceTimeBy(3_000) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertTrue(seeks.isEmpty(), "the intro keeps playing") + + // Still inside the same intro: the offer has withdrawn itself and must + // not immediately come back. + position.value = 40.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + + // Scrubbing out and back in re-offers, with a full timer. + position.value = 95.0 + runCurrent() + position.value = 32.0 + runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + } + + @Test + fun `ask - Select seeks to the end, resolves, and does not re-offer on scrub back`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) + + assertEquals(introRange.end, controller.select()) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + + // The caller performs the seek it was handed. + position.value = introRange.end + runCurrent() + position.value = 40.0 + runCurrent() + assertEquals( + IntroAutoSkipState.Hidden, + controller.state.value, + "a resolved intro never offers again", + ) + + advanceTimeBy(10_000) + runCurrent() + assertTrue(seeks.isEmpty(), "the viewer's own skip is returned, never performed here") + } + + @Test + fun `ask - Back dismisses the pill, resolves the intro, and reports the press consumed`() = + runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) + + assertTrue(controller.dismiss(), "the first Back is consumed by the pill") + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertFalse(controller.dismiss(), "a second Back belongs to the player") + + // Resolved: playback stays where it was and the pill never returns. + position.value = 40.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + advanceTimeBy(10_000) + runCurrent() + assertTrue(seeks.isEmpty()) + } + + @Test + fun `ask - pause freezes the timer and play resumes it from the same value`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + advanceTimeBy(2_000) + runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + + playing.value = false + runCurrent() + assertEquals( + IntroAutoSkipState.Asking(3), + controller.state.value, + "the pill stays visible and holds its number", + ) + assertFalse(controller.timerRunning.value) + + advanceTimeBy(10_000) + runCurrent() + assertEquals( + IntroAutoSkipState.Asking(3), + controller.state.value, + "a frozen timer does not run down while paused", + ) + + playing.value = true + runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + assertTrue(controller.timerRunning.value) advanceTimeBy(1_000) runCurrent() + assertEquals(IntroAutoSkipState.Asking(2), controller.state.value) + + advanceTimeBy(2_000) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + } + + @Test + fun `ask - the timer does not start until playback is actually running`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) + playing.value = false + runCurrent() + + position.value = 35.0 + runCurrent() + assertEquals( + IntroAutoSkipState.Hidden, + controller.state.value, + "the pill and its fill start together, once playback is up", + ) + + advanceTimeBy(10_000) + runCurrent() assertEquals(IntroAutoSkipState.Hidden, controller.state.value) - assertEquals(listOf(introRange.end), fired) + + playing.value = true + runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) } @Test - fun `position inside intro with auto-skip disabled - emits ShowingButton only`() = runTest { - enabled.value = false - val controller = newController() + fun `ask - seeking out of the intro hides the pill without resolving it`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + advanceTimeBy(2_000) runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + position.value = 120.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertFalse(controller.timerRunning.value) + + advanceTimeBy(10_000) + runCurrent() + assertTrue(seeks.isEmpty()) + + // Not resolved: seeking back in offers again, from a full timer. position.value = 35.0 runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) + } + + @Test + fun `ask - a different intro gets its own offer`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) + position.value = 35.0 + runCurrent() + controller.dismiss() + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + + introKey.value = "session-1:file-2:30:90" + runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + } - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) + @Test + fun `no intro key - nothing is ever offered`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) + introKey.value = null + runCurrent() + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) advanceTimeBy(10_000) runCurrent() - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) - assertTrue(fired.isEmpty()) + assertTrue(seeks.isEmpty()) } + // ---- always -------------------------------------------------------- + @Test - fun `cancelCountdown transitions to ShowingButton and prevents fire for same key`() = runTest { - val controller = newController(countdown = 5) + fun `always - entering an intro skips it immediately and offers the undo`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 3) + runCurrent() + + position.value = 35.0 + runCurrent() + assertEquals(listOf(introRange.end), seeks) + assertEquals(IntroAutoSkipState.Skipped(3), controller.state.value) + + advanceTimeBy(1_000) runCurrent() + assertEquals(IntroAutoSkipState.Skipped(2), controller.state.value) + } + @Test + fun `always - the undo pill is anchored to the intro, not to the position`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 5) position.value = 35.0 runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(5), controller.state.value) + assertEquals(IntroAutoSkipState.Skipped(5), controller.state.value) + + // The seek already put the position past `end`; playback keeps moving. + position.value = 95.0 + runCurrent() + position.value = 140.0 + runCurrent() + assertEquals( + IntroAutoSkipState.Skipped(5), + controller.state.value, + "position changes must not take the undo down", + ) advanceTimeBy(1_000) runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(4), controller.state.value) + assertEquals(IntroAutoSkipState.Skipped(4), controller.state.value) + } + + @Test + fun `always - the timer running out resolves the intro`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 3) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Skipped(3), controller.state.value) + + advanceTimeBy(3_000) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertEquals(listOf(introRange.end), seeks) + + // Resolved — scrubbing back into it does not skip again. + position.value = 35.0 + runCurrent() + advanceTimeBy(10_000) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertEquals(listOf(introRange.end), seeks) + } - controller.cancelCountdown() + @Test + fun `always - Select seeks back to the start, resolves, and does not skip again`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 5) + position.value = 35.0 runCurrent() - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) + assertEquals(IntroAutoSkipState.Skipped(5), controller.state.value) - // Move position; controller should still consider key cancelled and only show button. - position.value = 50.0 + assertEquals(introRange.start, controller.select(), "the undo plays the intro") runCurrent() - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + // The caller performs the seek it was handed; the intro plays through. + position.value = introRange.start + runCurrent() + position.value = 45.0 + runCurrent() advanceTimeBy(10_000) runCurrent() - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) - assertTrue(fired.isEmpty()) + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertEquals( + listOf(introRange.end), + seeks, + "the intro is resolved, so re-entering it does not skip again", + ) } @Test - fun `position leaves intro mid-countdown - cancels without firing`() = runTest { - val controller = newController(countdown = 5) + fun `always - Back resolves the intro and playback continues past it`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 5) + position.value = 35.0 runCurrent() + assertEquals(IntroAutoSkipState.Skipped(5), controller.state.value) + + assertTrue(controller.dismiss()) + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertFalse(controller.dismiss()) position.value = 35.0 runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(5), controller.state.value) + advanceTimeBy(10_000) + runCurrent() + assertEquals(listOf(introRange.end), seeks) + } + @Test + fun `always - pause freezes the undo timer`() = runTest { + val controller = newController(IntroSkipMode.ALWAYS, countdown = 5) + position.value = 35.0 + runCurrent() advanceTimeBy(2_000) runCurrent() - assertEquals(IntroAutoSkipState.CountingDown(3), controller.state.value) + assertEquals(IntroAutoSkipState.Skipped(3), controller.state.value) - // Leave the intro range. - position.value = 95.0 + playing.value = false + runCurrent() + advanceTimeBy(10_000) + runCurrent() + assertEquals(IntroAutoSkipState.Skipped(3), controller.state.value) + + playing.value = true + runCurrent() + advanceTimeBy(1_000) + runCurrent() + assertEquals(IntroAutoSkipState.Skipped(2), controller.state.value) + } + + // ---- mode changes and reset ---------------------------------------- + + @Test + fun `ask to never mid-intro takes the pill down`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) + + mode.value = IntroSkipMode.NEVER runCurrent() assertEquals(IntroAutoSkipState.Hidden, controller.state.value) - // Even after the original countdown would have completed, no fire. advanceTimeBy(10_000) runCurrent() - assertTrue(fired.isEmpty()) + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + assertTrue(seeks.isEmpty()) + } + + @Test + fun `never to ask mid-intro offers the pill`() = runTest { + val controller = newController(IntroSkipMode.NEVER, countdown = 5) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) + + mode.value = IntroSkipMode.ASK + runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) } @Test - fun `reset clears cancelled keys - countdown re-engages on re-entry`() = runTest { - val controller = newController(countdown = 3) + fun `ask to always mid-intro skips it there and then`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) + position.value = 35.0 + runCurrent() + assertEquals(IntroAutoSkipState.Asking(5), controller.state.value) + + mode.value = IntroSkipMode.ALWAYS runCurrent() + assertEquals(listOf(introRange.end), seeks) + assertEquals(IntroAutoSkipState.Skipped(5), controller.state.value) + } + @Test + fun `reset clears resolved intros so new content starts fresh`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) position.value = 35.0 runCurrent() - controller.cancelCountdown() + controller.dismiss() runCurrent() - assertEquals(IntroAutoSkipState.ShowingButton, controller.state.value) + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) controller.reset() runCurrent() assertEquals(IntroAutoSkipState.Hidden, controller.state.value) - // Bounce position out then back in to retrigger the inside-range path. position.value = 5.0 runCurrent() position.value = 40.0 runCurrent() + assertEquals(IntroAutoSkipState.Asking(3), controller.state.value) + } - assertEquals(IntroAutoSkipState.CountingDown(3), controller.state.value) + @Test + fun `select and dismiss are no-ops when no pill is showing`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 3) + runCurrent() + assertNull(controller.select()) + assertFalse(controller.dismiss()) + assertEquals(IntroAutoSkipState.Hidden, controller.state.value) } @Test - fun `null introKey - emits Hidden`() = runTest { - introKey.value = null - val controller = newController() + fun `the countdown run counter advances on a fresh offer and on a resume`() = runTest { + val controller = newController(IntroSkipMode.ASK, countdown = 5) runCurrent() + val idle = controller.countdownRun.value position.value = 35.0 runCurrent() + val started = controller.countdownRun.value + assertTrue(started > idle, "a fresh offer re-anchors the fill") - assertEquals(IntroAutoSkipState.Hidden, controller.state.value) - advanceTimeBy(10_000) + advanceTimeBy(1_000) + runCurrent() + assertEquals(started, controller.countdownRun.value, "a plain tick does not re-anchor") + + playing.value = false + runCurrent() + assertEquals(started, controller.countdownRun.value) + + playing.value = true runCurrent() - assertTrue(fired.isEmpty()) + assertTrue( + controller.countdownRun.value > started, + "thawing re-anchors the fill's frame clock", + ) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdgesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdgesTest.kt new file mode 100644 index 000000000..7cf819af5 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/player/SettlingFalseEdgesTest.kt @@ -0,0 +1,130 @@ +package org.prairieserver.prairie.domain.player + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class SettlingFalseEdgesTest { + + private val grace = 1_500L + + @Test + fun `resuming is reported immediately`() = runTest { + val source = MutableStateFlow(false) + val seen = mutableListOf() + val job = launch { source.settlingFalseEdges(grace).toList(seen) } + advanceTimeBy(grace + 1) + runCurrent() + + source.value = true + runCurrent() + + assertEquals(listOf(false, true), seen, "a resume must not wait out the grace period") + job.cancel() + } + + /** + * The case the intro countdown cares about: a rebuffer dips isPlaying for a + * moment, and passing that through restarts the countdown from full. + */ + @Test + fun `a stall shorter than the grace period is swallowed`() = runTest { + val source = MutableStateFlow(true) + val seen = mutableListOf() + val job = launch { source.settlingFalseEdges(grace).toList(seen) } + runCurrent() + + source.value = false + advanceTimeBy(grace / 2) + runCurrent() + source.value = true + advanceTimeBy(grace * 2) + runCurrent() + + assertEquals(listOf(true), seen, "a brief rebuffer should never be reported as a pause") + job.cancel() + } + + @Test + fun `a pause that outlasts the grace period is reported`() = runTest { + val source = MutableStateFlow(true) + val seen = mutableListOf() + val job = launch { source.settlingFalseEdges(grace).toList(seen) } + runCurrent() + + source.value = false + advanceTimeBy(grace + 1) + runCurrent() + + assertEquals(listOf(true, false), seen) + job.cancel() + } + + @Test + fun `a deliberate pause is reported without waiting out the grace period`() = runTest { + val playing = MutableStateFlow(true) + val paused = MutableStateFlow(false) + val seen = mutableListOf() + val job = launch { playing.settlingFalseEdges(grace, paused).toList(seen) } + runCurrent() + + // The press flips isPaused at once; isPlaying follows from the player. + paused.value = true + playing.value = false + runCurrent() + + assertEquals( + listOf(true, false), + seen, + "a pause must report on the press, not $grace ms later", + ) + job.cancel() + } + + @Test + fun `a stall is still swallowed when the viewer has not paused`() = runTest { + val playing = MutableStateFlow(true) + val paused = MutableStateFlow(false) + val seen = mutableListOf() + val job = launch { playing.settlingFalseEdges(grace, paused).toList(seen) } + runCurrent() + + playing.value = false + advanceTimeBy(grace / 2) + runCurrent() + playing.value = true + advanceTimeBy(grace * 2) + runCurrent() + + assertEquals(listOf(true), seen, "the grace window must survive the pause bypass") + job.cancel() + } + + /** Repeated stutters must not accumulate into a reported pause. */ + @Test + fun `several short stalls in a row are each swallowed`() = runTest { + val source = MutableStateFlow(true) + val seen = mutableListOf() + val job = launch { source.settlingFalseEdges(grace).toList(seen) } + runCurrent() + + repeat(4) { + source.value = false + advanceTimeBy(grace / 3) + runCurrent() + source.value = true + advanceTimeBy(grace / 3) + runCurrent() + } + + assertEquals(listOf(true), seen) + job.cancel() + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt new file mode 100644 index 000000000..592b2ab85 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/domain/settings/ProfileSettingsControllerTest.kt @@ -0,0 +1,268 @@ +package org.prairieserver.prairie.domain.settings + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.EffectiveSettingValuesResponse +import org.prairieserver.prairie.model.settings.SettingScope +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.model.settings.SettingKeys +import org.prairieserver.prairie.model.settings.SettingsContractCapabilities +import org.prairieserver.prairie.model.settings.StoredSettingValue +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.SettingsApi +import org.prairieserver.prairie.network.api.SettingsCapabilitiesResult +import org.prairieserver.prairie.repository.SettingsRepository +import io.ktor.client.HttpClient +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ProfileSettingsControllerTest { + + private sealed class Call { + data class Put(val key: String, val scope: SettingScope, val value: JsonElement) : Call() + data class Delete(val key: String, val scope: SettingScope) : Call() + } + + private class FakeSettingsApi( + val capabilities: SettingsCapabilitiesResult = + SettingsCapabilitiesResult.Available(SettingsContractCapabilities(revision = 1)), + val effective: ApiResult = + ApiResult.Success(EffectiveSettingValuesResponse()), + val putResult: (String) -> ApiResult = { + ApiResult.Success(StoredSettingValue(key = it, scope = "profile")) + }, + val deleteResult: ApiResult = ApiResult.Success(Unit), + ) : SettingsApi(HttpClient()) { + + val calls = mutableListOf() + val mutationIds = mutableListOf() + + override suspend fun getContractCapabilities(): SettingsCapabilitiesResult = capabilities + + override suspend fun getEffectiveValues( + keys: List, + libraryIds: List, + seriesIds: List, + ): ApiResult = effective + + override suspend fun putValue( + key: String, + scope: SettingScopeIdentity, + value: JsonElement, + mutationId: String, + profileId: String?, + ): ApiResult { + calls += Call.Put(key, scope.scope, value) + mutationIds += mutationId + return putResult(key) + } + + override suspend fun deleteValue( + key: String, + scope: SettingScopeIdentity, + profileId: String?, + ): ApiResult { + calls += Call.Delete(key, scope.scope) + return deleteResult + } + } + + private fun controllerFor(api: SettingsApi) = + ProfileSettingsController(SettingsRepository(api)) + + @Test + fun `writes address scope profile with a mutation id`() = runTest { + val api = FakeSettingsApi() + controllerFor(api).setSubtitleMode("always") + + assertEquals>( + listOf( + Call.Put( + SettingKeys.PLAYBACK_SUBTITLE_MODE, + SettingScope.PROFILE, + JsonPrimitive("always"), + ), + ), + api.calls, + ) + assertTrue(api.mutationIds.single().isNotBlank(), "a write must carry an idempotency id") + } + + @Test + fun `an empty language clears the row rather than writing an empty string`() = runTest { + // The server's language_tag validator rejects "", and the contract + // spells "no preference" as the absence of a row. + val api = FakeSettingsApi() + val controller = controllerFor(api) + controller.setSubtitleLanguage("") + controller.setMetadataLanguage(" ") + + assertEquals>( + listOf( + Call.Delete(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, SettingScope.PROFILE), + Call.Delete(SettingKeys.CATALOG_METADATA_LANGUAGE, SettingScope.PROFILE), + ), + api.calls, + ) + } + + @Test + fun `a non-empty language is written as the tag`() = runTest { + val api = FakeSettingsApi() + controllerFor(api).setSubtitleLanguage(" nl ") + + assertEquals>( + listOf( + Call.Put( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + SettingScope.PROFILE, + JsonPrimitive("nl"), + ), + ), + api.calls, + ) + } + + @Test + fun `clearing an absent value succeeds`() = runTest { + // 404 means nothing was stored there, which is the state the caller + // asked for — reporting it as an error would roll the UI back from a + // change that did take effect. + val api = FakeSettingsApi( + deleteResult = ApiResult.Error(404, "not_found", "No value is set at this scope"), + ) + assertTrue(controllerFor(api).setSubtitleLanguage("").succeeded) + } + + @Test + fun `a failed write is reported so the caller can roll back`() = runTest { + val api = FakeSettingsApi( + putResult = { ApiResult.Error(400, "invalid_value", "nope") }, + ) + assertFalse(controllerFor(api).setShowForcedSubtitles(false).succeeded) + } + + @Test + fun `a successful write returns what the server actually resolves`() = runTest { + // A stored value is not necessarily the effective one: policy can + // narrow it, and a profile_device row outranks the profile row these + // setters write. Without the re-resolve the screen would keep showing + // the authored value while playback used the winning one. + val api = FakeSettingsApi( + effective = ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_MODE, + value = JsonPrimitive("always"), + source = "profile_device", + scope = "profile_device", + ), + ), + ), + ), + ) + + val result = controllerFor(api).setSubtitleMode("off") + + assertTrue(result.succeeded, "the write itself landed") + assertEquals( + "always", + result.snapshot?.subtitleMode, + "the caller must be handed the winning value, not the one it authored", + ) + } + + @Test + fun `a write whose re-resolve fails still reports success`() = runTest { + // The write landed; only the follow-up read did not. Reporting failure + // would roll the UI back from a change that did take effect. + val api = FakeSettingsApi( + effective = ApiResult.Error(500, "internal_error", "boom"), + ) + + val result = controllerFor(api).setSubtitleMode("off") + + assertTrue(result.succeeded) + assertEquals(null, result.snapshot, "no snapshot means: keep the optimistic value") + } + + @Test + fun `an old server reports upgrade required and no snapshot`() = runTest { + val api = FakeSettingsApi(capabilities = SettingsCapabilitiesResult.ServerUpgradeRequired) + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.SERVER_UPGRADE_REQUIRED, result.availability) + assertEquals(null, result.snapshot, "values must not be invented for a server that has none") + } + + @Test + fun `load resolves the profile keys from the effective response`() = runTest { + val api = FakeSettingsApi( + effective = ApiResult.Success( + EffectiveSettingValuesResponse( + settings = listOf( + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + value = JsonPrimitive("nl"), + source = "explicit", + scope = "profile", + suggestedValues = listOf("en", "nl", "pt-BR"), + ), + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SUBTITLE_MODE, + value = JsonPrimitive("always"), + ), + EffectiveSettingValue( + key = SettingKeys.PLAYBACK_SHOW_FORCED_SUBTITLES, + value = JsonPrimitive(false), + ), + // A null language tag is "no preference", which the + // UI spells as the empty string. + EffectiveSettingValue( + key = SettingKeys.CATALOG_METADATA_LANGUAGE, + value = JsonNull, + ), + ), + ), + ), + ) + + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.AVAILABLE, result.availability) + val snapshot = result.snapshot!! + assertEquals("nl", snapshot.subtitleLanguage) + assertEquals("always", snapshot.subtitleMode) + assertEquals(false, snapshot.showForcedSubtitles) + assertEquals("", snapshot.metadataLanguage) + assertEquals(listOf("en", "nl", "pt-BR"), snapshot.subtitleLanguageSuggestions) + } + + @Test + fun `absent keys fall back to the contract defaults`() = runTest { + val snapshot = controllerFor(FakeSettingsApi()).load().snapshot!! + + assertEquals("", snapshot.subtitleLanguage) + // The legacy empty string means unset, not a fourth mode. + assertEquals("auto", snapshot.subtitleMode) + // show_forced_subtitles defaults true server-side; defaulting false + // would silently turn forced subtitles off for untouched profiles. + assertEquals(true, snapshot.showForcedSubtitles) + } + + @Test + fun `a failed resolve reports unavailable rather than empty values`() = runTest { + val api = FakeSettingsApi(effective = ApiResult.Error(500, "internal_error", "boom")) + val result = controllerFor(api).load() + + assertEquals(ProfileSettingsController.Availability.UNAVAILABLE, result.availability) + assertEquals(null, result.snapshot) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt index 0cf2f7281..8924e8aad 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/SharedModelsCoverageTest.kt @@ -153,7 +153,8 @@ class SharedModelsCoverageTest { Season(contentId = "s2", seasonNumber = 2, title = "Two", episodeCount = 8), Season(contentId = "s1", seasonNumber = 1, title = "One", episodeCount = 10, userData = SeasonUserData()), ) - assertEquals(listOf("s1", "s2", "s0"), seasons.sortedForDisplay().map { it.contentId }) + // Specials (season 0 / isSpecials) sort before regular seasons. + assertEquals(listOf("s0", "s1", "s2"), seasons.sortedForDisplay().map { it.contentId }) val browse = BrowseResponse(items = emptyList(), total = 0) assertEquals(0, json.decodeFromString(BrowseResponse.serializer(), json.encodeToString(BrowseResponse.serializer(), browse)).total) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicyTest.kt deleted file mode 100644 index 68c325202..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminClientPolicyTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.prairieserver.prairie.model.admin - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class AdminClientPolicyTest { - - @Test - fun `acting admins see the client admin surface`() { - // Product decision 2026-07-07: the stats dashboard (Apple parity - // surface) is exposed to acting admins. - assertTrue(shouldShowClientAdminSurface(isActingAdmin = true)) - } - - @Test - fun `client admin surfaces stay hidden for non admins`() { - assertFalse(shouldShowClientAdminSurface(isActingAdmin = false)) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminModelsSerializationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminModelsSerializationTest.kt deleted file mode 100644 index 926f6b9ac..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminModelsSerializationTest.kt +++ /dev/null @@ -1,365 +0,0 @@ -// shared/src/commonTest/kotlin/org/prairieserver/prairie/model/admin/AdminModelsSerializationTest.kt -package org.prairieserver.prairie.model.admin - -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class AdminModelsSerializationTest { - - // Mirrors PrairieJson (network/PrairieHttpClientImpl.kt). - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - explicitNulls = false - coerceInputValues = true - } - - @Test - fun `decodes admin stats with watch provider activity`() { - val payload = """ - { - "total_items": 1200, "total_files": 1500, "total_users": 8, - "total_movies": 400, "total_movie_files": 410, - "total_shows": 80, "total_show_files": 1090, - "active_streams": 3, "total_storage_bytes": 987654321012, - "watch_provider_activity": { - "trakt_connected_profiles": 5, "trakt_enabled_profiles": 4, - "trakt_export_enabled": 3, "trakt_scrobble_enabled": 2, - "last_sync_completed_at": "2026-06-12T08:00:00Z", - "sync_runs_24h": 12, "sync_errors_24h": 1, - "imported_watched_24h": 30, "imported_progress_24h": 7, - "exported_watched_24h": 9, "pending_exports": 2, - "failed_exports": 0, "open_scrobbles": 1, "scrobbles_24h": 14 - } - } - """.trimIndent() - - val stats = json.decodeFromString(AdminStats.serializer(), payload) - - assertEquals(1200, stats.totalItems) - assertEquals(987654321012L, stats.totalStorageBytes) - assertEquals(3, stats.activeStreams) - assertEquals(5L, stats.watchProviderActivity.traktConnectedProfiles) - assertEquals(14L, stats.watchProviderActivity.scrobbles24h) - assertEquals("2026-06-12T08:00:00Z", stats.watchProviderActivity.lastSyncCompletedAt) - } - - @Test - fun `decodes admin stats when watch provider activity omitted defaults to empty`() { - val payload = """ - {"total_items":0,"total_files":0,"total_users":0,"total_movies":0, - "total_movie_files":0,"total_shows":0,"total_show_files":0, - "active_streams":0,"total_storage_bytes":0} - """.trimIndent() - - val stats = json.decodeFromString(AdminStats.serializer(), payload) - - assertEquals(0L, stats.watchProviderActivity.traktConnectedProfiles) - assertNull(stats.watchProviderActivity.lastSyncCompletedAt) - } - - @Test - fun `decodes admin user with optional last_active_at present`() { - val payload = """ - { - "id": 7, "username": "alice", "email": "a@x.io", "role": "user", - "permissions": ["request"], "enabled": true, - "library_ids": [1,2], "max_playback_quality": "1080p", - "max_streams": 2, "max_transcodes": 1, "max_profiles": 5, - "download_allowed": true, "download_transcode_allowed": false, - "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-06-01T00:00:00Z", - "last_active_at": "2026-06-12T07:00:00Z" - } - """.trimIndent() - - val u = json.decodeFromString(AdminUser.serializer(), payload) - - assertEquals(7, u.id) - assertEquals(listOf("request"), u.permissions) - assertEquals(listOf(1, 2), u.libraryIds) - assertEquals("1080p", u.maxPlaybackQuality) - assertEquals(2, u.maxStreams) - assertEquals("2026-06-12T07:00:00Z", u.lastActiveAt) - } - - @Test - fun `decodes admin user with last_active_at absent`() { - val payload = """ - {"id":1,"username":"root","email":"r@x.io","role":"admin", - "permissions":[],"enabled":true,"library_ids":[], - "max_playback_quality":"original","max_streams":0,"max_transcodes":0, - "max_profiles":0,"download_allowed":false,"download_transcode_allowed":false, - "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"} - """.trimIndent() - - val u = json.decodeFromString(AdminUser.serializer(), payload) - assertNull(u.lastActiveAt) - assertTrue(u.permissions.isEmpty()) - } - - @Test - fun `decodes a bare array of admin users`() { - val payload = """ - [{"id":1,"username":"root","email":"r@x.io","role":"admin","permissions":[], - "enabled":true,"library_ids":[],"max_playback_quality":"original", - "max_streams":0,"max_transcodes":0,"max_profiles":0, - "download_allowed":false,"download_transcode_allowed":false, - "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}] - """.trimIndent() - - val users = json.decodeFromString(ListSerializer(AdminUser.serializer()), payload) - assertEquals(1, users.size) - assertEquals("root", users[0].username) - } - - @Test - fun `create user request omits null optional fields when encoded`() { - val req = CreateUserRequest( - username = "bob", - email = "b@x.io", - password = "secret", - role = "user", - permissions = listOf("request"), - createDefaultProfile = true, - libraryIds = listOf(3), - maxPlaybackQuality = "1080p", - ) - - val encoded = json.encodeToString(CreateUserRequest.serializer(), req) - - assertTrue("\"username\":\"bob\"" in encoded) - assertTrue("\"create_default_profile\":true" in encoded) - assertTrue("\"library_ids\":[3]" in encoded) - // explicitNulls = false → omitted optionals absent - assertTrue("max_streams" !in encoded) - assertTrue("default_profile_name" !in encoded) - assertTrue("download_allowed" !in encoded) - } - - // --- permissions null-omission tests (TDD: RED until model is fixed) --- - - @Test - fun `create user request with null permissions omits the permissions key entirely`() { - // Bug: when permissions=null the server applies auth.DefaultUserPermissions(). - // When permissions=[] the server treats the field as authoritative → zero perms. - // With explicitNulls=false the field must be List? = null so it is omitted. - val req = CreateUserRequest( - username = "dave", - email = "dave@x.io", - password = "secret1", - role = "user", - permissions = null, - ) - - val encoded = json.encodeToString(CreateUserRequest.serializer(), req) - - assertTrue( - "permissions" !in encoded, - "Expected 'permissions' key to be absent when null (server must apply defaults), but got: $encoded", - ) - } - - @Test - fun `create user request with explicit permissions list encodes the field`() { - val req = CreateUserRequest( - username = "eve", - email = "eve@x.io", - password = "secret1", - role = "user", - permissions = listOf("request", "download"), - ) - - val encoded = json.encodeToString(CreateUserRequest.serializer(), req) - - assertTrue( - "\"permissions\":[\"request\",\"download\"]" in encoded, - "Expected explicit permissions to be serialised, but got: $encoded", - ) - } - - @Test - fun `create user request built without explicit permissions defaults to null and omits key`() { - // This mirrors the AdminUserEditViewModel.create() path which does NOT pass permissions. - // The default must be null, not emptyList(), to avoid overriding server defaults. - val req = CreateUserRequest( - username = "frank", - email = "frank@x.io", - password = "secret1", - role = "user", - ) - - val encoded = json.encodeToString(CreateUserRequest.serializer(), req) - - assertTrue( - "permissions" !in encoded, - "Expected 'permissions' to be absent when using default value, but got: $encoded", - ) - } - - @Test - fun `update user request encodes only set fields (partial PUT)`() { - val req = UpdateUserRequest(enabled = false, maxStreams = 4) - val encoded = json.encodeToString(UpdateUserRequest.serializer(), req) - assertTrue("\"enabled\":false" in encoded) - assertTrue("\"max_streams\":4" in encoded) - assertTrue("username" !in encoded) - assertTrue("permissions" !in encoded) - assertTrue("password" !in encoded) - } - - @Test - fun `decodes a rich playback session row with full transcode detail`() { - val payload = """ - { - "session_id": "sess-9", "user_id": 3, "username": "alice", - "profile_id": "prof-2", "profile_name": "Alice", - "media_file_id": 88, "requested_media_file_id": 88, - "content_id": "c-1", "media_title": "Cold Harbor", "media_type": "episode", - "series_name": "Severance", "episode_name": "Cold Harbor", - "season_number": 2, "episode_number": 10, - "poster_url": "https://cdn/p.jpg", - "play_method": "transcode", "reporting_node": "node-a", - "node_display_name": "Node A", "file_duration": 3600, - "started_at": "2026-06-12T09:00:00Z", "updated_at": "2026-06-12T09:10:00Z", - "position_seconds": 612.5, "is_paused": false, - "has_playback_control": true, "client_ip": "10.0.0.5", - "audio_track_index": 1, "transcode_audio": true, "stream_bitrate_kbps": 8000, - "target_resolution": "1080p", "target_video_codec": "h264", - "target_audio_codec": "aac", "target_bitrate_kbps": 8000, - "transcode_hw_accel": "vaapi", - "source_container": "mkv", "source_bitrate_kbps": 20000, - "source_video_codec": "hevc", "source_video_resolution": "2160p", - "source_audio_codec": "truehd", "source_audio_channels": 8, - "source_audio_language": "eng", "source_audio_title": "Surround", - "source_audio_layout": "7.1", - "requested_video_codec": "h264", "requested_video_resolution": "1080p", - "video_decision": "transcode", "audio_decision": "transcode" - } - """.trimIndent() - - val s = json.decodeFromString(AdminSession.serializer(), payload) - - assertEquals("sess-9", s.sessionId) - assertEquals("transcode", s.playMethod) - assertEquals(true, s.hasPlaybackControl) - assertEquals(612.5, s.positionSeconds) - assertEquals(8000, s.streamBitrateKbps) - assertEquals("2160p", s.sourceVideoResolution) - assertEquals(8, s.sourceAudioChannels) - assertEquals("h264", s.targetVideoCodec) - assertEquals(3600, s.fileDuration) - } - - @Test - fun `decodes minimal session row defaulting omitted fields`() { - val payload = """ - {"session_id":"s1","user_id":1,"username":"u","profile_id":"p", - "media_file_id":1,"requested_media_file_id":1,"media_title":"M", - "media_type":"movie","play_method":"direct","reporting_node":"n", - "started_at":"2026-06-12T09:00:00Z","updated_at":"2026-06-12T09:00:00Z", - "position_seconds":0,"is_paused":false,"has_playback_control":false, - "audio_track_index":0,"transcode_audio":false} - """.trimIndent() - - val s = json.decodeFromString(AdminSession.serializer(), payload) - assertEquals("s1", s.sessionId) - assertNull(s.streamBitrateKbps) - assertNull(s.fileDuration) - assertEquals("", s.profileName) - assertNull(s.seasonNumber) - } - - @Test - fun `decodes app log page with entries and next_cursor`() { - val payload = """ - { - "entries": [ - {"id": 101, "timestamp": "2026-06-12T09:00:00Z", "level": "info", - "component": "scanner", "message": "scan complete", - "request_id": "req-1", "user_id": 3, "session_id": "sess-1", - "playback_session_id": "ps-1", "client_ip": "10.0.0.1", - "node_id": "node-a", "attrs": {"folder": "movies", "count": 12}} - ], - "next_cursor": "Y3Vyc29y" - } - """.trimIndent() - - val page = json.decodeFromString(AdminLogPage.serializer(), payload) - - assertEquals(1, page.entries.size) - assertEquals(101L, page.entries[0].id) - assertEquals("scanner", page.entries[0].component) - assertEquals(3, page.entries[0].userId) - assertEquals("Y3Vyc29y", page.nextCursor) - assertTrue(page.entries[0].attrs!!.containsKey("folder")) - } - - @Test - fun `decodes app log page without next_cursor and minimal entry`() { - val payload = """ - {"entries":[{"id":1,"timestamp":"2026-06-12T09:00:00Z","level":"warn", - "component":"http","message":"slow"}]} - """.trimIndent() - - val page = json.decodeFromString(AdminLogPage.serializer(), payload) - assertNull(page.nextCursor) - assertNull(page.entries[0].requestId) - assertNull(page.entries[0].userId) - assertNull(page.entries[0].attrs) - } - - @Test - fun `decodes audit log page`() { - val payload = """ - { - "entries": [ - {"id": 5, "timestamp": "2026-06-12T09:00:00Z", "client_ip": "10.0.0.2", - "user_id": 3, "impersonator_user_id": 1, "session_id": "sess-2", - "request_id": "req-9", "method": "POST", "path": "/api/v1/admin/users", - "path_pattern": "/api/v1/admin/users", "status_code": 201, - "user_agent": "prairie/1.0", "duration_ms": 42} - ], - "next_cursor": "Y3Vy" - } - """.trimIndent() - - val page = json.decodeFromString(AdminAuditPage.serializer(), payload) - - assertEquals(5L, page.entries[0].id) - assertEquals("POST", page.entries[0].method) - assertEquals(201, page.entries[0].statusCode) - assertEquals(1, page.entries[0].impersonatorUserId) - assertEquals("Y3Vy", page.nextCursor) - } - - @Test - fun `scan request encodes library_id and omits null path`() { - val req = ScanRequest(libraryId = 4) - val encoded = json.encodeToString(ScanRequest.serializer(), req) - assertTrue("\"library_id\":4" in encoded) - assertTrue("path" !in encoded) - } - - @Test - fun `decodes scan response and cancel response`() { - val scan = json.decodeFromString( - ScanResponse.serializer(), - """{"status":"scanning","mode":"incremental","library_id":4}""", - ) - assertEquals("scanning", scan.status) - assertEquals("incremental", scan.mode) - assertEquals(4, scan.libraryId) - - val cancel = json.decodeFromString( - ScanCancelResponse.serializer(), - """{"cancelled":2,"library_id":4}""", - ) - assertEquals(2, cancel.cancelled) - assertEquals(4, cancel.libraryId) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/AdminPermissionsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/AdminPermissionsTest.kt deleted file mode 100644 index d225be61c..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/auth/AdminPermissionsTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package org.prairieserver.prairie.model.auth - -import org.prairieserver.prairie.model.profile.Profile -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class AdminPermissionsTest { - - private fun user(role: String) = User( - id = 1, - username = "admin", - email = "admin@example.com", - role = role, - ) - - private fun profile(isPrimary: Boolean) = Profile( - id = "prof-1", - name = "Owner", - isPrimary = isPrimary, - ) - - @Test - fun `admin role on primary profile is acting admin`() { - assertTrue(isActingAdmin(user("admin"), profile(isPrimary = true))) - } - - @Test - fun `admin role on non-primary profile is not acting admin`() { - assertFalse(isActingAdmin(user("admin"), profile(isPrimary = false))) - } - - @Test - fun `admin role with null profile is acting admin (profile not yet resolved)`() { - assertTrue(isActingAdmin(user("admin"), null)) - } - - @Test - fun `non-admin role is never acting admin`() { - assertFalse(isActingAdmin(user("user"), profile(isPrimary = true))) - assertFalse(isActingAdmin(user("user"), null)) - } - - @Test - fun `null user is never acting admin`() { - assertFalse(isActingAdmin(null, profile(isPrimary = true))) - assertFalse(isActingAdmin(null, null)) - } - - @Test - fun `profile defaults is_primary to false when wire omits it`() { - val p = Profile(id = "p", name = "Kid") - assertFalse(p.isPrimary) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt new file mode 100644 index 000000000..fd3d1b5b3 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/SeasonDisplayOrderTest.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.model.catalog + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SeasonDisplayOrderTest { + private fun season( + number: Int, + specials: Boolean = false, + id: String = "season-$number-$specials", + title: String? = null, + ) = Season( + contentId = id, + seasonNumber = number, + isSpecials = specials, + title = title, + ) + + @Test + fun `specials sort before regular seasons`() { + val result = listOf(season(2), season(0), season(1)).sortedForDisplay() + assertEquals(listOf(0, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `specials flag is authoritative even for nonzero season number`() { + val result = listOf(season(1), season(99, specials = true), season(2)).sortedForDisplay() + assertEquals(listOf(99, 1, 2), result.map(Season::seasonNumber)) + } + + @Test + fun `ordinary opening selects first regular season`() { + val result = listOf(season(0), season(2), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(1, result?.seasonNumber) + } + + @Test + fun `requested specials remains selected`() { + val result = listOf(season(2), season(0), season(1)) + .initialSeasonForDisplay(preferredSeasonNumber = 0) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `specials-only series selects specials`() { + val result = listOf(season(0)) + .initialSeasonForDisplay(preferredSeasonNumber = null) + assertEquals(0, result?.seasonNumber) + } + + @Test + fun `duplicate season numbers use title as deterministic tie breaker`() { + val result = listOf( + season(1, id = "z-id", title = "Zulu"), + season(1, id = "a-id", title = "Alpha"), + ).sortedForDisplay() + + assertEquals(listOf("a-id", "z-id"), result.map(Season::contentId)) + } + + @Test + fun `duplicate season numbers and titles use content id as deterministic tie breaker`() { + val result = listOf( + season(1, id = "z-id", title = "Same"), + season(1, id = "a-id", title = "Same"), + ).sortedForDisplay() + + assertEquals(listOf("a-id", "z-id"), result.map(Season::contentId)) + } + + @Test + fun `ordinary opening aligns selected state and episode request on first regular season`() { + val plan = listOf(season(2), season(0), season(1)) + .initialSeasonDisplayPlan(preferredSeasonNumber = null) + + assertEquals(listOf(0, 1, 2), plan.seasons.map(Season::seasonNumber)) + assertEquals(1, plan.selectedSeasonNumber) + assertEquals(1, plan.episodeRequestSeasonNumber) + } + + @Test + fun `explicit specials aligns selected state and episode request`() { + val plan = listOf(season(2), season(0), season(1)) + .initialSeasonDisplayPlan(preferredSeasonNumber = 0) + + assertEquals(0, plan.selectedSeasonNumber) + assertEquals(0, plan.episodeRequestSeasonNumber) + } + + @Test + fun `specials-only opening aligns selected state and episode request`() { + val plan = listOf(season(0)) + .initialSeasonDisplayPlan(preferredSeasonNumber = null) + + assertEquals(0, plan.selectedSeasonNumber) + assertEquals(0, plan.episodeRequestSeasonNumber) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt index b45225b19..766b69e02 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/ClientSurfacePolicyTest.kt @@ -1,11 +1,11 @@ package org.prairieserver.prairie.model.feature import kotlin.test.Test -import kotlin.test.assertTrue +import kotlin.test.assertFalse class ClientSurfacePolicyTest { @Test - fun watchTogetherIsExposedInTheDetailOverflows() { - assertTrue(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) + fun watchTogetherCodeStaysPresentButHiddenFromUserMenus() { + assertFalse(CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt index 163cdf70d..cb9e660bb 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/navigation/MediaModeTest.kt @@ -185,7 +185,7 @@ class MediaModeTest { * Leaving it out of the video types did not degrade it — it erased it: the type * mapped to no [MediaMode], so the library never reached navigation, search or * browse on either platform and nothing indicated anything was missing. - * silo-apple hit the same thing (#93). + * prairie-apple hit the same thing (#93). */ class MixedLibraryModeTest { diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt new file mode 100644 index 000000000..50ce20ad9 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/onboarding/OnboardingModelsTest.kt @@ -0,0 +1,96 @@ +package org.prairieserver.prairie.model.onboarding + +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OnboardingModelsTest { + + @Test + fun flowRoundTripsWithSettingStepAndUnknownKind() { + val flow = OnboardingFlow( + version = 2, + tourId = "phone-welcome", + steps = listOf( + OnboardingStep( + id = "intro", + kind = "copy", + title = "Welcome", + body = "A short tour.", + illustration = "welcome", + ), + OnboardingStep( + id = "audio", + kind = "setting", + setting = OnboardingSettingSpec( + target = "setting", + key = "playback.audio_language", + control = "picker", + options = listOf( + OnboardingSettingOption(value = "en", label = "English"), + OnboardingSettingOption(value = "ja", label = "Japanese"), + ), + default = "en", + label = "Audio language", + ), + ), + OnboardingStep( + id = "future", + kind = "unknown_future_kind", + ), + ), + ) + + val decoded = PrairieJson.decodeFromString(OnboardingFlow.serializer(), PrairieJson.encodeToString(flow)) + + assertEquals(2, decoded.version) + assertEquals("phone-welcome", decoded.tourId) + assertEquals(3, decoded.steps.size) + assertEquals("copy", decoded.steps[0].kind) + assertEquals("welcome", decoded.steps[0].illustration) + assertEquals("playback.audio_language", decoded.steps[1].setting?.key) + assertEquals(2, decoded.steps[1].setting?.options?.size) + assertEquals("en", decoded.steps[1].setting?.default) + assertEquals("unknown_future_kind", decoded.steps[2].kind) + assertNull(decoded.steps[2].setting) + } + + @Test + fun stateAndProgressRequestRoundTrip() { + val state = OnboardingState( + tourId = "tv-welcome", + lastStep = "audio", + completedAt = null, + skippedAt = "2026-07-01T00:00:00Z", + done = true, + ) + val progress = OnboardingProgressRequest( + tourId = "tv-welcome", + lastStep = "audio", + completed = false, + skipped = true, + ) + + val decodedState = + PrairieJson.decodeFromString(OnboardingState.serializer(), PrairieJson.encodeToString(state)) + val decodedProgress = + PrairieJson.decodeFromString( + OnboardingProgressRequest.serializer(), + PrairieJson.encodeToString(progress), + ) + + assertEquals("tv-welcome", decodedState.tourId) + assertEquals("audio", decodedState.lastStep) + assertNull(decodedState.completedAt) + assertEquals("2026-07-01T00:00:00Z", decodedState.skippedAt) + assertTrue(decodedState.done) + + assertEquals("tv-welcome", decodedProgress.tourId) + assertEquals("audio", decodedProgress.lastStep) + assertFalse(decodedProgress.completed) + assertTrue(decodedProgress.skipped) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt new file mode 100644 index 000000000..69b24409c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/AutoSubtitleResolverTest.kt @@ -0,0 +1,269 @@ +package org.prairieserver.prairie.model.playback + +import org.prairieserver.prairie.model.catalog.SubtitleTrack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The single auto-subtitle resolver, pinned to the TV detail row's semantics — + * the behaviour the viewer sees and that QA signed off. These cases are ported + * from `TvPlaybackFormattingTest`'s Auto-preview suite with identical + * expectations, plus the Shield regression that motivated the extraction. + */ +class AutoSubtitleResolverTest { + + // --- the regression ------------------------------------------------- + + @Test + fun preferredLanguageAlwaysPicksTheExternalTextTrackOverAnEmbeddedBitmapOne() { + // Shield, direct-play MKV: embedded PGS "English (SDH)" + an external + // English SRT, preference English/Always. The detail row previewed the + // SRT; the player, ranking only Media3's mounted tracks, started the + // PGS. Over the full catalog the SRT wins — and its combined index is + // what the start request can carry. + val tracks = listOf( + SubtitleTrack(index = 2, codec = "hdmv_pgs_subtitle", language = "eng", title = "English (SDH)"), + SubtitleTrack(index = 0, codec = "srt", language = "eng", external = true), + ) + + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always", showForced = true), + ).selectedCandidate() + + // Externals occupy 0..n-1: the sidecar is combined index 0. + assertEquals(0, selected?.selectionIndex) + assertEquals("srt", selected?.codec) + } + + @Test + fun alwaysWithForcedEnabledStillPrefersTheFullDialogueTrack() { + // Shield (Supergirl): three English SubRip streams — Forced, plain + // (untitled), SDH — profile English/Always with "show forced" ON. + // Forced is a separate setting for the subtitles-otherwise-off case; + // it must not outrank the viewer's full-subtitle preference. + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "Forced", forced = true), + SubtitleTrack(index = 1, codec = "srt", language = "eng"), + SubtitleTrack(index = 2, codec = "srt", language = "eng", title = "SDH"), + ) + + for (mode in listOf("always", "auto")) { + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext( + preferredLanguage = "en", + mode = mode, + showForced = true, + audioLanguage = "ja", + ), + ).selectedCandidate() + assertEquals(1, selected?.selectionIndex, "mode=$mode") + } + } + + @Test + fun forcedIsStillTheLastResortWhenTheLanguageHasNothingElse() { + val tracks = listOf( + SubtitleTrack(index = 0, codec = "srt", language = "eng", title = "Forced", forced = true), + ) + val selected = resolveAutoSubtitle( + candidates = catalogAutoSubtitleCandidates(tracks), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always", showForced = true), + ).selectedCandidate() + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun aBitmapTrackStillWinsWhenItIsTheOnlyCandidate() { + // Bitmap stays deprioritised, never excluded. + val rows = listOf( + PlayerSubtitleInfo(index = 0, language = "eng", codec = "pgs", url = ""), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always", showForced = true), + ).selectedCandidate() + + assertEquals(0, selected?.selectionIndex) + } + + @Test + fun theServerInventoryResolvesInCombinedSpace() { + val rows = listOf( + PlayerSubtitleInfo(index = 0, language = "fre", codec = "webvtt", url = "", catalogLabel = "French"), + PlayerSubtitleInfo(index = 1, language = "eng", codec = "webvtt", url = "", catalogLabel = "English"), + ) + + val selected = resolveAutoSubtitle( + candidates = inventoryAutoSubtitleCandidates(rows), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ).selectedCandidate() + + assertEquals(1, selected?.selectionIndex) + } + + // --- ported detail-preview cases ------------------------------------- + + @Test + fun resolvesThePreferredLanguageWhenAudioIsAnother() { + val ordinal = autoOrdinal( + tracks = listOf(track(lang = "eng"), track(lang = "fre")), + context = AutoSubtitleContext(preferredLanguage = "fr", mode = "auto", audioLanguage = "eng"), + ) + assertEquals(1, ordinal) + } + + @Test + fun resolvesToNothingWhenAudioAlreadyMatchesThePreferredLanguage() { + assertNull( + autoOrdinal( + tracks = listOf(track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", audioLanguage = "eng"), + ), + ) + } + + @Test + fun resolvesTheForcedTrackWhenAudioMatchesAndForcedSubsAreOn() { + val ordinal = autoOrdinal( + tracks = listOf(track(lang = "eng"), track(lang = "eng", forced = true)), + context = AutoSubtitleContext( + preferredLanguage = "en", + mode = "auto", + showForced = true, + audioLanguage = "eng", + ), + ) + assertEquals(1, ordinal) + } + + @Test + fun modeOffResolvesToNothing() { + assertNull( + autoOrdinal( + tracks = listOf(track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "off"), + ), + ) + } + + @Test + fun anEmptyPreferredLanguageMeansNoSubtitles() { + assertNull( + autoOrdinal( + tracks = listOf(track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = "", mode = "auto"), + ), + ) + } + + @Test + fun noPreferenceUnderPlainAutoResolvesToNothing() { + assertNull( + autoOrdinal( + tracks = listOf(track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = null, mode = "auto"), + ), + ) + } + + @Test + fun alwaysWithNoPreferencePrefersFullDialogueOverForced() { + val ordinal = autoOrdinal( + tracks = listOf(track(lang = "fre", forced = true), track(lang = "fre")), + context = AutoSubtitleContext(preferredLanguage = null, mode = "always"), + ) + assertEquals(1, ordinal) + } + + @Test + fun fullDialogueBeatsSdh() { + val ordinal = autoOrdinal( + tracks = listOf(track(lang = "eng", title = "English SDH"), track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", audioLanguage = "jpn"), + ) + assertEquals(1, ordinal) + } + + @Test + fun dvbBitmapIsSkippedForATextTrack() { + val ordinal = autoOrdinal( + tracks = listOf( + track(lang = "fre", codec = "dvb_subtitle"), + track(lang = "fre", codec = "subrip"), + ), + context = AutoSubtitleContext(preferredLanguage = "fr", mode = "auto", audioLanguage = "eng"), + ) + assertEquals(1, ordinal) + } + + @Test + fun vobsubBitmapIsSkippedForATextTrack() { + val ordinal = autoOrdinal( + tracks = listOf( + track(lang = "eng", codec = "vobsub"), + track(lang = "eng", codec = "srt"), + ), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", audioLanguage = "jpn"), + ) + assertEquals(1, ordinal) + } + + @Test + fun aHindiCodeInTheTitleIsNotHearingImpaired() { + val ordinal = autoOrdinal( + tracks = listOf(track(lang = "eng", title = "EN - HI"), track(lang = "eng")), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "auto", audioLanguage = "jpn"), + ) + assertEquals(0, ordinal) + } + + @Test + fun anEmptyInventoryResolvesToNothing() { + assertEquals( + AutoSubtitleResolution.NoChange, + resolveAutoSubtitle( + candidates = emptyList(), + context = AutoSubtitleContext(preferredLanguage = "en", mode = "always"), + ), + ) + } + + @Test + fun theLanguageTableFoldsIso639BibliographicCodes() { + assertEquals("en", autoSubtitleLanguageKey("eng")) + assertEquals("fr", autoSubtitleLanguageKey("fra")) + assertEquals("fr", autoSubtitleLanguageKey("fre")) + assertEquals("pt", autoSubtitleLanguageKey("pt-BR")) + assertNull(autoSubtitleLanguageKey("und")) + assertNull(autoSubtitleLanguageKey(" ")) + } + + // ------------------------------------------------------------------ + + /** Catalog ordinal of the resolved track — no externals, so ordinal == combined. */ + private fun autoOrdinal( + tracks: List, + context: AutoSubtitleContext, + ): Int? = resolveAutoSubtitle(catalogAutoSubtitleCandidates(tracks), context) + .selectedCandidate() + ?.selectionIndex + + private fun track( + lang: String? = null, + codec: String? = null, + title: String? = null, + forced: Boolean = false, + external: Boolean = false, + ) = SubtitleTrack( + index = 0, + codec = codec, + language = lang, + title = title, + forced = forced, + external = external, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackModelsV2SerializationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackModelsV2SerializationTest.kt deleted file mode 100644 index 7280c408b..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackModelsV2SerializationTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package org.prairieserver.prairie.model.playback - -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class PlaybackModelsV2SerializationTest { - private val json = Json { - ignoreUnknownKeys = true - encodeDefaults = true - explicitNulls = false - coerceInputValues = true - } - - @Test - fun startRequestSerializesV1FlatFieldsAndV2Context() { - val request = StartPlaybackRequest( - fileId = 42, - profileId = "p1", - playMethod = "direct", - audioTrackIndex = 2, - subtitleTrackIndex = 3, - qualityPreference = "original", - preserveDirectAudioSelection = true, - codecsVideo = listOf("h264"), - codecsAudio = listOf("aac"), - containers = listOf("mp4"), - maxResolution = "2160p", - clientPlaybackContext = ClientPlaybackContext( - formFactor = "tv", - appVersion = "0.1.0", - engines = mapOf( - PlaybackEngineKind.MPV_DIRECT to EngineCapabilityEnvelope( - containers = listOf("mkv"), - subtitles = EngineSubtitleCapabilities(assStyling = true), - ), - ), - ), - ) - - val encoded = json.encodeToString(request) - - assertTrue(encoded.contains("\"codecs_video\"")) - assertTrue(encoded.contains("\"client_playback_context\"")) - assertTrue(encoded.contains("\"play_method\":\"direct\"")) - assertTrue(encoded.contains("\"quality_preference\":\"original\"")) - assertTrue(encoded.contains("\"subtitle_track_index\":3")) - assertTrue(encoded.contains("\"preserve_direct_audio_selection\":true")) - } - - @Test - fun sessionResponseDecodesWithAndWithoutPlaybackPlan() { - val legacy = json.decodeFromString( - """ - { - "session_id": "s1", - "user_id": 1, - "media_file_id": 42, - "play_method": "direct", - "stream_url": "/stream/s1", - "audio_track_index": 0 - } - """.trimIndent(), - ) - assertEquals(null, legacy.playbackPlan) - - val v2 = json.decodeFromString( - """ - { - "session_id": "s1", - "user_id": 1, - "media_file_id": 42, - "play_method": "direct", - "stream_url": "/stream/s1", - "audio_track_index": 0, - "playback_plan": { - "plan_id": "s1", - "protocol_version": 2, - "delivery": "original_http", - "engine": "mpv_direct", - "route_family": "compatibility_direct" - } - } - """.trimIndent(), - ) - assertNotNull(v2.playbackPlan) - assertEquals(PlaybackEngineKind.MPV_DIRECT, v2.playbackPlan?.engine) - } - - @Test - fun playerSubtitleInfoPreservesRealDownloadedSubtitleId() { - val subtitle = PlayerSubtitleInfo( - index = 4, - language = "en", - codec = "webvtt", - label = "Downloaded English", - source = "downloaded", - forced = false, - url = "/stream/s1/subtitles/4.vtt", - downloadId = 312, - ) - - val encoded = json.encodeToString(subtitle) - val decoded = json.decodeFromString(encoded) - - assertTrue(encoded.contains("\"download_id\":312")) - assertEquals(312, decoded.downloadId) - } - - @Test - fun legacyPlayerSubtitleInfoWithoutDownloadIdRemainsDecodable() { - val decoded = json.decodeFromString( - """ - { - "index": 4, - "language": "en", - "source": "downloaded", - "url": "/stream/s1/subtitles/4.vtt" - } - """.trimIndent(), - ) - - assertNull(decoded.downloadId) - } - - @Test - fun incompletePlaybackPlanDegradesToNullInsteadOfFailingTheResponse() { - // A present-but-incomplete plan (missing the required `plan_id`) must NOT - // throw and fail the ENTIRE session-start decode — it degrades to null so - // the client falls back to legacy V1 routing rather than refusing playback. - val decoded = json.decodeFromString( - """ - { - "session_id": "s1", - "user_id": 1, - "media_file_id": 42, - "play_method": "direct", - "stream_url": "/stream/s1", - "audio_track_index": 0, - "playback_plan": { - "delivery": "original_http", - "engine": "mpv_direct", - "route_family": "compatibility_direct" - } - } - """.trimIndent(), - ) - assertEquals("s1", decoded.sessionId) - assertEquals(null, decoded.playbackPlan) - } - - @Test - fun transcodeStartResponseModelsStreamOriginSeconds() { - val decoded = json.decodeFromString( - """ - { - "session_id": "s1", - "status": "ready", - "manifest_url": "/playback/transcode/s1/master.m3u8", - "player_start_seconds": 12.0, - "stream_origin_seconds": 10.0, - "timeline_offset_seconds": 2.0, - "can_seek_anywhere": false - } - """.trimIndent(), - ) - - assertEquals(10.0, decoded.streamOriginSeconds) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt index bb6b7b958..e792af7da 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackProtocolV3Test.kt @@ -12,11 +12,15 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue class PlaybackProtocolV3Test { + private val neutralServerFeatures = listOf( + PLAYBACK_PLAN_V3_FEATURE, + NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE, + ) private val plan = PlaybackPlanV3( planId = "plan-1", + planAttemptKey = "v3:0000000000000001", sessionId = "session-1", delivery = PlaybackDelivery.ORIGINAL_HTTP, - engine = PlaybackEngineKind.MEDIA3_DIRECT, stream = PlaybackStreamV3( url = "/api/v1/playback/session-1/stream", protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, @@ -36,12 +40,58 @@ class PlaybackProtocolV3Test { effectiveMediaFileId = 84, ) + @Test + fun removedDraftSidecarsAreIgnored() { + val decoded = PrairieJson.decodeFromString( + """ + { + "plan_id": "plan", + "delivery": "original_http", + "engine": "media3_direct", + "stream": {"url": "/stream/session", "protocol": "http_progressive"}, + "subtitle": { + "mode": "convert", + "track_id": "file:42:subtitle:0", + "sidecars": [{ + "track_id": "removed", + "index": 1, + "url": "/removed.srt", + "mime_type": "application/x-subrip", + "format": "srt" + }], + "artifact": { + "url": "/stream/session/subtitles/0.vtt", + "mime_type": "text/vtt", + "format": "vtt", + "timing_origin_seconds": 0 + } + }, + "decision_reason": "test" + } + """.trimIndent(), + ) + + assertTrue(decoded.subtitle.inventory.isEmpty()) + assertEquals("/stream/session/subtitles/0.vtt", decoded.subtitle.artifact?.url) + } + @Test fun missingProtocolFeatureRequiresServerUpgradeAndPreservesAllocatedSession() { val result = PlaybackDecisionResponseV3(sessionId = "legacy-session").validateForMedia3() assertEquals(PlaybackV3Validation.Incompatible("legacy-session"), result) } + @Test + fun preNeutralV3ServerRequiresUpgradeInsteadOfEnteringAnIncompatibleSession() { + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + sessionId = "draft-v3-session", + ).validateForMedia3() + + assertEquals(PlaybackV3Validation.Incompatible("draft-v3-session"), result) + } + @Test fun legacyPlanShapeDecodesTolerantlyBeforeCompatibilityGate() { val decoded = PrairieJson.decodeFromString( @@ -53,22 +103,207 @@ class PlaybackProtocolV3Test { } @Test - fun playablePlanMustUseMedia3() { + fun playablePlanValidatesOnDeliveryAlone() { + // The neutral contract names a delivery class, not a client engine, so + // there is nothing left for the client to disagree with the server + // about here: a well-formed plan on any delivery is playable. val playable = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan, ).validateForMedia3() assertIs(playable) - val stale = PlaybackDecisionResponseV3( + val hls = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, + stream = plan.stream.copy(protocol = PlaybackStreamProtocol.HLS), + ), + ).validateForMedia3() + assertIs(hls) + } + + @Test + fun playablePlanRequiresAServerMintedAttemptKey() { + val result = PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy(planAttemptKey = ""), + ).validateForMedia3() + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun playablePlanRejectsAGappedOrUndeliverableSubtitleInventory() { + val result = PlaybackDecisionResponseV3( + protocolVersion = 3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "track", + combinedIndex = 1, + source = "external", + delivery = "sidecar", + ), + ), + ), + ), + ).validateForMedia3() + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun selectedSubtitleMustResolveAgainstAnEmptyAuthoritativeInventory() { + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, - playbackPlan = plan.copy(engine = PlaybackEngineKind.MPV_DIRECT), + playbackPlan = plan.copy( + selectedTracks = SelectedPlaybackTracksV3( + subtitle = PlaybackTrackIdentityV3("missing-track", 0), + ), + subtitle = PlaybackSubtitleDecisionV3(inventory = emptyList()), + ), ).validateForMedia3() - assertIs(stale) + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun subtitleArtifactRequiresASelectedInventoryIdentity() { + val track = PlaybackSubtitleInventoryItemV3( + trackId = "file:84:subtitle:0", + combinedIndex = 0, + source = "external", + codec = "srt", + delivery = SUBTITLE_DELIVERY_SIDECAR, + url = "/api/v1/playback/session-1/subtitles/0.vtt", + ) + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + selectedTracks = SelectedPlaybackTracksV3(subtitle = null), + subtitle = PlaybackSubtitleDecisionV3( + mode = PlaybackSubtitleModeV3.CONVERT, + trackId = track.trackId, + artifact = PlaybackSubtitleArtifactV3( + url = track.url.orEmpty(), + mimeType = "text/vtt", + format = "vtt", + ), + inventory = listOf(track), + ), + ), + ).validateForMedia3() + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun unknownUnselectedSubtitleDeliveryRejectsThePlan() { + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = "future-track", + combinedIndex = 0, + source = "external", + delivery = "future_delivery", + ), + ), + ), + ), + ).validateForMedia3() + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun unknownSelectedSubtitleDeliveryRejectsThePlan() { + val selected = PlaybackTrackIdentityV3("future-track", 0) + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + selectedTracks = SelectedPlaybackTracksV3(subtitle = selected), + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = selected.id, + combinedIndex = selected.index ?: 0, + source = "external", + delivery = "future_delivery", + ), + ), + ), + ), + ).validateForMedia3() + + assertEquals( + "invalid_playback_plan", + assertIs(result).reason, + ) + } + + @Test + fun subtitleSelectedByTrackIdAloneStaysPlayable() { + val trackId = "file:42:subtitle:0" + val result = PlaybackDecisionResponseV3( + protocolVersion = PLAYBACK_PROTOCOL_V3, + serverFeatures = neutralServerFeatures, + outcome = PlaybackDecisionOutcome.PLAYABLE, + playbackPlan = plan.copy( + selectedTracks = SelectedPlaybackTracksV3( + subtitle = PlaybackTrackIdentityV3(trackId, index = null), + ), + subtitle = PlaybackSubtitleDecisionV3( + inventory = listOf( + PlaybackSubtitleInventoryItemV3( + trackId = trackId, + combinedIndex = 0, + source = "external", + delivery = SUBTITLE_DELIVERY_SIDECAR, + url = "/stream/session-1/subtitles/0.vtt", + ), + ), + ), + ), + ).validateForMedia3() + + val playable = assertIs(result) + assertEquals(0, playable.plan.resolvedSelectedSubtitleIndex()) } @Test @@ -94,7 +329,7 @@ class PlaybackProtocolV3Test { fun adaptationUnavailableIsTerminal() { val result = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.ADAPTATION_UNAVAILABLE, terminal = PlaybackTerminalV3("transcoding_disabled", "No compatible direct route.", false), ).validateForMedia3() @@ -108,7 +343,7 @@ class PlaybackProtocolV3Test { fun unsupportedHeaderRefreshFailsClosed() { val result = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan.copy( stream = plan.stream.copy( @@ -127,7 +362,7 @@ class PlaybackProtocolV3Test { fun unknownClientTransformationRequestsAReplan() { val result = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan.copy( transformations = listOf( @@ -147,13 +382,18 @@ class PlaybackProtocolV3Test { } @Test - fun clientTransformationRequiresDirectMedia3Engine() { + fun clientTransformationRequiresOriginalDelivery() { + // A client-side Dolby Vision rewrite edits the elementary stream on its + // way into the decoder, which the client only owns while playing the + // original file. On a server-produced delivery the server already made + // the dynamic-range decision. val result = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan.copy( - engine = PlaybackEngineKind.MEDIA3_HLS, + delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, + stream = plan.stream.copy(protocol = PlaybackStreamProtocol.HLS), transformations = listOf( PlaybackTransformationV3( name = CLIENT_DV7_TO_HDR10, @@ -165,7 +405,7 @@ class PlaybackProtocolV3Test { ).validateForMedia3() assertEquals( - "client_transformation_requires_media3_direct", + "client_transformation_requires_original_delivery", assertIs(result).reason, ) } @@ -184,7 +424,7 @@ class PlaybackProtocolV3Test { val conflicting = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan.copy( transformations = listOf( @@ -206,91 +446,47 @@ class PlaybackProtocolV3Test { } @Test - fun attemptKeyIsCanonicalAndOutputRouteAware() { - val a = plan.copy( - transformations = listOf( - PlaybackTransformationV3("audio_adapt"), - PlaybackTransformationV3("container_remux"), - ), - ).planAttemptKey(7, listOf("pcm:truehd:8", "transport_reopen")) - val b = plan.copy( - transformations = listOf( - PlaybackTransformationV3("container_remux"), - PlaybackTransformationV3("audio_adapt"), - ), - ).planAttemptKey(7, listOf("transport_reopen", "pcm:truehd:8")) - assertEquals(a, b) - assertNotEquals(a, plan.planAttemptKey(8, listOf("pcm:truehd:8", "transport_reopen"))) - assertTrue(a.matches(Regex("v3:[0-9a-f]{16}"))) - } - - @Test - fun attemptKeyMatchesGoClientTransformationFixture() { - val fixture = plan.copy( - planId = "plan:dv81-fixture", - delivery = PlaybackDelivery.ORIGINAL_HTTP, - stream = plan.stream.copy( - protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, - container = "mkv", - ), - effectiveRecipe = plan.effectiveRecipe.copy( - videoCodec = "hevc", - audioCodec = "truehd", - width = 3840, - height = 2160, - bitrateKbps = 65_000, - dynamicRange = "dolby_vision", - ), - subtitle = PlaybackSubtitleDecisionV3(mode = PlaybackSubtitleModeV3.OFF), - transformations = listOf( - PlaybackTransformationV3( - name = CLIENT_DV7_TO_DV81, - executor = PlaybackTransformationExecutor.CLIENT, - recipeVersion = "1", - ), - ), + fun attemptKeyIsServerOwnedAndEchoedVerbatim() { + // The client no longer derives attempt keys: the server mints them and + // the client stores and echoes the opaque value. Anything the client + // did locally is reported as `local_mutations` for the server to fold + // into the next key, rather than hashed here. + val decoded = PrairieJson.decodeFromString( + """{"protocol_version":3,"server_features":["$PLAYBACK_PLAN_V3_FEATURE",""" + + """"$NEUTRAL_PLAYBACK_V3_CONTRACT_FEATURE"],"outcome":"playable",""" + + """"playback_plan":{"plan_id":"p","session_id":"s","plan_attempt_key":"v3:server-minted",""" + + """"delivery":"original_http","stream":{"url":"/s","protocol":"http_progressive"},""" + + """"decision_reason":"validated_original_playback"}}""", ) + assertEquals("v3:server-minted", decoded.playbackPlan?.planAttemptKey) - assertEquals("v3:2a88b5e686373440", fixture.planAttemptKey(9)) - } - - @Test - fun attemptKeyMatchesGoDeviceQuirkFixture() { - val fixture = plan.copy( - planId = "plan:quirk", - delivery = PlaybackDelivery.ORIGINAL_HTTP, - stream = plan.stream.copy( - protocol = PlaybackStreamProtocol.HTTP_PROGRESSIVE, - container = "mkv", - ), - effectiveRecipe = plan.effectiveRecipe.copy( - videoCodec = "hevc", - audioCodec = "eac3", - width = 3840, - height = 2160, - bitrateKbps = 60_000, - dynamicRange = "dolby_vision", - ), - subtitle = PlaybackSubtitleDecisionV3(mode = PlaybackSubtitleModeV3.OFF), - transformations = emptyList(), - appliedQuirks = listOf( - PlaybackAppliedQuirkV3( - id = "android.fire_tv.dv8_hdr10plus_sei_v1", - registryRevision = "2026-07-13.1", - action = "client_runtime_correction", - ), + val echoed = PrairieJson.encodeToString( + PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + playbackAttemptId = "attempt", + replanRequestId = "request", + failedPlanId = "p", + planAttemptId = "plan-attempt", + planAttemptKey = "v3:server-minted", + attemptedPlanKeys = listOf("v3:server-minted"), + localMutations = listOf("pcm:truehd:8"), + attemptCount = 2, + positionSeconds = 10.0, + selectedTracks = SelectedPlaybackTracksV3(), + failure = PlaybackFailureV3("transport_stall"), + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), ), - runtimeCorrections = listOf(CLIENT_DV8_HDR10_PLUS_SANITIZER), ) - - assertEquals("v3:8d843bfffeb3adc3", fixture.planAttemptKey(9)) + assertTrue(echoed.contains("\"plan_attempt_key\":\"v3:server-minted\"")) + assertTrue(echoed.contains("\"local_mutations\":[\"pcm:truehd:8\"]")) } @Test fun unknownRuntimeCorrectionRequestsReplan() { val response = PlaybackDecisionResponseV3( protocolVersion = 3, - serverFeatures = listOf(PLAYBACK_PLAN_V3_FEATURE), + serverFeatures = neutralServerFeatures, outcome = PlaybackDecisionOutcome.PLAYABLE, playbackPlan = plan.copy(runtimeCorrections = listOf("future_runtime_fix")), ).validateForMedia3() @@ -302,32 +498,59 @@ class PlaybackProtocolV3Test { } @Test - fun startRequestNeverForcesAPlayMethod() { + fun startRequestNeverForcesAPlayMethodOrNamesAnEngine() { val encoded = PrairieJson.encodeToString( PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, fileId = 12, profileId = "profile", playbackAttemptId = "attempt", subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, - outputRouteGeneration = 4, capabilities = ClientCodecCapabilities(), - clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), + clientPlaybackContext = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(outputContextId = "4"), + ), ), ) assertFalse(encoded.contains("play_method")) assertTrue(encoded.contains("\"protocol_version\":3")) - assertTrue(encoded.contains("media3_only")) + // The neutral contract negotiates delivery classes, so the request must + // not leak this client's internal player component names. + assertFalse(encoded.contains("media3")) + assertTrue(encoded.contains("\"output_context_id\":\"4\"")) + } + + @Test + fun clientOwnedProgressSerializesAnExplicitZeroFileLocalStart() { + val encoded = PrairieJson.encodeToString( + PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + fileId = 12, + profileId = "profile", + playbackAttemptId = "audiobook-attempt", + subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, + startPosition = 0.0, + progressPersistence = ProgressPersistenceV3.CLIENT, + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"), + ), + ) + + assertTrue(encoded.contains("\"start_position\":0.0")) + assertTrue(encoded.contains("\"progress_persistence\":\"client\"")) } @Test fun startAndReplanRequestsCarryCurrentNetworkEvidence() { val start = PrairieJson.encodeToString( PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, fileId = 12, profileId = "profile", playbackAttemptId = "attempt", subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, - outputRouteGeneration = 4, metered = true, bandwidthEstimateKbps = 22_000, bandwidthCapKbps = 15_000, @@ -337,6 +560,7 @@ class PlaybackProtocolV3Test { ) val replan = PrairieJson.encodeToString( PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, playbackAttemptId = "attempt", replanRequestId = "request", failedPlanId = "plan", @@ -345,7 +569,6 @@ class PlaybackProtocolV3Test { attemptedPlanKeys = listOf("key"), attemptCount = 2, positionSeconds = 10.0, - outputRouteGeneration = 4, metered = true, bandwidthEstimateKbps = 9_000, bandwidthCapKbps = 8_000, @@ -367,17 +590,18 @@ class PlaybackProtocolV3Test { fun seekReanchorOperationIsExplicitAndNegotiated() { val start = PrairieJson.encodeToString( PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, fileId = 12, profileId = "profile", playbackAttemptId = "attempt", subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, - outputRouteGeneration = 4, capabilities = ClientCodecCapabilities(), clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), ), ) val reanchor = PrairieJson.encodeToString( PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, operation = SEEK_REANCHOR_V3_OPERATION, playbackAttemptId = "attempt", replanRequestId = "request", @@ -387,9 +611,7 @@ class PlaybackProtocolV3Test { attemptedPlanKeys = listOf("key"), attemptCount = 1, positionSeconds = 10.0, - outputRouteGeneration = 4, selectedTracks = SelectedPlaybackTracksV3(), - failure = PlaybackFailureV3(SEEK_REANCHOR_V3_OPERATION), capabilities = ClientCodecCapabilities(), clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), ), @@ -397,6 +619,39 @@ class PlaybackProtocolV3Test { assertTrue(start.contains(SEEK_REANCHOR_V3_FEATURE)) assertTrue(reanchor.contains("\"operation\":\"seek_reanchor\"")) + assertFalse(reanchor.contains("\"failure\"")) + } + + @Test + fun intentOperationsCarryNoFailure() { + // `track_change` and `quality_change` replace what used to be separate + // endpoints. Nothing failed, so no failure is reported and the previous + // route stays eligible — the server may legitimately hand back a plan + // the client has already tried, which is not a loop. + for (operation in INTENT_V3_OPERATIONS) { + val encoded = PrairieJson.encodeToString( + PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, + operation = operation, + playbackAttemptId = "attempt", + replanRequestId = "request", + failedPlanId = "plan", + planAttemptId = "plan-attempt", + planAttemptKey = "key", + attemptedPlanKeys = emptyList(), + attemptCount = 1, + qualityPreference = "1080p", + positionSeconds = 10.0, + selectedTracks = SelectedPlaybackTracksV3(), + failure = null, + capabilities = ClientCodecCapabilities(), + clientPlaybackContext = ClientPlaybackContext(formFactor = "tv", appVersion = "test"), + ), + ) + + assertTrue(encoded.contains("\"operation\":\"$operation\"")) + assertFalse(encoded.contains("\"failure\"")) + } } @Test @@ -412,17 +667,23 @@ class PlaybackProtocolV3Test { ), ), ) - val encoded = PrairieJson.encodeToString( - ClientPlaybackContext( - formFactor = "tv", - appVersion = "test", - features = listOf(LAYOUT_AWARE_PASSTHROUGH_FEATURE), - output = PlaybackOutputContext(audioPassthrough = passthrough), - ), + val context = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(audioPassthrough = passthrough), ) + val encoded = PrairieJson.encodeToString(context) - assertTrue(encoded.contains("layout_aware_passthrough")) assertTrue(encoded.contains("\"channel_counts\":[2,6,8]")) assertTrue(encoded.contains("\"layouts\":[\"stereo\",\"5.1(side)\",\"7.1\"]")) + // The context itself carries no feature list: feature advertisement + // lives only in the request's top-level `client_features`, and the + // layout-aware claim is earned by enumerating real layouts. + assertFalse(encoded.contains(LAYOUT_AWARE_PASSTHROUGH_FEATURE)) + assertTrue(LAYOUT_AWARE_PASSTHROUGH_FEATURE in playbackClientFeaturesV3(context)) + assertFalse( + LAYOUT_AWARE_PASSTHROUGH_FEATURE in + playbackClientFeaturesV3(context.copy(output = PlaybackOutputContext())), + ) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSessionModelsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSessionModelsTest.kt new file mode 100644 index 000000000..9e12381a1 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSessionModelsTest.kt @@ -0,0 +1,87 @@ +package org.prairieserver.prairie.model.playback + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [PlaybackSessionResponse] is no longer a wire type: the neutral v3 contract + * returns `PlaybackDecisionResponseV3`, and this model is built in-process by + * `PlaybackV3Session.toSessionResponse` as a UI view of the plan. Its + * serializers still matter because the subtitle models round-trip through + * saved state and local caches, so they are what is covered here. + */ +class PlaybackSessionModelsTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + coerceInputValues = true + } + + @Test + fun playerSubtitleInfoPreservesRealDownloadedSubtitleId() { + val subtitle = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "/stream/s1/subtitles/4.vtt", + downloadId = 312, + ) + + val encoded = json.encodeToString(subtitle) + val decoded = json.decodeFromString(encoded) + + assertTrue(encoded.contains("\"download_id\":312")) + assertEquals(312, decoded.downloadId) + } + + @Test + fun playerSubtitleInfoWithoutDownloadIdRemainsDecodable() { + val decoded = json.decodeFromString( + """ + { + "index": 4, + "language": "en", + "source": "downloaded", + "url": "/stream/s1/subtitles/4.vtt" + } + """.trimIndent(), + ) + + assertNull(decoded.downloadId) + } + + @Test + fun incompletePlaybackPlanDegradesToNullInsteadOfFailingTheResponse() { + // A present-but-incomplete plan (missing the required `plan_id`) must NOT + // throw and fail the ENTIRE decode. There is no legacy protocol left to + // fall back to, so degrading to a null plan is what lets the caller + // surface a replan instead of losing the whole session object. + val decoded = json.decodeFromString( + """ + { + "session_id": "s1", + "user_id": 1, + "media_file_id": 42, + "play_method": "direct", + "stream_url": "/stream/s1", + "audio_track_index": 0, + "playback_plan": { + "delivery": "original_http", + "route_family": "compatibility_direct" + } + } + """.trimIndent(), + ) + assertEquals("s1", decoded.sessionId) + assertNull(decoded.playbackPlan) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt index ce657021e..d25c115fc 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/PlaybackSubtitleChoicesTest.kt @@ -138,4 +138,24 @@ class PlaybackSubtitleChoicesTest { assertEquals(listOf(0), choices.map(PlayerSubtitleInfo::index)) assertEquals("/a.vtt", choices.single().url) } + + @Test + fun authoritativeInventoryNeverSynthesizesCatalogOnlyRows() { + val catalog = listOf( + SubtitleTrack(index = 3, language = "en", title = "English"), + SubtitleTrack(index = 7, language = "ja", title = "Signs"), + ) + + assertEquals( + emptyList(), + enrichAuthoritativePlaybackSubtitleChoices(catalog, plannedTracks = emptyList()), + ) + + val authoritative = enrichAuthoritativePlaybackSubtitleChoices( + catalogTracks = catalog, + plannedTracks = listOf(PlayerSubtitleInfo(index = 1, url = "/signs.vtt")), + ) + assertEquals(listOf(1), authoritative.map(PlayerSubtitleInfo::index)) + assertEquals("Signs", authoritative.single().catalogLabel) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt index 1c92dedf7..99379402d 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/playback/SubtitleTrackMergeTest.kt @@ -81,13 +81,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val reordered = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 313), downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(1, first.single { it.downloadId == 312 }.index) @@ -101,13 +101,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312), downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val afterDeletion = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 313)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(2, beforeDeletion.single { it.downloadId == 313 }.index) @@ -121,13 +121,13 @@ class SubtitleTrackMergeTest { existing = listOf(track(0, source = "embedded")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) val expandedCatalog = mergeDownloadedSubtitles( existing = listOf(track(0, source = "embedded"), track(7, source = "external")), downloaded = listOf(downloaded(id = 312)), sessionId = "sess-1", - serverUrl = "https://silo.example", + serverUrl = "https://prairie.example", ) assertEquals(1, shortCatalog.single { it.source == "downloaded" }.index) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt index c61732c90..b800f852f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/profile/ProfileQualityPreferenceTest.kt @@ -1,8 +1,12 @@ package org.prairieserver.prairie.model.profile +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class ProfileQualityPreferenceTest { @Test @@ -30,4 +34,13 @@ class ProfileQualityPreferenceTest { assertEquals("480p", displayProfileQualityPreference("480p")) assertEquals("Cinema", displayProfileQualityPreference(" Cinema ")) } + + @Test + fun createRequestCannotSendLegacyQualityButEditRequestStillCan() { + val create = Json.encodeToString(CreateProfileRequest(name = "New profile")) + val update = Json.encodeToString(UpdateProfileRequest(qualityPreference = "1080p")) + + assertFalse("quality_preference" in create) + assertTrue("\"quality_preference\":\"1080p\"" in update) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt new file mode 100644 index 000000000..a8b31e0eb --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/LanguageOptionsTest.kt @@ -0,0 +1,94 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class LanguageOptionsTest { + + @Test + fun `uses the definition-specific generated contract floor`() { + val options = LanguageOptions.namedOptions(SettingKeys.PLAYBACK_AUDIO_LANGUAGE) + val values = options.map { it.first } + + assertTrue("en" in values) + assertTrue("te" in values) + assertEquals(37, values.size) + assertTrue(options.all { it.second.isNotBlank() }) + } + + @Test + fun `unions runtime and exact current values without collapsing regions`() { + val values = LanguageOptions.namedOptions( + key = SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = "pt-BR", + runtimeValues = listOf("eng", "es-MX"), + ).map { it.first } + + assertTrue("en" in values) + assertFalse("eng" in values, "a true ISO alias must not create a duplicate row") + assertTrue("es-MX" in values) + assertTrue("pt" in values) + assertTrue("pt-BR" in values) + } + + @Test + fun `an exact current alias replaces the contract spelling`() { + val values = LanguageOptions.namedOptions( + key = SettingKeys.PLAYBACK_AUDIO_LANGUAGE, + currentValue = "eng", + ).map { it.first } + + assertTrue("eng" in values) + assertFalse("en" in values) + } + + @Test + fun `each definition supplies its own unset label`() { + assertEquals( + LanguageOptions.UNSET to "No preference", + LanguageOptions.options(SettingKeys.PLAYBACK_AUDIO_LANGUAGE).first(), + ) + assertEquals( + LanguageOptions.UNSET to "None", + LanguageOptions.options(SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE).first(), + ) + assertEquals( + LanguageOptions.UNSET to "Library default", + LanguageOptions.options(SettingKeys.CATALOG_METADATA_LANGUAGE).first(), + ) + } + + @Test + fun `labels and wire values round trip through the rendered options`() { + val options = LanguageOptions.options( + SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE, + currentValue = "pt-BR", + ) + for ((wire, label) in options) { + assertEquals(wire, LanguageOptions.wireValue(label, options)) + } + assertNotEquals( + "None", + LanguageOptions.label("pt-BR", SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + ) + assertEquals( + "None", + LanguageOptions.label("English", SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), + ) + } + + @Test + fun `legacy label values migrate while valid tags survive`() { + assertEquals("en", LanguageOptions.migrateLegacyValue("English")) + assertEquals("ja", LanguageOptions.migrateLegacyValue("Japanese")) + assertEquals("pt-BR", LanguageOptions.migrateLegacyValue("pt-BR")) + assertEquals("zh-Hant", LanguageOptions.migrateLegacyValue("zh-Hant")) + assertEquals("eng", LanguageOptions.migrateLegacyValue("eng")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Klingon")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Off")) + assertEquals(LanguageOptions.UNSET, LanguageOptions.migrateLegacyValue("Default")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt new file mode 100644 index 000000000..2aced684d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/QualityPresetsTest.kt @@ -0,0 +1,137 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The preset table is a client-side pairing of two contract settings, so the + * invariants worth pinning are the ones a silent edit could break: every + * resolution must be a member the server's enum accepts, every bitrate must + * sit inside the contract's range, and the table must round-trip — a preset + * written and read back has to select itself, or the picker shows the wrong + * entry for a value the user just chose. + * + * Mirrors web/src/lib/qualityPresets.test.ts. + */ +class QualityPresetsTest { + + // playback.preferred_quality's enum, and playback.max_bitrate_kbps's bounds. + private val contractResolutions = setOf("auto", "480p", "720p", "1080p", "2160p", "original") + private val minBitrate = 100 + private val maxBitrate = 200_000 + + @Test + fun `every preset uses contract-legal values`() { + for (preset in QualityPresets.ALL) { + assertTrue( + preset.resolution in contractResolutions, + "${preset.id}: ${preset.resolution} is not a playback.preferred_quality member", + ) + preset.bitrateKbps?.let { + assertTrue( + it in minBitrate..maxBitrate, + "${preset.id}: $it is outside playback.max_bitrate_kbps bounds", + ) + } + } + } + + @Test + fun `preset ids and axis pairs are unique`() { + assertEquals( + QualityPresets.ALL.size, + QualityPresets.ALL.map { it.id }.toSet().size, + "duplicate preset id", + ) + assertEquals( + QualityPresets.ALL.size, + QualityPresets.ALL.map { it.resolution to it.bitrateKbps }.toSet().size, + "two presets store the same pair, so one can never be selected", + ) + } + + @Test + fun `every preset round-trips through its stored pair`() { + for (preset in QualityPresets.ALL) { + assertEquals( + preset.id, + QualityPresets.presetFor(preset.resolution, preset.bitrateKbps)?.id, + "${preset.id} does not select itself", + ) + } + } + + @Test + fun `the table matches the web client's semantics`() { + // Named explicitly rather than derived, so a retune on one platform + // that is not mirrored on the other shows up here. + assertEquals( + listOf( + "auto" to null, + "original" to null, + "2160p" to null, + "1080p" to 10000, + "1080p" to 6000, + "1080p" to 3000, + "720p" to 4000, + "720p" to 2000, + "480p" to 1500, + ), + QualityPresets.ALL.map { it.resolution to it.bitrateKbps }, + ) + assertEquals( + listOf( + "Auto", "Original", "4K", "1080p High", "1080p", "1080p Low", + "720p High", "720p", "480p", + ), + QualityPresets.ALL.map { it.label }, + ) + } + + @Test + fun `a combination no preset covers selects nothing but still describes`() { + assertNull(QualityPresets.presetFor("1080p", 4500)) + assertEquals("1080p at 4.5 Mbps", QualityPresets.describe("1080p", 4500)) + assertEquals("4K at 25 Mbps", QualityPresets.describe("2160p", 25000)) + } + + @Test + fun `uncapped is the absence of a bitrate`() { + assertEquals("auto", QualityPresets.presetFor("auto", null)?.id) + // 0 is the local store's spelling of uncapped; it must not read as a cap. + assertEquals("auto", QualityPresets.presetFor("auto", 0)?.id) + assertEquals("Auto", QualityPresets.describe("auto", 0)) + } + + @Test + fun `legacy compound resolutions decompose to the enum member`() { + // These are the transcode-ladder spellings older builds stored. The + // bitrate they encoded is deliberately dropped: it lives on its own + // axis now, and inventing a cap would throttle playback silently. + assertEquals("1080p", QualityPresets.normalizeResolution("1080p-high")) + assertEquals("1080p", QualityPresets.normalizeResolution("1080p-8")) + assertEquals("720p", QualityPresets.normalizeResolution("720p-high")) + assertEquals("2160p", QualityPresets.normalizeResolution("4K")) + assertEquals("auto", QualityPresets.normalizeResolution("328p")) + assertEquals("auto", QualityPresets.normalizeResolution("")) + assertEquals("auto", QualityPresets.normalizeResolution(null)) + assertEquals("original", QualityPresets.normalizeResolution("Original")) + } + + @Test + fun `presetFor normalizes before matching so a legacy value still selects`() { + assertEquals("2160p", QualityPresets.presetFor("4k", null)?.id) + assertEquals("1080p", QualityPresets.presetFor("1080p-high", 6000)?.id) + } + + @Test + fun `byId and describe cover uncapped original and compound 4k`() { + assertEquals("original", QualityPresets.byId("original")?.id) + assertNull(QualityPresets.byId("missing")) + assertEquals("Original", QualityPresets.describe("original", null)) + assertEquals("Original at 12 Mbps", QualityPresets.describe("original", 12000)) + assertEquals("2160p", QualityPresets.normalizeResolution("4k-high")) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt new file mode 100644 index 000000000..9416e3e02 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingKeysContractTest.kt @@ -0,0 +1,138 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pins this client's hand-written key list against the generated contract. + * + * PlaybackSettingsKeys predates the contract and still exists because Android + * carries local-only keys the server has no opinion about. What it must not do + * is disagree with the contract about the keys they share — a name that drifts + * is a setting the server rejects, and the drift is invisible until a user's + * write silently stops persisting. + */ +class SettingKeysContractTest { + + @Test + fun everyKeyAndroidFlushesIsOneTheServerStores() { + val remote = SettingKeys.REMOTE.toSet() + + // Android flattens subtitle appearance into granular fields locally; the + // contract carries the composite object instead, so those are expected + // to be absent from REMOTE and are projected before flushing. + val locallyFlattened = PlaybackSettingsKeys.DeviceSettings.filter { + it.startsWith("subtitle.") + }.toSet() + + val unknown = PlaybackSettingsKeys.DeviceSettings.toSet() - remote - locallyFlattened + assertTrue( + unknown.isEmpty(), + "these keys are flushed to the server but have no contract definition, " + + "so the server will reject them: $unknown", + ) + } + + @Test + fun localOnlyKeysAreNeverFlushed() { + // The contract's client_local persistence and Android's exclusion from + // DeviceSettings are the same statement. A key the contract calls local + // that Android flushes anyway would poison a whole settings batch. + val flushed = PlaybackSettingsKeys.DeviceSettings.toSet() + for (key in SettingKeys.CLIENT_LOCAL) { + // Android suffixes two of these with ".local"; compare on the base. + assertTrue( + key !in flushed, + "$key is client_local in the contract but Android flushes it", + ) + } + } + + @Test + fun theSharedKeysAgreeOnSpelling() { + // The specific pairs that drifted before the contract existed. Android + // shipped player.next_up_prompt_seconds while Apple and the server used + // playback.next_up_prompt_seconds, so the same preference was two + // settings and neither client could read the other's. + assertEquals(SettingKeys.PLAYBACK_AUDIO_LANGUAGE, PlaybackSettingsKeys.AudioLanguage) + assertEquals(SettingKeys.PLAYBACK_PREFERRED_QUALITY, PlaybackSettingsKeys.PreferredQuality) + assertEquals(SettingKeys.PLAYBACK_AUTO_SKIP_INTRO, PlaybackSettingsKeys.AutoSkipIntro) + assertEquals(SettingKeys.PLAYBACK_AUTO_SKIP_CREDITS, PlaybackSettingsKeys.AutoSkipCredits) + assertEquals(SettingKeys.PLAYBACK_AUTO_PLAY_NEXT, PlaybackSettingsKeys.AutoPlayNext) + // The two the cutover actually renamed. The membership checks above + // still pass if either constant points at some *other* contract key, + // so name both pairs explicitly. + assertEquals( + SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, + PlaybackSettingsKeys.SubtitleAppearance, + ) + assertEquals( + SettingKeys.PLAYBACK_NEXT_UP_PROMPT_SECONDS, + PlaybackSettingsKeys.NextUpPromptSeconds, + ) + assertEquals(SettingKeys.PLAYER_PLAYBACK_SPEED, PlaybackSettingsKeys.PlaybackSpeed) + assertEquals(SettingKeys.PLAYER_AUDIO_SYNC_MS, PlaybackSettingsKeys.AudioSyncMs) + assertEquals(SettingKeys.PLAYER_SUBTITLE_SYNC_MS, PlaybackSettingsKeys.SubtitleSyncMs) + assertEquals(SettingKeys.PLAYER_HDR_ENABLED, PlaybackSettingsKeys.HdrEnabled) + assertEquals(SettingKeys.PLAYER_VIDEO_GRAVITY, PlaybackSettingsKeys.VideoGravity) + assertEquals(SettingKeys.PLAYER_ORIENTATION_MODE, PlaybackSettingsKeys.OrientationMode) + assertEquals( + SettingKeys.PLAYER_SLEEP_TIMER_DEFAULT_MINUTES, + PlaybackSettingsKeys.SleepTimerDefaultMinutes, + ) + assertEquals( + SettingKeys.PLAYER_MATCH_FRAME_RATE, + PlaybackSettingsKeys.MatchContentFrameRate, + ) + } + + @Test + fun theTypeTablesComeFromTheContract() { + // Every generated classification must be a key the contract also lists + // as remote, or the tables describe settings that cannot be written. + val remote = SettingKeys.REMOTE.toSet() + for (key in SettingKeys.BOOLEAN_KEYS + SettingKeys.INT_KEYS + SettingKeys.DOUBLE_KEYS) { + assertTrue(key in remote, "$key is classified but not remote") + } + + // And the three must not overlap: a key in two tables would parse + // differently depending on which check ran first. + assertTrue((SettingKeys.BOOLEAN_KEYS intersect SettingKeys.INT_KEYS).isEmpty()) + assertTrue((SettingKeys.BOOLEAN_KEYS intersect SettingKeys.DOUBLE_KEYS).isEmpty()) + assertTrue((SettingKeys.INT_KEYS intersect SettingKeys.DOUBLE_KEYS).isEmpty()) + } + + @Test + fun theRenameTableTargetsTheKeysThatWereRenamed() { + // The local migration copies each old slot into the value on the right, + // so a target that drifts off the contract would move the user's value + // into a slot nothing reads — the same silent revert the table exists + // to prevent, just one rename later. + assertEquals( + mapOf( + "subtitle_appearance" to SettingKeys.PLAYBACK_SUBTITLE_APPEARANCE, + "player.next_up_prompt_seconds" to SettingKeys.PLAYBACK_NEXT_UP_PROMPT_SECONDS, + ), + PlaybackSettingsKeys.RenamedLocalKeys, + ) + // And every old spelling must be genuinely retired: a key that is still + // live would have its value copied out from under it. + for (oldKey in PlaybackSettingsKeys.RenamedLocalKeys.keys) { + assertTrue( + oldKey !in SettingKeys.REMOTE, + "$oldKey is still a contract key; renaming it locally would strand it", + ) + } + } + + @Test + fun qualityIsTwoAxesHere() { + // The compound ladder values are gone; a client composes a resolution + // and a bitrate. Both keys have to exist for the picker to offer the + // presets the phone and TV UIs show. + assertTrue(SettingKeys.PLAYBACK_PREFERRED_QUALITY in SettingKeys.REMOTE) + assertTrue(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS in SettingKeys.REMOTE) + assertTrue(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS in SettingKeys.INT_KEYS) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt new file mode 100644 index 000000000..b4691f53c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsManifest.kt @@ -0,0 +1,122 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * The parts of `contracts/settings/v1/manifest.json` that resolution depends on. + * + * The generated bindings in [SettingKeys] carry key names and a coarse type + * table, which is all production code needs — Android writes through + * `/settings/values` and reads effective values back from the server. They do + * not carry the facts a *resolver* needs: each definition's resolution order, + * its default value, whether its enum members form a ranked progression, and + * what policy input constrains it. Rather than hand-copy those into Kotlin + * (which is exactly the drift the contract exists to remove), the conformance + * runner parses the vendored manifest and is driven by it. + * + * Only the fields resolution reads are modelled, and parsing tolerates unknown + * ones. That asymmetry with the fixture — which is decoded strictly — is + * deliberate: the manifest is a vendored copy pinned by [revision], and the + * revision check is its drift gate. The fixture has no such pin, so an + * unrecognized field there is the only signal that its schema moved. + * + * Fields skipped on purpose, because nothing here ranks or validates values: + * `allowed_scopes` (a write-side concern, and its entries may be either a + * scope name or an object), the numeric `minimum`/`maximum` bounds (which may + * be either a bare number or a widening history array), and all of the UI + * metadata except the language option-set fields, which are retained to gate + * the generated picker presentation against the vendored manifest. + */ +@Serializable +data class SettingsManifest( + @SerialName("api_version") val apiVersion: Int, + val revision: Int, + @SerialName("option_sets") val optionSets: Map = emptyMap(), + val definitions: List, +) { + private val byKey: Map = definitions.associateBy { it.key } + + /** The definition for [key], or null when this manifest does not declare it. */ + fun lookup(key: String): SettingDefinition? = byKey[key] +} + +@Serializable +data class SettingDefinition( + val key: String, + val persistence: String, + @SerialName("resolution_order") val resolutionOrder: List, + @SerialName("value_schema") val valueSchema: SettingValueSchema, + // Required by the manifest schema and never absent, but always present as + // an explicit JSON value that may itself be null — so it is typed as a + // JsonElement and JsonNull is a real default, not a missing one. + @SerialName("default_value") val defaultValue: JsonElement, + @SerialName("constrained_by") val constrainedBy: SettingConstraintBinding? = null, + @SerialName("suggested_options") val suggestedOptions: String? = null, + @SerialName("unset_label") val unsetLabel: String? = null, +) { + /** True when the server stores this setting; client_local keys never resolve. */ + val isRemote: Boolean get() = persistence == PERSISTENCE_REMOTE + + /** True for the types that rank numerically rather than by enum position. */ + val isNumeric: Boolean + get() = valueSchema.type == TYPE_INTEGER || valueSchema.type == TYPE_NUMBER + + companion object { + const val PERSISTENCE_REMOTE = "remote" + const val TYPE_INTEGER = "integer" + const val TYPE_NUMBER = "number" + const val TYPE_ENUM = "enum" + } +} + +@Serializable +data class ContractOptionSet( + val type: String, + val options: List, +) + +@Serializable +data class ContractSuggestedOption( + val value: String, + @SerialName("introduced_in") val introducedIn: Int, +) + +@Serializable +data class SettingValueSchema( + val type: String, + /** Members in declared order; ranking uses the position, not the label. */ + val values: List = emptyList(), + /** Set when the members form a progression a ceiling or floor can cap along. */ + val ordered: Boolean = false, + val nullable: Boolean = false, +) + +@Serializable +data class SettingEnumMember( + val value: JsonElement, +) + +/** A definition's binding to the policy input that may narrow it. */ +@Serializable +data class SettingConstraintBinding( + @SerialName("policy_input") val policyInput: String, + val constraint: SettingConstraintKind, +) + +/** How a policy input narrows a resolved value. */ +@Serializable +enum class SettingConstraintKind { + @SerialName("ceiling") + CEILING, + + @SerialName("floor") + FLOOR, + + @SerialName("allowlist") + ALLOWLIST, + + @SerialName("locked") + LOCKED, +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt new file mode 100644 index 000000000..f3ee8da6b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SettingsResolve.kt @@ -0,0 +1,325 @@ +package org.prairieserver.prairie.model.settings + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Client-side settings resolution, mirroring the server's + * `internal/settingsresolve` semantics: the definition's declared resolution + * order decides which stored value wins, an identity absent from the context + * drops the scopes that need it, and policy constraints narrow the answer + * without destroying what the user authored. + * + * Android does not resolve settings in production — it writes through + * `/settings/values` and reads effective values back from + * `/settings/values/effective`, so the server stays the single authority. This + * resolver exists for one reason: the cross-platform conformance fixture in + * `contracts/settings/v1/conformance.json` names a Kotlin implementation as one + * of the four that must agree, and four independently written resolvers + * agreeing is what makes the fixture a drift gate rather than a tautology. It + * therefore lives in test sources, and every behavioral choice in it is pinned + * by [SettingsConformanceTest] — do not change one without the fixture + * agreeing. + * + * The Go implementation is `internal/settingsresolve/resolve.go` and the + * TypeScript one is `web/src/lib/settingsResolve.ts`; the three are meant to be + * readable side by side. + */ + +/** Where a resolved value came from, or "default" when nothing was stored. */ +object SettingSource { + const val DEFAULT = "default" + const val ACCOUNT = "account" + const val PROFILE = "profile" + + /** + * One profile's value shared by every like client — a television menu that + * applies to tvOS and Android TV but not to a phone. The family is explicit + * resolution context (the server takes it from `X-Prairie-Client-Family`), + * never inferred from device metadata. + */ + const val PROFILE_CLIENT = "profile_client" + const val PROFILE_DEVICE = "profile_device" + const val PROFILE_LIBRARY = "profile_library" + const val PROFILE_SERIES = "profile_series" +} + +/** One stored row, as the server's values API reports it. */ +data class StoredSettingRow( + val key: String, + val scope: String, + val profileId: String? = null, + val clientFamily: String? = null, + val deviceId: String? = null, + val libraryId: Int? = null, + val seriesId: String? = null, + val value: JsonElement, +) + +/** The identity a resolution happens against. Absent fields drop their scopes. */ +data class SettingResolutionContext( + val profileId: String? = null, + /** The caller's client family (`tv`, `mobile`, …); absent drops [SettingSource.PROFILE_CLIENT]. */ + val clientFamily: String? = null, + val deviceId: String? = null, + val libraryIds: List = emptyList(), + val seriesIds: List = emptyList(), +) + +/** One resolved setting. */ +data class ResolvedSetting( + val key: String, + val value: JsonElement, + val source: String, + /** True when a policy constraint narrowed [value] away from what was stored. */ + val constrained: Boolean = false, + /** What the user authored (may be [JsonNull]); present only when constrained. */ + val storedValue: JsonElement? = null, + val constraintKind: SettingConstraintKind? = null, +) + +/** + * Resolves the effective value for each requested key against stored rows. + * + * Unknown and `client_local` keys are omitted rather than erroring, matching the + * server: they have no server-resolved answer, so a newer client asking for a + * setting this contract does not carry gets a short answer, not a failure. + * + * [constraintBindings] lets a caller — in practice the conformance runner — + * attach a constraint to a key the shipped manifest does not bind, so + * constraint kinds no definition currently carries stay testable. A key with no + * entry uses the manifest's own `constrained_by`. + */ +fun resolveSettingValues( + manifest: SettingsManifest, + keys: List, + stored: List, + context: SettingResolutionContext, + constraints: Map = emptyMap(), + constraintBindings: Map = emptyMap(), +): List { + val seen = mutableSetOf() + return keys.mapNotNull { key -> + if (!seen.add(key)) return@mapNotNull null + val definition = manifest.lookup(key) ?: return@mapNotNull null + if (!definition.isRemote) return@mapNotNull null + resolveOne(definition, stored, context, constraints, constraintBindings[key]) + } +} + +private fun resolveOne( + definition: SettingDefinition, + stored: List, + context: SettingResolutionContext, + constraints: Map, + bindingOverride: SettingConstraintBinding?, +): ResolvedSetting { + val candidates = stored.filter { it.key == definition.key } + + var value = definition.defaultValue + var source = SettingSource.DEFAULT + for (scope in definition.resolutionOrder) { + if (scope == SettingSource.DEFAULT) break + val row = pickForScope(scope, candidates, context) ?: continue + value = row.value + source = scope + break + } + + return applyConstraint( + definition, + ResolvedSetting(key = definition.key, value = value, source = source), + constraints, + bindingOverride, + ) +} + +/** + * Returns the candidate row for one scope, mirroring the server: an identity + * missing from the context matches nothing, and a tie between several content + * rows breaks deterministically by (libraryId, seriesId). + * + * The device case checks the context's device id is non-empty as well as equal. + * Without that, a caller with no device identity — the anonymous jellycompat + * seed — matches every row whose own device id is also empty, and one device's + * settings leak to every client. `profile_client` carries the same guard for + * the same reason (`rc.ClientFamily.Valid()` in the Go resolver): a caller that + * sent no `X-Prairie-Client-Family` must not inherit the family rows of every + * other caller that also sent none. + * + * That non-empty guard is currently unpinned by the fixture, in every language: + * `missing_device_identity_drops_device_scope` supplies no device id in the + * context but every stored profile_device row it carries names "d1", so plain + * equality already excludes them and removing the guard still passes. It is + * kept because the Go and TypeScript resolvers both have it and a row with an + * empty device_id is reachable in production. Closing the gap means a case + * upstream in contracts/settings/v1/conformance.json — a stored profile_device + * row with an empty device_id against a context with none — so that all four + * runners gain it at once; fixing it only here would defeat the point. + */ +private fun pickForScope( + scope: String, + candidates: List, + context: SettingResolutionContext, +): StoredSettingRow? { + val profileId = context.profileId.orEmpty() + val clientFamily = context.clientFamily.orEmpty() + val deviceId = context.deviceId.orEmpty() + val matches = candidates.filter { row -> + if (row.scope != scope) return@filter false + when (scope) { + SettingSource.ACCOUNT -> true + SettingSource.PROFILE -> row.profileId.orEmpty() == profileId + SettingSource.PROFILE_CLIENT -> + row.profileId.orEmpty() == profileId && + row.clientFamily.orEmpty() == clientFamily && + clientFamily.isNotEmpty() + SettingSource.PROFILE_DEVICE -> + row.profileId.orEmpty() == profileId && + row.deviceId.orEmpty() == deviceId && + deviceId.isNotEmpty() + SettingSource.PROFILE_LIBRARY -> + row.profileId.orEmpty() == profileId && + (row.libraryId ?: 0) in context.libraryIds + SettingSource.PROFILE_SERIES -> + row.profileId.orEmpty() == profileId && + row.seriesId.orEmpty() in context.seriesIds + else -> false + } + } + if (matches.size <= 1) return matches.firstOrNull() + // Deterministic rather than arbitrary: a batch spanning several libraries or + // series has no single right answer and the caller is expected to resolve + // per item, but two identical requests must not disagree. + return matches.sortedWith( + compareBy({ it.libraryId ?: 0 }, { it.seriesId.orEmpty() }), + ).first() +} + +/** + * Narrows an effective value to what policy permits without destroying the + * authored one: a preference capped today must take effect the day the cap + * lifts, so the stored value is reported alongside the cap rather than replaced. + */ +private fun applyConstraint( + definition: SettingDefinition, + resolved: ResolvedSetting, + constraints: Map, + bindingOverride: SettingConstraintBinding?, +): ResolvedSetting { + val binding = bindingOverride ?: definition.constrainedBy ?: return resolved + val limit = constraints[binding.policyInput] ?: return resolved + + val narrowed = narrowValue(definition, binding.constraint, resolved.value, limit) + ?: return resolved + return resolved.copy( + value = narrowed, + storedValue = resolved.value, + constrained = true, + constraintKind = binding.constraint, + ) +} + +/** Applies one constraint kind, returning the narrowed value or null when it stands. */ +private fun narrowValue( + definition: SettingDefinition, + kind: SettingConstraintKind, + value: JsonElement, + limit: JsonElement, +): JsonElement? = when (kind) { + // The policy value replaces the user's outright. An already-equal value is + // not a narrowing, so clients do not tell the user their own choice was + // overridden. + SettingConstraintKind.LOCKED -> + if (jsonEquivalent(value, limit)) null else limit + + SettingConstraintKind.CEILING -> when { + // null on a nullable numeric means "no cap of my own" — unbounded + // above, which is exactly what a ceiling exists to bring down. It has + // no numeric rank, so a plain comparison reports 0 and the one value + // that most needs capping would slip past. + value is JsonNull && definition.isNumeric -> limit + compareValues(definition, value, limit) <= 0 -> null + else -> limit + } + + // The mirror rule: unbounded above already satisfies any floor. + SettingConstraintKind.FLOOR -> when { + value is JsonNull && definition.isNumeric -> null + compareValues(definition, value, limit) >= 0 -> null + else -> limit + } + + SettingConstraintKind.ALLOWLIST -> { + val allowed = limit as? JsonArray + when { + allowed == null || allowed.isEmpty() -> null + allowed.any { jsonEquivalent(it, value) } -> null + // Falling back to the first allowed member rather than the + // definition default: the default may itself be outside the + // allowlist, and an effective value the policy forbids is the one + // thing this must never return. + else -> allowed.first() + } + } +} + +/** + * Ranks two values through the definition's own schema: numbers numerically, + * ordered enums by declared member position. Anything unrankable compares equal, + * so an unrecognized value is never silently narrowed — validation is a separate + * concern and has already rejected it by the time a constraint applies. + */ +private fun compareValues(definition: SettingDefinition, a: JsonElement, b: JsonElement): Int { + if (definition.isNumeric) { + val left = a.asDoubleOrNull() ?: return 0 + val right = b.asDoubleOrNull() ?: return 0 + return left.compareTo(right) + } + if (definition.valueSchema.type == SettingDefinition.TYPE_ENUM && + definition.valueSchema.ordered + ) { + val members = definition.valueSchema.values + val left = members.indexOfFirst { jsonEquivalent(it.value, a) } + val right = members.indexOfFirst { jsonEquivalent(it.value, b) } + if (left < 0 || right < 0) return 0 + return left.compareTo(right) + } + return 0 +} + +private fun JsonElement.asDoubleOrNull(): Double? { + val primitive = this as? JsonPrimitive ?: return null + if (primitive is JsonNull || primitive.isString) return null + return primitive.content.toDoubleOrNull() +} + +/** + * Structural equality over JSON values, ignoring object key order and numeric + * spelling. + * + * [JsonElement] already compares structurally, but it compares numbers by their + * source text: `8000` and `8000.0` are the same JSON number and must not be + * treated as different values. The Go runner decodes to `any` before comparing, + * which collapses both to a float64; this does the same by hand. + */ +fun jsonEquivalent(a: JsonElement, b: JsonElement): Boolean = when { + a is JsonNull || b is JsonNull -> a is JsonNull && b is JsonNull + a is JsonObject && b is JsonObject -> + a.keys == b.keys && a.all { (key, value) -> jsonEquivalent(value, b.getValue(key)) } + a is JsonArray && b is JsonArray -> + a.size == b.size && a.indices.all { jsonEquivalent(a[it], b[it]) } + a is JsonPrimitive && b is JsonPrimitive -> when { + a.isString != b.isString -> false + a.isString -> a.content == b.content + else -> { + val left = a.asDoubleOrNull() + val right = b.asDoubleOrNull() + if (left != null && right != null) left == right else a.content == b.content + } + } + else -> false +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt new file mode 100644 index 000000000..dc3825e1d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/settings/SubtitleAppearanceProjectionTest.kt @@ -0,0 +1,159 @@ +package org.prairieserver.prairie.model.settings + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The projection is what makes a per-field subtitle edit reach the server at + * all: the contract has one composite object and no definitions for the + * granular `subtitle.*` fields, so a field the projection drops is a + * preference that silently never syncs. + */ +class SubtitleAppearanceProjectionTest { + + @Test + fun `every field projects onto the composite`() { + val projected = SubtitleAppearanceProjection.project( + mapOf( + PlaybackSettingsKeys.SubtitleFontSize to "xxlarge", + PlaybackSettingsKeys.SubtitleFontFamily to "Avenir Next", + PlaybackSettingsKeys.SubtitleTextColor to "#ffee00", + PlaybackSettingsKeys.SubtitleBackgroundColor to "#101010", + PlaybackSettingsKeys.SubtitleBackgroundStyle to "box", + PlaybackSettingsKeys.SubtitleBackgroundOpacity to "40", + PlaybackSettingsKeys.SubtitleTextOutline to "true", + PlaybackSettingsKeys.SubtitleTextOutlineColor to "#001122", + PlaybackSettingsKeys.SubtitlePosition to "lower-third", + ), + ) + + assertEquals(SubtitleFontSizePreset.XXLarge, projected.fontSize) + assertEquals("Avenir Next", projected.fontFamily) + assertEquals("#ffee00", projected.fontColor) + assertEquals("#101010", projected.backgroundColor) + assertEquals(SubtitleBackgroundStylePreset.Box, projected.backgroundStyle) + assertEquals(40, projected.backgroundOpacity) + assertEquals(true, projected.textOutline) + assertEquals("#001122", projected.textOutlineColor) + assertEquals(SubtitlePositionPreset.LowerThird, projected.position) + } + + @Test + fun `projection is sparse over the base`() { + val base = SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.Small, + fontColor = "#123456", + backgroundOpacity = 20, + ) + // Only one field set; the rest of the base must survive untouched, + // matching the schema's "a stored value is a sparse override" rule. + val projected = SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleTextOutline to "true"), + base, + ) + + assertEquals(SubtitleFontSizePreset.Small, projected.fontSize) + assertEquals("#123456", projected.fontColor) + assertEquals(20, projected.backgroundOpacity) + assertEquals(true, projected.textOutline) + } + + @Test + fun `a bad or absent field leaves the base value alone`() { + val base = SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.XLarge, + fontColor = "#abcdef", + position = SubtitlePositionPreset.Top, + ) + val projected = SubtitleAppearanceProjection.project( + mapOf( + PlaybackSettingsKeys.SubtitleFontSize to "enormous", + PlaybackSettingsKeys.SubtitleTextColor to "not-a-color", + PlaybackSettingsKeys.SubtitleFontFamily to " ", + PlaybackSettingsKeys.SubtitleTextOutline to "yes", + PlaybackSettingsKeys.SubtitlePosition to null, + ), + base, + ) + + assertEquals(SubtitleFontSizePreset.XLarge, projected.fontSize) + assertEquals("#abcdef", projected.fontColor) + assertEquals(base.fontFamily, projected.fontFamily) + assertEquals(base.textOutline, projected.textOutline) + assertEquals(SubtitlePositionPreset.Top, projected.position) + } + + @Test + fun `opacity is clamped rather than dropped`() { + assertEquals( + 100, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity to "180"), + ).backgroundOpacity, + ) + assertEquals( + 0, + SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitleBackgroundOpacity to "-5"), + ).backgroundOpacity, + ) + } + + @Test + fun `the legacy numeric position maps onto the enum`() { + fun positionFor(raw: String) = SubtitleAppearanceProjection.project( + mapOf(PlaybackSettingsKeys.SubtitlePosition to raw), + ).position + + assertEquals(SubtitlePositionPreset.Top, positionFor("0")) + assertEquals(SubtitlePositionPreset.LowerThird, positionFor("70")) + assertEquals(SubtitlePositionPreset.Bottom, positionFor("100")) + } + + @Test + fun `flatten then project is the identity`() { + val appearance = SubtitleAppearance( + fontSize = SubtitleFontSizePreset.XLarge, + fontFamily = "monospace", + fontColor = "#00ff00", + backgroundColor = "#220011", + backgroundStyle = SubtitleBackgroundStylePreset.Outline, + backgroundOpacity = 33, + textOutline = true, + textOutlineColor = "#334455", + position = SubtitlePositionPreset.Top, + ) + + // Projecting over the DEFAULT base, not over the appearance itself: + // a field flatten forgot would fall back to the default and be caught. + assertEquals( + appearance, + SubtitleAppearanceProjection.project( + SubtitleAppearanceProjection.flatten(appearance), + SubtitleAppearance.DEFAULT, + ), + ) + } + + @Test + fun `flatten covers every granular key`() { + assertEquals( + SubtitleAppearanceProjection.GRANULAR_KEYS.toSet(), + SubtitleAppearanceProjection.flatten(SubtitleAppearance.DEFAULT).keys, + ) + } + + @Test + fun `flattened enum values are the schema's spellings`() { + val flat = SubtitleAppearanceProjection.flatten( + SubtitleAppearance.DEFAULT.copy( + fontSize = SubtitleFontSizePreset.XXLarge, + backgroundStyle = SubtitleBackgroundStylePreset.None, + position = SubtitlePositionPreset.LowerThird, + ), + ) + assertEquals("xxlarge", flat[PlaybackSettingsKeys.SubtitleFontSize]) + assertEquals("none", flat[PlaybackSettingsKeys.SubtitleBackgroundStyle]) + assertEquals("lower-third", flat[PlaybackSettingsKeys.SubtitlePosition]) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt index 0d9ea3ea9..4b4cc6451 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/IdentityTransitionBarrierTest.kt @@ -1,10 +1,16 @@ package org.prairieserver.prairie.network +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +@OptIn(ExperimentalCoroutinesApi::class) class IdentityTransitionBarrierTest { @Test fun gateRunsInlineBeforeMutationAndDidChangeRunsAfterward() = runTest { @@ -61,6 +67,62 @@ class IdentityTransitionBarrierTest { assertEquals(listOf(1L, 1L), observed.map(IdentityTransition::generation)) } + @Test + fun targetMetadataIsResolvedOnceAndCarriedAcrossBothTransitionPhases() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val observed = mutableListOf() + var targetReads = 0 + barrier.installObserverForTests(observed::add) + + barrier.changing( + kind = IdentityTransitionKind.SERVER_REMOVE, + target = { + targetReads += 1 + IdentityTransitionTarget( + serverId = "server-b", + affectsCurrentIdentity = false, + ) + }, + ) { } + + assertEquals(1, targetReads) + assertEquals(listOf("server-b", "server-b"), observed.map(IdentityTransition::targetServerId)) + assertEquals(listOf(false, false), observed.map(IdentityTransition::affectsCurrentIdentity)) + } + + @Test + fun currentGenerationFenceSerializesTheGuardedBoundaryWithMutation() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val guardedStarted = CompletableDeferred() + val releaseGuard = CompletableDeferred() + val mutationRequested = CompletableDeferred() + val mutationStarted = CompletableDeferred() + + val guarded = async { + barrier.withCurrentGeneration(0) { + guardedStarted.complete(Unit) + releaseGuard.await() + "sent" + } + } + guardedStarted.await() + val mutation = async { + mutationRequested.complete(Unit) + barrier.changing(IdentityTransitionKind.SIGN_OUT) { + mutationStarted.complete(Unit) + } + } + mutationRequested.await() + runCurrent() + assertFalse(mutationStarted.isCompleted) + + releaseGuard.complete(Unit) + assertEquals("sent", guarded.await()) + mutation.await() + assertEquals(1, barrier.generation.value) + assertEquals(null, barrier.withCurrentGeneration(0) { "must-not-run" }) + } + @Test fun tokenMutationsAreWrappedExactlyOnceWithTheExpectedKind() = runTest { val barrier = DefaultIdentityTransitionBarrier() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt index 643d02e81..e85008b56 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginPinTest.kt @@ -693,6 +693,12 @@ class PrairieAuthPluginPinTest { override suspend fun getProfileId(): String = "server-b-profile" override suspend fun getProfileToken(): String = "server-b-profile-token" + + // Interface delegation forwards the DEFAULT getProfileIdentity() to the + // delegate, silently bypassing the two overrides above — so anything + // reading the identity as a pair would test the wrong values. + override suspend fun getProfileIdentity(): ProfileIdentity = + ProfileIdentity(getProfileId(), getProfileToken()) } private class InFlightSwitchingTokenManager( @@ -716,6 +722,9 @@ class PrairieAuthPluginPinTest { override suspend fun getProfileId(): String = "$activeServer-profile" + override suspend fun getProfileIdentity(): ProfileIdentity = + ProfileIdentity(getProfileId(), getProfileToken()) + override suspend fun getProfileToken(): String = "$activeServer-profile-token" override suspend fun invalidateSession() { diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshHazardTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshHazardTest.kt new file mode 100644 index 000000000..d04400d6a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshHazardTest.kt @@ -0,0 +1,441 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.prairieserver.prairie.network.api.BrandingApi +import org.prairieserver.prairie.network.api.HealthApi + +/** + * The two ways refreshing early can be worse than refreshing late: doing it on + * every request, and doing it after the server has already said no. + */ +class PrairieAuthPluginProactiveRefreshHazardTest { + + /** + * A server whose access tokens are shorter than the refresh margin is + * inside the window from the instant it issues one. Without the half-life + * clamp every request refreshes, and every refresh rotates the refresh + * token — a storm that invites rate limiting and turns one transient + * rejection into a signed-out session. + */ + @Test + fun aServerIssuingShortTokensDoesNotRefreshOnEveryRequest() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("live-access", "refresh-token", expiresIn = 30) + } + val sent = mutableListOf>() + val client = client(tokenManager, sent) + + repeat(5) { client.get("/api/v1/home/sections") } + + val refreshes = sent.count { it.first.endsWith("/auth/refresh") } + assertEquals( + 0, + refreshes, + "a 30s token against a 60s margin refreshed $refreshes times in 5 requests", + ) + assertTrue(sent.all { it.second == "Bearer live-access" }) + } + + /** + * When the refresh token has been revoked, the proactive refresh returns + * 401 and the session is torn down. The original request must not go out at + * all. Stripping only the bearer is not enough: an optionally-authenticated + * endpoint would accept the anonymous remainder, turning a repudiated write + * into a successful anonymous one. A write is the motivating case, so this + * uses one. + */ + @Test + fun aRepudiatedSessionDoesNotSendTheRequestAtAll() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("live-access", "revoked-refresh", expiresIn = 0) + setProfileId("profile-1") + setProfileToken("profile-token-1") + } + val sent = mutableListOf>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + content = """{"error":"invalid_grant","message":"revoked"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + // Deliberately generous: an endpoint that would happily + // accept the anonymous remainder of the request. + respond( + content = """{"ok":true}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + val failure = assertFailsWith { + client.post("/api/v1/watch/history") + } + assertEquals(PrairieAuthUnavailableException.CREDENTIALS_REPUDIATED, failure.reason) + + assertTrue( + sent.any { it.first.endsWith("/auth/refresh") }, + "the proactive refresh should still have been attempted", + ) + assertTrue( + sent.none { it.first == "/api/v1/watch/history" }, + "the repudiated write reached the server as ${sent.map { it.first }}", + ) + } + + /** + * A read is NOT given an anonymous second chance. "Safe methods don't + * change state" is false here — GET /downloads/{id}/file completes the + * download server-side — and an optionally-authenticated read would hand + * back GUEST data with a 200 that callers cache while the user is being + * signed out. Genuinely public calls opt out with skipPrairieAuth() and never + * reach this path at all. + */ + @Test + fun anAuthenticatedReadIsNotRetriedAnonymouslyAfterRepudiation() = runTest { + val tokenManager = repudiatedTokenManager() + val sent = mutableListOf>() + val client = repudiatingClient(tokenManager, sent) + + assertFailsWith { + client.get("/api/v1/home/sections") + } + assertTrue( + sent.none { it.first == "/api/v1/home/sections" }, + "an authenticated read was resent anonymously: ${sent.map { it.first }}", + ) + } + + /** + * The public escape hatch: a call that opted out never receives a bearer, + * so it never enters the proactive path and a dead session elsewhere cannot + * fail it. + */ + @Test + fun aPublicOptedOutReadIsUnaffectedByARepudiatedSession() = runTest { + val tokenManager = repudiatedTokenManager() + val sent = mutableListOf>() + val client = repudiatingClient(tokenManager, sent) + + val response = client.get("/api/v1/health") { skipPrairieAuth() } + + assertEquals(HttpStatusCode.OK, response.status) + assertNull(sent.single { it.first == "/api/v1/health" }.second) + assertTrue( + sent.none { it.first.endsWith("/auth/refresh") }, + "an opted-out call should not have triggered a refresh at all", + ) + } + + /** + * A refresh service returning 5xx must not be asked twice for one request: + * the proactive attempt fails, the request goes out and 401s, and the + * reactive path must NOT immediately ask again. + */ + @Test + fun aTransientRefreshFailureIsNotImmediatelyRetriedByTheReactivePath() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("live-access", "refresh-token", expiresIn = 0) + } + val sent = mutableListOf>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + content = """{"error":"bad_gateway"}""", + status = HttpStatusCode.BadGateway, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + respond( + content = """{"error":"unauthorized"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("/api/v1/home/sections") + + val refreshes = sent.count { it.first.endsWith("/auth/refresh") } + assertEquals( + 1, + refreshes, + "one request produced $refreshes refresh attempts against a failing refresh service", + ) + } + + /** + * Suppressing the second refresh must not suppress RECOVERY. If another + * request installs a working token while this one is in flight, this one + * should retry with it — that costs no network call, and returning a stale + * 401 while usable credentials are sitting there is just a lost request. + */ + @Test + fun aConcurrentlyRotatedTokenStillRecoversAfterATransientFailure() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("stale-access", "refresh-token", expiresIn = 0) + } + val sent = mutableListOf>() + var rotated = false + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + when { + request.url.encodedPath.endsWith("/auth/refresh") -> respond( + content = """{"error":"bad_gateway"}""", + status = HttpStatusCode.BadGateway, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + + request.headers[HttpHeaders.Authorization] == "Bearer stale-access" -> { + if (!rotated) { + rotated = true + // Stand in for a concurrent request whose refresh + // succeeded and installed a working token. + tokenManager.saveTokens("rotated-access", "rotated-refresh", 3600) + } + respond( + content = """{"error":"unauthorized"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + else -> respond( + content = """{"sections":[]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + val response = client.get("/api/v1/home/sections") + + assertEquals( + HttpStatusCode.OK, + response.status, + "a usable token was already installed; the request should have retried with it", + ) + assertEquals( + "Bearer rotated-access", + sent.last { it.first == "/api/v1/home/sections" }.second, + ) + assertEquals( + 1, + sent.count { it.first.endsWith("/auth/refresh") }, + "recovery must not cost a second refresh call", + ) + } + + /** + * Server-name resolution must survive a dead session. Branding is the + * primary source since #192 and health is only its fallback, so if a + * repudiated session could fail branding, the app would quietly go back to + * the compatibility name that change exists to replace. + */ + @Test + fun serverNameProbesSurviveARepudiatedSession() = runTest { + val tokenManager = repudiatedTokenManager() + val sent = mutableListOf>() + val client = repudiatingClient(tokenManager, sent) + + val branding = BrandingApi(client).getBranding() + val health = HealthApi(client).checkHealth() + + assertTrue(branding is ApiResult.Success, "branding failed: $branding") + assertTrue(health is ApiResult.Success, "health failed: $health") + assertTrue( + sent.filter { !it.first.endsWith("/auth/refresh") }.all { it.second == null }, + "a public identity probe carried a bearer: $sent", + ) + } + + /** + * A request captures its bearer before it waits on the refresh mutex. If a + * concurrent sign-out clears the credentials in that window, the refresh + * reports only "nothing was refreshed" — which must not be read as + * permission to spend the bearer that no longer exists. + */ + @Test + fun aSignOutWhileWaitingStopsTheRequestBeingSent() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("live-access", "refresh-token", expiresIn = 0) + } + val sent = mutableListOf>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + // Stand in for a concurrent sign-out landing while this + // request was waiting on the refresh mutex. + tokenManager.clearTokens() + respond( + content = """{"error":"bad_gateway"}""", + status = HttpStatusCode.BadGateway, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + respond( + content = """{"ok":true}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + assertFailsWith { + client.post("/api/v1/watch/history") + } + assertTrue( + sent.none { it.first == "/api/v1/watch/history" }, + "a signed-out session still sent its request: ${sent.map { it.first }}", + ) + } + + /** + * The benign half: credentials rotated by someone else while we waited are + * still usable, so the request goes out with the token that is actually + * installed rather than the stale capture. + */ + @Test + fun credentialsRotatedWhileWaitingAreSpentInsteadOfTheStaleCapture() = runTest { + val tokenManager = TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("stale-access", "refresh-token", expiresIn = 0) + } + val sent = mutableListOf>() + val client = HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + tokenManager.saveTokens("rotated-access", "rotated-refresh", 3600) + respond( + content = """{"error":"bad_gateway"}""", + status = HttpStatusCode.BadGateway, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + respond( + content = """{"sections":[]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + client.get("/api/v1/home/sections") + + assertEquals( + "Bearer rotated-access", + sent.last { it.first == "/api/v1/home/sections" }.second, + "the request spent a token that had already been replaced", + ) + } + + private suspend fun repudiatedTokenManager(): TokenManagerImpl = + TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens("live-access", "revoked-refresh", expiresIn = 0) + } + + private fun repudiatingClient( + tokenManager: TokenManager, + sent: MutableList>, + ): HttpClient = + HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + content = """{"error":"invalid_grant"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + // Deliberately generous: would accept an anonymous request. + // Body satisfies both HealthStatus and BrandingStatus so + // the same client can stand in for either probe. + respond( + content = """{"status":"ok","server_name":"Living Room"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + private fun client( + tokenManager: TokenManager, + sent: MutableList>, + ): HttpClient = + HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + content = """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":30}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + respond( + content = """{"sections":[]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshTest.kt new file mode 100644 index 000000000..fa46873cc --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/PrairieAuthPluginProactiveRefreshTest.kt @@ -0,0 +1,114 @@ +package org.prairieserver.prairie.network + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A token already past its deadline must be refreshed BEFORE it is spent. + * + * Every TokenManager has always recorded an expiry at save time and nothing + * ever read it, so expiry was only discoverable by a 401: the first request + * after the deadline paid a wasted round trip. On a live device that was 42 of + * 351 `/home/sections` calls. + */ +class PrairieAuthPluginProactiveRefreshTest { + + @Test + fun anExpiredTokenIsRefreshedBeforeTheRequestIsSent() = runTest { + val tokenManager = tokenManager(expiresIn = 0) + val sent = mutableListOf>() + val client = client(tokenManager, sent) + + val response = client.get("/api/v1/home/sections") + + assertEquals(HttpStatusCode.OK, response.status) + // Refresh first, then the real call — and the real call carries the new + // token, never the doomed one. One round trip saved. + assertEquals( + listOf>( + "/api/v1/auth/refresh" to null, + "/api/v1/home/sections" to "Bearer fresh-access", + ), + sent, + ) + assertEquals("fresh-access", tokenManager.getAccessToken()) + } + + @Test + fun aHealthyTokenIsSpentWithoutAnExtraRoundTrip() = runTest { + val tokenManager = tokenManager(expiresIn = 3600) + val sent = mutableListOf>() + val client = client(tokenManager, sent) + + client.get("/api/v1/home/sections") + + assertEquals( + listOf>("/api/v1/home/sections" to "Bearer live-access"), + sent, + ) + } + + /** + * No credentials means nothing to refresh. Without this the plugin would + * spend a refresh attempt on every unauthenticated call. + */ + @Test + fun aSignedOutClientDoesNotRefreshAtAll() = runTest { + val tokenManager = TokenManagerImpl().apply { setServerUrl("https://silo.example") } + val sent = mutableListOf>() + val client = client(tokenManager, sent) + + client.get("/api/v1/home/sections") + + assertEquals( + listOf>("/api/v1/home/sections" to null), + sent, + ) + } + + private suspend fun tokenManager(expiresIn: Long): TokenManagerImpl = + TokenManagerImpl().apply { + setServerUrl("https://silo.example") + saveTokens( + accessToken = "live-access", + refreshToken = "refresh-token", + expiresIn = expiresIn, + ) + } + + private fun client( + tokenManager: TokenManager, + sent: MutableList>, + ): HttpClient = + HttpClient( + MockEngine { request -> + sent += request.url.encodedPath to request.headers[HttpHeaders.Authorization] + if (request.url.encodedPath.endsWith("/auth/refresh")) { + respond( + content = """{"access_token":"fresh-access","refresh_token":"fresh-refresh","expires_in":3600}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } else { + respond( + content = """{"sections":[]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicyTest.kt new file mode 100644 index 000000000..e1c7b00cb --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/ProactiveRefreshPolicyTest.kt @@ -0,0 +1,94 @@ +package org.prairieserver.prairie.network + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The half-life clamp. A fixed margin against a token shorter than the margin + * puts every request inside the refresh window, and every refresh rotates the + * refresh token. + */ +class ProactiveRefreshPolicyTest { + + private val margin = 60_000L + + @Test + fun refreshesInsideTheMarginForANormalLifetime() { + assertTrue( + shouldRefreshProactively( + remainingMs = 30_000, + lifetimeMs = 3_600_000, + marginMs = margin, + ), + ) + } + + @Test + fun leavesAHealthyTokenAlone() { + assertFalse( + shouldRefreshProactively( + remainingMs = 3_000_000, + lifetimeMs = 3_600_000, + marginMs = margin, + ), + ) + } + + @Test + fun aFreshlyIssuedShortTokenIsNotAlreadyDue() { + // expires_in=30 against a 60s margin: without the clamp this is true + // the instant the server issues it, so every request refreshes and + // every refresh rotates - a storm. + assertFalse( + shouldRefreshProactively( + remainingMs = 30_000, + lifetimeMs = 30_000, + marginMs = margin, + ), + "a token that has not been spent yet must not already be due", + ) + } + + @Test + fun aShortTokenStillRefreshesAtItsHalfLife() { + assertTrue( + shouldRefreshProactively( + remainingMs = 15_000, + lifetimeMs = 30_000, + marginMs = margin, + ), + "clamping must not disable proactive refresh, only delay it to half-life", + ) + } + + /** + * Credentials stored before the lifetime field existed load with a null + * lifetime. Guessing with the full margin is exactly how the storm starts - + * and because a 5xx refresh never persists a lifetime, every request would + * retry it for the whole outage. Stay reactive until an issuance says more. + */ + @Test + fun anUnknownLifetimeStaysReactive() { + assertFalse( + shouldRefreshProactively(30_000, lifetimeMs = null, marginMs = margin), + "an upgraded install must not refresh on a lifetime it never recorded", + ) + assertFalse(shouldRefreshProactively(90_000, lifetimeMs = null, marginMs = margin)) + } + + @Test + fun anExpiredTokenIsAlwaysDueEvenWithoutAKnownLifetime() { + assertTrue(shouldRefreshProactively(-5_000, lifetimeMs = 30_000, marginMs = margin)) + assertTrue(shouldRefreshProactively(0, lifetimeMs = 30_000, marginMs = margin)) + assertTrue( + shouldRefreshProactively(-1, lifetimeMs = null, marginMs = margin), + "nothing is conserved by spending a token that has already expired", + ) + } + + @Test + fun aNonPositiveLifetimeIsNotTreatedAsAZeroMargin() { + assertFalse(shouldRefreshProactively(30_000, lifetimeMs = 0, marginMs = margin)) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AdminApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AdminApiTest.kt deleted file mode 100644 index 069115f2c..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AdminApiTest.kt +++ /dev/null @@ -1,296 +0,0 @@ -package org.prairieserver.prairie.network.api - -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.PrairieJson -import io.ktor.client.HttpClient -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.toByteArray -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import io.ktor.serialization.kotlinx.json.json -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.jsonObject -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertTrue - -class AdminApiTest { - - private class Captured { - var method: HttpMethod? = null - var path: String = "" - var query: Map = emptyMap() - var body: String = "" - } - - private fun api( - status: HttpStatusCode = HttpStatusCode.OK, - responseBody: String = "{}", - captured: Captured = Captured(), - ): Pair { - val client = HttpClient( - MockEngine { request -> - captured.method = request.method - captured.path = request.url.encodedPath - captured.query = request.url.parameters.names() - .associateWith { request.url.parameters[it] } - captured.body = request.body.toByteArray().decodeToString() - respond( - content = responseBody, - status = status, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - }, - ) { - install(ContentNegotiation) { json(PrairieJson) } - } - return DefaultAdminApi(client) to captured - } - - private val statsBody = """ - {"total_items":1,"total_files":1,"total_users":1,"total_movies":1, - "total_movie_files":1,"total_shows":0,"total_show_files":0, - "active_streams":0,"total_storage_bytes":10, - "watch_provider_activity":{"trakt_connected_profiles":1,"scrobbles_24h":2}} - """.trimIndent() - - @Test - fun `getStats omits refresh when false`() = runTest { - val (api, captured) = api(responseBody = statsBody) - val result = api.getStats(refresh = false) - assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/admin/stats", captured.path) - assertFalse("refresh" in captured.query.keys) - assertIs>(result) - assertEquals(2L, (result as ApiResult.Success).data.watchProviderActivity.scrobbles24h) - } - - @Test - fun `getStats passes refresh=true`() = runTest { - val (api, captured) = api(responseBody = statsBody) - api.getStats(refresh = true) - assertEquals("true", captured.query["refresh"]) - } - - @Test - fun `getUsers hits users path`() = runTest { - val (api, captured) = api(responseBody = "[]") - val result = api.getUsers() - assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/admin/users", captured.path) - assertIs>(result) - } - - @Test - fun `getUser hits id path`() = runTest { - val (api, captured) = api( - responseBody = """{"id":7,"username":"a","email":"a@x.io","role":"user", - "permissions":[],"enabled":true,"library_ids":[],"max_playback_quality":"", - "max_streams":0,"max_transcodes":0,"max_profiles":0, - "download_allowed":false,"download_transcode_allowed":false, - "created_at":"t","updated_at":"t"}""", - ) - val result = api.getUser(7) - assertEquals("/api/v1/admin/users/7", captured.path) - assertIs>(result) - assertEquals(7, (result as ApiResult.Success).data.id) - } - - @Test - fun `createUser posts body to users path`() = runTest { - val (api, captured) = api( - responseBody = """{"id":9,"username":"bob","email":"b@x.io","role":"user", - "permissions":[],"enabled":true,"library_ids":[],"max_playback_quality":"", - "max_streams":0,"max_transcodes":0,"max_profiles":0, - "download_allowed":false,"download_transcode_allowed":false, - "created_at":"t","updated_at":"t"}""", - ) - val result = api.createUser( - CreateUserRequest( - username = "bob", email = "b@x.io", password = "pw", role = "user", - createDefaultProfile = true, - ), - ) - assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/admin/users", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("bob", sent["username"]?.toString()?.trim('"')) - assertTrue("password" in sent.keys) - assertTrue("max_streams" !in sent.keys) // null omitted - assertIs>(result) - } - - @Test - fun `updateUser puts partial body to id path`() = runTest { - val (api, captured) = api( - responseBody = """{"id":7,"username":"a","email":"a@x.io","role":"user", - "permissions":[],"enabled":false,"library_ids":[],"max_playback_quality":"", - "max_streams":4,"max_transcodes":0,"max_profiles":0, - "download_allowed":false,"download_transcode_allowed":false, - "created_at":"t","updated_at":"t"}""", - ) - val result = api.updateUser(7, UpdateUserRequest(enabled = false, maxStreams = 4)) - assertEquals(HttpMethod.Put, captured.method) - assertEquals("/api/v1/admin/users/7", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals(setOf("enabled", "max_streams"), sent.keys) // only set fields - assertIs>(result) - } - - @Test - fun `deleteUser deletes id path and maps 204 to Unit`() = runTest { - val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") - val result = api.deleteUser(7) - assertEquals(HttpMethod.Delete, captured.method) - assertEquals("/api/v1/admin/users/7", captured.path) - assertEquals(ApiResult.Success(Unit), result) - } - - @Test - fun `getSessions hits sessions path`() = runTest { - val (api, captured) = api(responseBody = "[]") - val result = api.getSessions() - assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/admin/sessions", captured.path) - assertIs>(result) - } - - @Test - fun `sessionControl posts action path with body and decodes response`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Accepted, - responseBody = """{"command_id":"cmd-1","status":"dispatched"}""", - ) - val result = api.sessionControl( - "sess-9", - SessionControlAction.Message, - SessionControlRequest(title = "Heads up", message = "Stopping soon"), - ) - assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/admin/sessions/sess-9/message", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("Stopping soon", sent["message"]?.toString()?.trim('"')) - assertTrue("reason" !in sent.keys) // null omitted - assertIs>(result) - assertEquals("cmd-1", (result as ApiResult.Success).data.commandId) - } - - @Test - fun `sessionControl pause uses pause segment`() = runTest { - val (api, captured) = api( - status = HttpStatusCode.Accepted, - responseBody = """{"command_id":"c","status":"dispatched"}""", - ) - api.sessionControl("s1", SessionControlAction.Pause, SessionControlRequest(deadlineMs = 5000)) - assertEquals("/api/v1/admin/sessions/s1/pause", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("5000", sent["deadline_ms"]?.toString()) - } - - @Test - fun `getAppLogs passes filters and cursor and limit, omits nulls`() = runTest { - val (api, captured) = api(responseBody = """{"entries":[]}""") - val result = api.getAppLogs( - level = "error", - component = "scanner", - nodeId = null, - requestId = null, - sessionId = null, - playbackSessionId = null, - userId = 3, - from = "2026-06-12T00:00:00Z", - to = null, - query = "fail", - cursor = "cur-1", - limit = 50, - ) - assertEquals("/api/v1/admin/logs/app", captured.path) - assertEquals("error", captured.query["level"]) - assertEquals("scanner", captured.query["component"]) - assertEquals("3", captured.query["user_id"]) - assertEquals("2026-06-12T00:00:00Z", captured.query["from"]) - assertEquals("fail", captured.query["q"]) - assertEquals("cur-1", captured.query["cursor"]) - assertEquals("50", captured.query["limit"]) - assertFalse("node_id" in captured.query.keys) - assertFalse("to" in captured.query.keys) - assertIs>(result) - } - - @Test - fun `getAuditLogs passes audit filters and omits nulls`() = runTest { - val (api, captured) = api(responseBody = """{"entries":[]}""") - api.getAuditLogs( - method = "POST", - pathPrefix = "/api/v1/admin", - statusCode = 201, - clientIp = null, - requestId = null, - sessionId = null, - playbackSessionId = null, - userId = null, - from = null, - to = null, - cursor = null, - limit = 100, - ) - assertEquals("/api/v1/admin/logs/audit", captured.path) - assertEquals("POST", captured.query["method"]) - assertEquals("/api/v1/admin", captured.query["path_prefix"]) - assertEquals("201", captured.query["status_code"]) - assertEquals("100", captured.query["limit"]) - assertFalse("client_ip" in captured.query.keys) - assertFalse("cursor" in captured.query.keys) - } - - @Test - fun `triggerScan posts to libraries scan with body`() = runTest { - val (api, captured) = api( - responseBody = """{"status":"scanning","mode":"incremental","library_id":4}""", - ) - val result = api.triggerScan(ScanRequest(libraryId = 4)) - assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/libraries/scan", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("4", sent["library_id"]?.toString()) - assertTrue("path" !in sent.keys) - assertIs>(result) - assertEquals(4, (result as ApiResult.Success).data.libraryId) - } - - @Test - fun `cancelScan posts to libraries scan cancel with body`() = runTest { - val (api, captured) = api(responseBody = """{"cancelled":1,"library_id":4}""") - val result = api.cancelScan(ScanCancelRequest(libraryId = 4)) - assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/libraries/scan/cancel", captured.path) - val sent = PrairieJson.parseToJsonElement(captured.body).jsonObject - assertEquals("4", sent["library_id"]?.toString()) - assertIs>(result) - assertEquals(1, (result as ApiResult.Success).data.cancelled) - } - - @Test - fun `server error surfaces as ApiResult Error with message`() = runTest { - val (api, _) = api( - status = HttpStatusCode.Forbidden, - responseBody = """{"error":"forbidden","message":"Admin access required"}""", - ) - val result = api.getStats(refresh = false) - assertIs(result) - assertEquals(403, result.code) - assertEquals("Admin access required", result.message) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt index 349ac6840..79bf69c6f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/AuthApiTest.kt @@ -8,14 +8,18 @@ import org.prairieserver.prairie.network.PrairieJson import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertTrue class AuthApiTest { private val loginJson = """ @@ -23,9 +27,24 @@ class AuthApiTest { "user":{"id":1,"username":"u","email":"e","role":"user"}} """.trimIndent() - private fun api(body: String = loginJson, status: HttpStatusCode = HttpStatusCode.OK): AuthApi { + private class Captured { + var method: HttpMethod? = null + var url: String = "" + var body: String = "" + } + + private fun api( + body: String = loginJson, + status: HttpStatusCode = HttpStatusCode.OK, + captured: Captured? = null, + ): AuthApi { val client = HttpClient( - MockEngine { respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) }, + MockEngine { request -> + captured?.method = request.method + captured?.url = request.url.toString() + captured?.body = request.body.toByteArray().decodeToString() + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + }, ) { install(ContentNegotiation) { json(PrairieJson) } } return AuthApi(client) } @@ -53,4 +72,41 @@ class AuthApiTest { assertIs>(api(status = HttpStatusCode.NoContent, body = "").revokeSession("s1")) assertIs>(api(status = HttpStatusCode.NoContent, body = "").deleteSession("s1")) } + + @Test + fun lookupInvitationPathEncodesTokenAndParsesClaimPreview() = runTest { + val captured = Captured() + val result = api( + body = """ + {"email":"invitee@example.com","inviter_name":"Host", + "server_name":"Prairie","expires_at":"2026-08-01T00:00:00Z"} + """.trimIndent(), + captured = captured, + ).lookupInvitation("https://srv.example/", "tok/en?raw") + + assertEquals(HttpMethod.Get, captured.method) + assertTrue(captured.url.contains("/api/v1/invitations/")) + assertTrue(captured.url.contains("tok"), "token should remain in the path") + assertIs>(result) + val lookup = (result as ApiResult.Success).data + assertEquals("invitee@example.com", lookup.email) + assertEquals("Host", lookup.inviterName) + assertEquals("Prairie", lookup.serverName) + assertEquals("2026-08-01T00:00:00Z", lookup.expiresAt) + } + + @Test + fun acceptInvitationPostsPasswordAndReturnsLogin() = runTest { + val captured = Captured() + val result = api(captured = captured).acceptInvitation( + serverUrl = "https://srv.example", + token = "claim-token", + password = "secret-pass", + ) + + assertEquals(HttpMethod.Post, captured.method) + assertTrue(captured.url.endsWith("/api/v1/invitations/claim-token/accept")) + assertTrue(captured.body.contains("\"password\":\"secret-pass\"")) + assertIs>(result) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/BrandingApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/BrandingApiTest.kt new file mode 100644 index 000000000..17f6184a6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/BrandingApiTest.kt @@ -0,0 +1,39 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class BrandingApiTest { + @Test + fun `getBranding decodes native server name`() = runTest { + val api = BrandingApi( + HttpClient( + MockEngine { + respond( + content = """{"server_name":"Home Silo","login_subtitle":"Welcome"}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + }, + ) + + val result = assertIs>(api.getBranding()) + + assertEquals("Home Silo", result.data.serverName) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApiTest.kt index 98d3a10ef..0bd837f59 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/DiagnosticsApiTest.kt @@ -2,23 +2,31 @@ package org.prairieserver.prairie.network.api import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.toByteArray import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf import io.ktor.http.toHttpDate import io.ktor.util.date.GMTDate import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.test.runTest import org.prairieserver.prairie.model.diagnostics.DiagnosticsAvailabilityStatus import org.prairieserver.prairie.model.diagnostics.DiagnosticsErrorCode import org.prairieserver.prairie.model.diagnostics.DiagnosticsUploadResult import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.DiagnosticsUploadAuthorization import org.prairieserver.prairie.network.PrairieAuthPlugin import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.TokenManagerImpl @@ -203,6 +211,106 @@ class DiagnosticsApiTest { assertFalse(fixture.requestBody.contains("manifest")) } + @Test + fun exactUploadDoesNotProactivelyRefreshWhileIdentityLeaseIsHeld() = runTest { + val transitions = DefaultIdentityTransitionBarrier() + val tokenManager = TokenManagerImpl(transitions).apply { + setServerUrl("https://silo.example") + saveTokens("expired-active", "refresh-token", 0) + setProfileIdentity("active-profile", "active-profile-token") + } + val requests = mutableListOf() + val client = exactUploadClient(tokenManager) { request -> + requests += request + respond( + content = if (request.url.encodedPath.endsWith("/auth/refresh")) { + """{"access_token":"fresh","refresh_token":"fresh-refresh","expires_in":3600}""" + } else { + """{"report_id":"report-1","short_id":"ABC123"}""" + }, + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val authorization = exactAuthorization(transitions.generation.value, "captured-access") + + val result = withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(1_000) { + transitions.withCurrentGeneration(transitions.generation.value) { + DefaultDiagnosticsApi(client).upload( + byteArrayOf(1), + byteArrayOf(2), + capturedProfileId = "captured-profile", + authorization = authorization, + ) + } + } + } + + assertIs(result) + assertEquals(listOf("/api/v1/diagnostics/reports"), requests.map { it.url.encodedPath }) + assertEquals("Bearer captured-access", requests.single().headers[HttpHeaders.Authorization]) + assertEquals("captured-profile", requests.single().headers["X-Profile-Id"]) + assertNull(requests.single().headers["X-Profile-Token"]) + } + + @Test + fun exactUploadSurfacesUnauthorizedWithoutRefreshOrSessionInvalidationUnderLease() = runTest { + val transitions = DefaultIdentityTransitionBarrier() + val tokenManager = TokenManagerImpl(transitions).apply { + setServerUrl("https://silo.example") + saveTokens("rejected-active", "refresh-token", 3_600) + } + val paths = mutableListOf() + val client = exactUploadClient(tokenManager) { request -> + paths += request.url.encodedPath + respond( + content = """{"error":"unauthorized","message":"expired"}""", + status = HttpStatusCode.Unauthorized, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val authorization = exactAuthorization(transitions.generation.value, "rejected-active") + + val result = withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(1_000) { + transitions.withCurrentGeneration(transitions.generation.value) { + DefaultDiagnosticsApi(client).upload( + byteArrayOf(1), + byteArrayOf(2), + capturedProfileId = null, + authorization = authorization, + ) + } + } + } + + val failure = assertIs(result) + assertEquals(DiagnosticsErrorCode.UNAUTHORIZED, failure.code) + assertEquals(listOf("/api/v1/diagnostics/reports"), paths) + assertEquals("rejected-active", tokenManager.getAccessToken()) + assertEquals("refresh-token", tokenManager.getRefreshToken()) + } + + private fun exactUploadClient( + tokenManager: TokenManagerImpl, + handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData, + ): HttpClient = HttpClient(MockEngine(handler)) { + install(ContentNegotiation) { json(PrairieJson) } + install(PrairieAuthPlugin) { this.tokenManager = tokenManager } + } + + private fun exactAuthorization( + identityGeneration: Long, + accessToken: String, + ) = DiagnosticsUploadAuthorization( + serverId = "server-1", + serverUrl = "https://silo.example", + accessToken = accessToken, + activeProfileId = "active-profile", + identityGeneration = identityGeneration, + ) + private suspend fun fixture( responseStatus: HttpStatusCode = HttpStatusCode.OK, responseBody: String = if (responseStatus == HttpStatusCode.Created) { @@ -222,7 +330,7 @@ class DiagnosticsApiTest { retryAfterHeader: String? = retryAfterSeconds?.toString(), ): Fixture { val tokenManager = TokenManagerImpl().apply { - setServerUrl("https://prairie.example") + setServerUrl("https://silo.example") saveTokens("access-token", "refresh-token", 3_600) setProfileId("active-profile") setProfileToken("active-profile-token") diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApiTest.kt new file mode 100644 index 000000000..ea83ecf46 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/HostedDiagnosticsApiTest.kt @@ -0,0 +1,375 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.prairieserver.prairie.model.diagnostics.DiagnosticsArchive +import org.prairieserver.prairie.model.diagnostics.DiagnosticsConsent +import org.prairieserver.prairie.model.diagnostics.DiagnosticsConsentMode +import org.prairieserver.prairie.model.diagnostics.DiagnosticsDestination +import org.prairieserver.prairie.model.diagnostics.DiagnosticsDeviceSummary +import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory +import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogSummary +import org.prairieserver.prairie.model.diagnostics.DiagnosticsManifest +import org.prairieserver.prairie.model.diagnostics.DiagnosticsPlatform +import org.prairieserver.prairie.model.diagnostics.DiagnosticsReport +import org.prairieserver.prairie.model.diagnostics.DiagnosticsReportType +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HostedDiagnosticsApiTest { + @Test + fun dedicatedCollectorFlowUsesOnlyAnonymousCollectorCredentials() = runTest { + val captured = mutableListOf() + val engine = MockEngine { request -> + captured += CapturedRequest( + method = request.method, + host = request.url.host, + path = request.url.encodedPath, + headers = request.headers, + contentType = request.body.contentType?.toString(), + contentLength = request.body.contentLength, + body = request.body.toByteArray(), + ) + val (status, body) = when { + request.url.encodedPath == "/v1/reports/$REPORT_ID" && request.method == HttpMethod.Delete -> { + HttpStatusCode.NoContent to "" + } + request.url.encodedPath == "/v1/capabilities" -> HttpStatusCode.OK to CAPABILITIES + request.url.encodedPath == "/v1/installations" -> HttpStatusCode.Created to INSTALLATION + request.url.encodedPath == "/v1/reports" -> HttpStatusCode.Created to CREATED + request.url.encodedPath == "/v1/reports/$REPORT_ID/bundle" -> HttpStatusCode.Accepted to PUT_STATUS + request.url.encodedPath == "/v1/reports/$REPORT_ID" -> HttpStatusCode.OK to STATUS + else -> HttpStatusCode.NotFound to "{}" + } + respond( + content = body, + status = status, + headers = Headers.build { append(HttpHeaders.ContentType, "application/json") }, + ) + } + val transport = createHostedDiagnosticsClient( + baseUrl = "https://collector.example", + platformClient = HttpClient(engine), + ) + val api = DefaultHostedDiagnosticsApi(transport) + + assertIs>(api.capabilities()) + assertIs>( + api.createInstallation(HostedDiagnosticsInstallationRequest("android", "org.prairieserver.prairie", "1.2", "34")), + ) + assertIs>( + api.createReport( + installationToken = INSTALLATION_TOKEN, + request = HostedDiagnosticsCreateReportRequest( + reportId = REPORT_ID, + manifest = manifest(), + bundleBytes = BUNDLE.size.toLong(), + bundleSha256 = "a".repeat(64), + ), + ), + ) + val upload = assertIs>( + api.uploadBundle(INSTALLATION_TOKEN, REPORT_ID, UPLOAD_TOKEN, BUNDLE), + ) + val status = assertIs>( + api.reportStatus(INSTALLATION_TOKEN, REPORT_ID), + ) + assertIs>( + api.deleteReport(INSTALLATION_TOKEN, REPORT_ID), + ) + + assertEquals(HostedDiagnosticsReportState.PROCESSING, status.value.state) + assertEquals(REPORT_ID, upload.value.reportId) + assertEquals("ABC123", upload.value.shortId) + assertEquals(HostedDiagnosticsReportState.PROCESSING, upload.value.state) + assertTrue(captured.all { it.host == "collector.example" }) + assertEquals( + listOf(HttpMethod.Get, HttpMethod.Post, HttpMethod.Post, HttpMethod.Put, HttpMethod.Get, HttpMethod.Delete), + captured.map(CapturedRequest::method), + ) + assertEquals( + listOf( + "/v1/capabilities", + "/v1/installations", + "/v1/reports", + "/v1/reports/$REPORT_ID/bundle", + "/v1/reports/$REPORT_ID", + "/v1/reports/$REPORT_ID", + ), + captured.map(CapturedRequest::path), + ) + captured.forEachIndexed { index, request -> + assertNull(request.headers["X-Profile-Id"], "request $index") + assertNull(request.headers["X-Profile-Token"], "request $index") + assertNull(request.headers["X-Prairie-Device-Id"], "request $index") + assertNull(request.headers[HttpHeaders.Cookie], "request $index") + assertTrue( + request.headers.names().none { it.startsWith("X-Prairie-", ignoreCase = true) }, + "request $index must not inherit Silo client headers", + ) + } + assertNull(captured[0].headers[HttpHeaders.Authorization]) + assertNull(captured[1].headers[HttpHeaders.Authorization]) + captured.drop(2).forEach { request -> + assertEquals("Bearer $INSTALLATION_TOKEN", request.headers[HttpHeaders.Authorization]) + } + assertEquals(UPLOAD_TOKEN, captured[3].headers["X-Upload-Token"]) + assertEquals(BUNDLE.size.toString(), captured[3].headers[HttpHeaders.ContentLength]) + assertEquals(listOf(BUNDLE.size.toString()), captured[3].headers.getAll(HttpHeaders.ContentLength)) + assertEquals(BUNDLE.size.toLong(), captured[3].contentLength) + assertEquals("application/gzip", captured[3].contentType) + assertContentEquals(BUNDLE, captured[3].body) + + val envelope = Json.parseToJsonElement(captured[2].body.decodeToString()).jsonObject + assertEquals(REPORT_ID, envelope.getValue("report_id").jsonPrimitive.content) + assertEquals("collector-public", envelope.getValue("manifest").jsonObject + .getValue("destination").jsonObject.getValue("server_instance_id").jsonPrimitive.content) + assertFalse(envelope.getValue("manifest").jsonObject.getValue("report").jsonObject.containsKey("report_id")) + val encoded = captured.joinToString("\n") { it.body.decodeToString() } + listOf(SOURCE_ACCESS, SOURCE_PROFILE, SOURCE_ACCOUNT, SOURCE_SERVER).forEach { sourceIdentity -> + assertFalse(encoded.contains(sourceIdentity), sourceIdentity) + } + transport.close() + } + + @Test + fun baseUrlValidationRequiresACanonicalOrigin() { + listOf( + "https://collector.example?debug=true", + "https://collector.example?", + "https://collector.example/#fragment", + "https://collector.example#", + "https://collector.example/v1", + "https://user:secret@collector.example", + "http://collector.example", + "http://localhost.evil", + "https://collector.example.evil@trusted.example", + " https://collector.example", + "https://collector.example ", + ).forEach { invalid -> + assertFailsWith(invalid) { validateHostedDiagnosticsBaseUrl(invalid) } + } + + assertEquals("https://collector.example", validateHostedDiagnosticsBaseUrl("https://collector.example/")) + assertEquals("https://collector.example:8443", validateHostedDiagnosticsBaseUrl("https://collector.example:8443")) + assertEquals("http://localhost:8787", validateHostedDiagnosticsBaseUrl("http://localhost:8787")) + assertEquals("http://127.0.0.1:8787", validateHostedDiagnosticsBaseUrl("http://127.0.0.1:8787")) + } + + @Test + fun crossOriginRedirectCannotReceiveBearerOrRawBundle() = runTest { + val captured = mutableListOf() + val transport = createHostedDiagnosticsClient( + baseUrl = "https://collector.example", + platformClient = HttpClient( + MockEngine { request -> + captured += CapturedRequest( + method = request.method, + host = request.url.host, + path = request.url.encodedPath, + headers = request.headers, + contentType = request.body.contentType?.toString(), + contentLength = request.body.contentLength, + body = request.body.toByteArray(), + ) + respond( + content = "", + status = HttpStatusCode.TemporaryRedirect, + headers = Headers.build { + append(HttpHeaders.Location, "https://redirect-attacker.example/stolen") + }, + ) + }, + ), + ) + + val result = DefaultHostedDiagnosticsApi(transport).uploadBundle( + installationToken = INSTALLATION_TOKEN, + reportId = REPORT_ID, + uploadToken = UPLOAD_TOKEN, + bundle = BUNDLE, + ) + + val failure = assertIs(result) + assertEquals(HttpStatusCode.TemporaryRedirect.value, failure.httpStatus) + assertEquals(1, captured.size) + assertEquals("collector.example", captured.single().host) + assertEquals("Bearer $INSTALLATION_TOKEN", captured.single().headers[HttpHeaders.Authorization]) + assertContentEquals(BUNDLE, captured.single().body) + transport.close() + } + + @Test + fun malformedAcceptedUploadReceiptIsAProtocolFailure() = runTest { + val transport = createHostedDiagnosticsClient( + baseUrl = "https://collector.example", + platformClient = HttpClient( + MockEngine { + respond( + content = "{not-json", + status = HttpStatusCode.Accepted, + headers = Headers.build { append(HttpHeaders.ContentType, "application/json") }, + ) + }, + ), + ) + + val result = assertIs( + DefaultHostedDiagnosticsApi(transport).uploadBundle( + INSTALLATION_TOKEN, + REPORT_ID, + UPLOAD_TOKEN, + BUNDLE, + ), + ) + + assertEquals(HttpStatusCode.Accepted.value, result.httpStatus) + assertEquals("invalid_response", result.errorCode) + transport.close() + } + + @Test + fun deleteFailurePreservesCollectorErrorForLocalRetry() = runTest { + val transport = createHostedDiagnosticsClient( + baseUrl = "https://collector.example", + platformClient = HttpClient( + MockEngine { + respond( + content = """{"error":"storage_unavailable","message":"try again"}""", + status = HttpStatusCode.ServiceUnavailable, + headers = Headers.build { + append(HttpHeaders.ContentType, "application/json") + append(HttpHeaders.RetryAfter, "60") + }, + ) + }, + ), + ) + + val result = assertIs( + DefaultHostedDiagnosticsApi(transport).deleteReport(INSTALLATION_TOKEN, REPORT_ID), + ) + + assertEquals(HttpStatusCode.ServiceUnavailable.value, result.httpStatus) + assertEquals("storage_unavailable", result.errorCode) + assertEquals(60, result.retryAfterSeconds) + transport.close() + } + + @Test + fun reportNotFoundRemainsAFailureForForeignInstallationOwnership() = runTest { + val transport = createHostedDiagnosticsClient( + baseUrl = "https://collector.example", + platformClient = HttpClient( + MockEngine { + respond( + content = """{"error":"report_not_found","message":"already erased"}""", + status = HttpStatusCode.NotFound, + headers = Headers.build { append(HttpHeaders.ContentType, "application/json") }, + ) + }, + ), + ) + + val result = assertIs( + DefaultHostedDiagnosticsApi(transport).deleteReport(INSTALLATION_TOKEN, REPORT_ID), + ) + assertEquals(HttpStatusCode.NotFound.value, result.httpStatus) + assertEquals("report_not_found", result.errorCode) + transport.close() + } + + @Test + fun reportStateMappingPreservesRejectedStateAndError() = runTest { + val client = HttpClient( + MockEngine { + respond( + content = """{"report_id":"$REPORT_ID","short_id":"ABC123","state":"rejected","error_code":"invalid_archive"}""", + status = HttpStatusCode.OK, + headers = Headers.build { append(HttpHeaders.ContentType, "application/json") }, + ) + }, + ) { + install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { + json(org.prairieserver.prairie.network.PrairieJson) + } + } + val result = assertIs>( + DefaultHostedDiagnosticsApi(client).reportStatus(INSTALLATION_TOKEN, REPORT_ID), + ) + + assertEquals(HostedDiagnosticsReportState.REJECTED, result.value.state) + assertEquals("invalid_archive", result.value.errorCode) + client.close() + } + + private fun manifest() = DiagnosticsManifest( + schemaVersion = 1, + report = DiagnosticsReport( + type = DiagnosticsReportType.MANUAL, + capturedAt = "2026-08-11T00:00:00Z", + captureSessionId = "capture-1", + appVersion = "1.2", + appBuild = "34", + platform = DiagnosticsPlatform.ANDROID, + osVersion = "36", + profileId = null, + ), + destination = DiagnosticsDestination("collector-public"), + consent = DiagnosticsConsent(DiagnosticsConsentMode.MANUAL, 1), + deviceSummary = DiagnosticsDeviceSummary("Google", "Pixel", "Android 36", "mobile"), + playbackSessionIds = emptyList(), + logSummary = DiagnosticsLogSummary(0, 0, 0, listOf(DiagnosticsLogCategory.OTHER), false), + archive = DiagnosticsArchive(listOf("manifest.json", "device.json"), BUNDLE.size.toLong(), 512, "a".repeat(64)), + ) + + private data class CapturedRequest( + val method: HttpMethod, + val host: String, + val path: String, + val headers: Headers, + val contentType: String?, + val contentLength: Long?, + val body: ByteArray, + ) + + private companion object { + const val REPORT_ID = "01234567-89ab-4def-8123-456789abcdef" + const val INSTALLATION_TOKEN = "collector-installation-token" + const val UPLOAD_TOKEN = "one-time-upload-token" + const val SOURCE_ACCESS = "silo-access-token" + const val SOURCE_PROFILE = "source-profile-id" + const val SOURCE_ACCOUNT = "source-account-id" + const val SOURCE_SERVER = "https://private-silo.example" + val BUNDLE = byteArrayOf(0x1f, 0x8b.toByte(), 1, 2, 3, 4) + val CAPABILITIES = """{ + "status":"available","collector_id":"collector-public","accepted_schema_versions":[1], + "max_bundle_bytes":10485760,"max_manifest_bytes":65536,"retention_days":30, + "consent_notice_version":1 + }""".trimIndent() + val INSTALLATION = """{"installation_id":"install-1","installation_token":"$INSTALLATION_TOKEN"}""" + val CREATED = """{ + "report_id":"$REPORT_ID","short_id":"ABC123","upload_token":"$UPLOAD_TOKEN", + "expires_at":"2026-09-10T00:00:00Z" + }""".trimIndent() + val PUT_STATUS = """{"report_id":"$REPORT_ID","short_id":"ABC123","state":"processing"}""" + val STATUS = """{"report_id":"$REPORT_ID","short_id":"ABC123","state":"processing"}""" + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt new file mode 100644 index 000000000..7b793bab0 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/OnboardingApiTest.kt @@ -0,0 +1,117 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.onboarding.OnboardingProgressRequest +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OnboardingApiTest { + + private class Captured { + var method: HttpMethod? = null + var path: String = "" + var query: Map = emptyMap() + var body: String = "" + } + + private fun api( + status: HttpStatusCode = HttpStatusCode.OK, + responseBody: String = "{}", + captured: Captured = Captured(), + ): Pair { + val client = HttpClient( + MockEngine { request -> + captured.method = request.method + captured.path = request.url.encodedPath + captured.query = request.url.parameters.names() + .associateWith { request.url.parameters[it] } + captured.body = request.body.toByteArray().decodeToString() + respond( + content = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return OnboardingApi(client) to captured + } + + @Test + fun getFlowPassesSurfaceAndParsesSteps() = runTest { + val (api, captured) = api( + responseBody = """ + {"version":1,"tour_id":"phone-welcome","steps":[ + {"id":"intro","kind":"copy","title":"Hi","body":"Welcome"}, + {"id":"audio","kind":"setting", + "setting":{"target":"setting","key":"playback.audio_language", + "control":"picker","options":[{"value":"en","label":"English"}]}} + ]} + """.trimIndent(), + ) + + val result = api.getFlow("phone") + + assertEquals(HttpMethod.Get, captured.method) + assertEquals("/api/v1/onboarding/flow", captured.path) + assertEquals("phone", captured.query["surface"]) + assertIs>(result) + val flow = (result as ApiResult.Success).data + assertEquals("phone-welcome", flow.tourId) + assertEquals(2, flow.steps.size) + assertEquals("playback.audio_language", flow.steps[1].setting?.key) + } + + @Test + fun getStateParsesProgressFlags() = runTest { + val (api, captured) = api( + responseBody = """ + {"tour_id":"tv-welcome","last_step":"intro","done":false} + """.trimIndent(), + ) + + val result = api.getState() + + assertEquals("/api/v1/onboarding/state", captured.path) + assertIs>(result) + val state = (result as ApiResult.Success).data + assertEquals("tv-welcome", state.tourId) + assertEquals("intro", state.lastStep) + assertEquals(false, state.done) + } + + @Test + fun postProgressSendsJsonBody() = runTest { + val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + + val result = api.postProgress( + OnboardingProgressRequest( + tourId = "phone-welcome", + lastStep = "audio", + completed = true, + ), + ) + + assertEquals(HttpMethod.Post, captured.method) + assertEquals("/api/v1/onboarding/progress", captured.path) + assertTrue(captured.body.contains("\"tour_id\":\"phone-welcome\"")) + assertTrue(captured.body.contains("\"last_step\":\"audio\"")) + assertTrue(captured.body.contains("\"completed\":true")) + assertIs>(result) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt index 8cbb29f5c..fdb7b5e5c 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt @@ -20,6 +20,7 @@ import org.prairieserver.prairie.model.playback.ClientCodecCapabilities import org.prairieserver.prairie.model.playback.ClientPlaybackContext import org.prairieserver.prairie.model.playback.PLAYBACK_START_CLIENT_FEATURES_V3 import org.prairieserver.prairie.model.playback.PlaybackFailureV3 +import org.prairieserver.prairie.model.playback.PlaybackOutputContext import org.prairieserver.prairie.model.playback.PlaybackReplanRequestV3 import org.prairieserver.prairie.model.playback.PlaybackRouteEventV3 import org.prairieserver.prairie.model.playback.PlaybackStartRequestV3 @@ -54,22 +55,26 @@ class PlaybackApiTest { return PlaybackApi(client) } - private fun context() = ClientPlaybackContext(formFactor = "tv", appVersion = "test") + private fun context(outputContextId: String? = null) = ClientPlaybackContext( + formFactor = "tv", + appVersion = "test", + output = PlaybackOutputContext(outputContextId = outputContextId), + ) @Test fun `v3 start uses canonical endpoint and negotiation fields`() = runTest { val captured = Captured() api(captured).startPlaybackV3( PlaybackStartRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, fileId = 42, profileId = "profile", playbackAttemptId = "attempt", subtitleFidelityPreference = SubtitleFidelityPreference.PRESERVE, audioTrackId = "file:42:audio:2", audioTrackIndex = 2, - outputRouteGeneration = 7, capabilities = ClientCodecCapabilities(), - clientPlaybackContext = context(), + clientPlaybackContext = context(outputContextId = "tv:hdmi:primary"), ), ) @@ -82,6 +87,12 @@ class PlaybackApiTest { PLAYBACK_START_CLIENT_FEATURES_V3, body["client_features"]!!.jsonArray.map { it.jsonPrimitive.content }, ) + assertEquals( + "tv:hdmi:primary", + body["client_playback_context"]!!.jsonObject["output"]!!.jsonObject[ + "output_context_id" + ]!!.jsonPrimitive.content, + ) } @Test @@ -90,6 +101,7 @@ class PlaybackApiTest { api(captured).replanPlaybackV3( "session-1", PlaybackReplanRequestV3( + clientFeatures = PLAYBACK_START_CLIENT_FEATURES_V3, playbackAttemptId = "attempt", replanRequestId = "request", failedPlanId = "plan-1", @@ -99,7 +111,6 @@ class PlaybackApiTest { attemptCount = 2, qualityPreference = "720p", positionSeconds = 12.5, - outputRouteGeneration = 8, metered = true, bandwidthEstimateKbps = 18_500, bandwidthCapKbps = 12_000, @@ -128,7 +139,7 @@ class PlaybackApiTest { playbackAttemptId = "attempt", sessionId = "session", event = "plan_failed", - outputRouteGeneration = 9, + outputContextId = "tv:hdmi:primary", ), ) @@ -137,5 +148,6 @@ class PlaybackApiTest { val body = PrairieJson.parseToJsonElement(captured.body).jsonObject assertEquals("attempt", body["playback_attempt_id"]!!.jsonPrimitive.content) assertEquals("plan_failed", body["event"]!!.jsonPrimitive.content) + assertEquals("tv:hdmi:primary", body["output_context_id"]!!.jsonPrimitive.content) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt new file mode 100644 index 000000000..13b7d8b62 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SectionApiCollectionItemsTest.kt @@ -0,0 +1,93 @@ +package org.prairieserver.prairie.network.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.catalog.CatalogQueryGroup +import org.prairieserver.prairie.model.catalog.CatalogQueryRule +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SectionApiCollectionItemsTest { + + /** + * The collection's own order is expressed by sending no sort at all, so a + * null sort must not leak an `order` param either. + */ + @Test + fun omitsSortAndOrderWhenNoSortRequested() = runTest { + val requests = mutableListOf>() + val api = SectionApi(clientFor(requests, """{"total":0,"has_more":false,"items":[]}""")) + + api.getLibraryCollectionItems("c1", offset = 0, limit = 60, order = "desc") + + val query = requests.single() + assertEquals("library_collection", query["source"]) + assertEquals("c1", query["collection_id"]) + assertFalse("sort" in query.keys) + assertFalse("order" in query.keys) + } + + @Test + fun sendsSortOrderAndFacetGroupsWhenRequested() = runTest { + val requests = mutableListOf>() + val api = SectionApi( + clientFor( + requests, + """{"total":3,"has_more":false,"items":[],"effective_sort":{"field":"title","order":"asc"}}""", + ), + ) + + val result = api.getLibraryCollectionItems( + collectionId = "c1", + sort = "title", + order = "asc", + queryGroups = listOf( + CatalogQueryGroup( + match = "any", + rules = listOf(CatalogQueryRule(field = "genre", op = "contains", value = "Drama")), + ), + ), + match = "all", + ) + + assertTrue(result is ApiResult.Success) + assertEquals("title", result.data.effectiveSort?.field) + assertEquals("asc", result.data.effectiveSort?.order) + + val query = requests.single() + assertEquals("title", query["sort"]) + assertEquals("asc", query["order"]) + assertEquals("all", query["match"]) + assertEquals("any", query["groups[0][match]"]) + assertEquals("genre", query["groups[0][rules][0][field]"]) + assertEquals("contains", query["groups[0][rules][0][op]"]) + assertEquals("Drama", query["groups[0][rules][0][value]"]) + } + + private fun clientFor( + requests: MutableList>, + body: String, + ): HttpClient = HttpClient( + MockEngine { request -> + requests += request.url.parameters.names().associateWith { request.url.parameters[it] } + respond( + content = body, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt new file mode 100644 index 000000000..a79594290 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/SettingsApiValuesTest.kt @@ -0,0 +1,337 @@ +package org.prairieserver.prairie.network.api + +import org.prairieserver.prairie.model.settings.EffectiveSettingValue +import org.prairieserver.prairie.model.settings.SettingScopeIdentity +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.PrairieJson +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Tests for the canonical settings API surface: `/settings/contract` and the `/settings/values` routes. */ +class SettingsApiValuesTest { + + private class Captured { + var method: HttpMethod? = null + var path: String = "" + var query: Map = emptyMap() + var headers: Headers = headersOf() + var body: String = "" + } + + private fun api( + status: HttpStatusCode = HttpStatusCode.OK, + responseBody: String = "{}", + responseContentType: String = "application/json", + captured: Captured = Captured(), + ): Pair { + val client = HttpClient( + MockEngine { request -> + captured.method = request.method + captured.path = request.url.encodedPath + captured.query = request.url.parameters.names() + .associateWith { request.url.parameters[it] } + captured.headers = request.headers + captured.body = request.body.toByteArray().decodeToString() + respond( + content = responseBody, + status = status, + headers = headersOf(HttpHeaders.ContentType, responseContentType), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return SettingsApi(client) to captured + } + + // ---- capabilities / server-upgrade-required ---- + + @Test + fun `getContractCapabilities parses the server capabilities shape`() = runTest { + val (api, captured) = api( + responseBody = """ + {"api_version":1,"revision":4,"contract_etag":"\"abc123\"", + "definition_count":41, + "scopes":["account","profile","profile_device","profile_library","profile_series"], + "supports_batched_effective":true,"supports_idempotent_writes":true} + """.trimIndent(), + ) + + val result = api.getContractCapabilities() + + assertEquals("/api/v1/settings/contract/capabilities", captured.path) + assertIs(result) + assertEquals(1, result.capabilities.apiVersion) + assertEquals(4, result.capabilities.revision) + assertEquals("\"abc123\"", result.capabilities.contractEtag) + assertEquals(41, result.capabilities.definitionCount) + assertEquals(5, result.capabilities.scopes.size) + assertTrue(result.capabilities.supportsBatchedEffective) + assertTrue(result.capabilities.supportsIdempotentWrites) + } + + @Test + fun `getContractCapabilities maps a routeless 404 to ServerUpgradeRequired`() = runTest { + // An old server has no /settings/contract routes: the router answers a + // plain-text 404, not the JSON error shape. + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = "404 page not found", + responseContentType = "text/plain", + ) + + assertIs(api.getContractCapabilities()) + } + + @Test + fun `getContractCapabilities keeps a profile-not-found 404 off the upgrade path`() = runTest { + // The route sits behind the viewer-access middleware, which answers + // this exact body when the X-Profile-Id we send names a profile the + // household deleted from another device. The server is current; the + // fix is picking a profile, so the upgrade notice must not appear. + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = """{"error":"not_found","message":"Profile not found"}""", + ) + + val result = api.getContractCapabilities() + + assertIs(result) + assertEquals(404, result.code) + assertEquals("not_found", result.error) + } + + @Test + fun `getContractCapabilities keeps other failures on the generic error path`() = runTest { + val (api, _) = api( + status = HttpStatusCode.InternalServerError, + responseBody = """{"error":"internal_error","message":"Failed to read the settings contract"}""", + ) + + val result = api.getContractCapabilities() + + assertIs(result) + assertEquals(500, result.code) + assertEquals("internal_error", result.error) + } + + // ---- batched effective resolution ---- + + @Test + fun `getEffectiveValues sends csv params and parses constrained values`() = runTest { + val (api, captured) = api( + responseBody = """ + {"settings":[ + {"key":"playback.auto_play_next","value":true,"source":"default", + "suggested_values":["en","pt-BR"]}, + {"key":"playback.preferred_quality","value":"1080p","source":"profile_device", + "stored_value":"2160p","constrained":true,"constraint_kind":"ceiling", + "scope":"profile_device","profile_id":"p1","device_id":"d1"} + ], + "revision":4} + """.trimIndent(), + ) + + val result = api.getEffectiveValues( + keys = listOf("playback.auto_play_next", "playback.preferred_quality"), + libraryIds = listOf(3, 7), + seriesIds = listOf("s1", "s2"), + ) + + assertEquals("/api/v1/settings/values/effective", captured.path) + assertEquals("playback.auto_play_next,playback.preferred_quality", captured.query["keys"]) + assertEquals("3,7", captured.query["library_ids"]) + assertEquals("s1,s2", captured.query["series_ids"]) + + assertIs>(result) + val response = (result as ApiResult.Success).data + assertEquals(4, response.revision) + + val default = response.settings[0] + assertEquals(EffectiveSettingValue.SOURCE_DEFAULT, default.source) + assertEquals(true, default.value.jsonPrimitive.content.toBoolean()) + assertFalse(default.constrained) + assertNull(default.scope) + assertEquals(listOf("en", "pt-BR"), default.suggestedValues) + + val capped = response.settings[1] + assertEquals("1080p", capped.value.jsonPrimitive.content) + assertEquals("2160p", capped.storedValue?.jsonPrimitive?.content) + assertTrue(capped.constrained) + assertEquals("ceiling", capped.constraintKind) + assertEquals("profile_device", capped.scope) + assertEquals("d1", capped.deviceId) + } + + @Test + fun `getEffectiveValues omits empty params so the server resolves every key`() = runTest { + val (api, captured) = api(responseBody = """{"settings":[],"revision":1}""") + + api.getEffectiveValues() + + assertTrue(captured.query.isEmpty()) + } + + // ---- writes ---- + + @Test + fun `putValue sends the scope identity mutation id and typed body`() = runTest { + val (api, captured) = api( + responseBody = """ + {"key":"playback.preferred_quality","scope":"profile_library", + "profile_id":"p1","library_id":7,"value":"1080p", + "revision":12,"updated_at":"2026-07-28T00:00:00Z"} + """.trimIndent(), + ) + + val result = api.putValue( + key = "playback.preferred_quality", + scope = SettingScopeIdentity.profileLibrary(7), + value = JsonPrimitive("1080p"), + mutationId = "mut-1", + ) + + assertEquals(HttpMethod.Put, captured.method) + assertEquals("/api/v1/settings/values/playback.preferred_quality", captured.path) + assertEquals("profile_library", captured.query["scope"]) + assertEquals("7", captured.query["library_id"]) + assertEquals("mut-1", captured.headers["X-Prairie-Mutation-Id"]) + assertEquals("""{"value":"1080p"}""", captured.body) + + assertIs>(result) + val receipt = (result as ApiResult.Success).data + assertEquals("profile_library", receipt.scope) + assertEquals(7, receipt.libraryId) + assertEquals(12L, receipt.revision) + assertEquals("2026-07-28T00:00:00Z", receipt.updatedAt) + } + + @Test + fun `putValue round-trips an object value`() = runTest { + val (api, captured) = api( + responseBody = """ + {"key":"playback.subtitle_appearance","scope":"profile", + "profile_id":"p1","value":{"size":"large","edge":"drop_shadow"}} + """.trimIndent(), + ) + + val result = api.putValue( + key = "playback.subtitle_appearance", + scope = SettingScopeIdentity.profile(), + value = buildJsonObject { + put("size", "large") + put("edge", "drop_shadow") + }, + mutationId = "mut-2", + ) + + assertEquals("""{"value":{"size":"large","edge":"drop_shadow"}}""", captured.body) + assertIs>(result) + val receipt = (result as ApiResult.Success).data + assertEquals("large", receipt.value.jsonObject["size"]?.jsonPrimitive?.content) + // A replayed receipt omits revision/updated_at; defaults must hold. + assertEquals(0L, receipt.revision) + assertNull(receipt.updatedAt) + } + + @Test + fun `putValue surfaces a mutation id conflict as a typed error`() = runTest { + val (api, _) = api( + status = HttpStatusCode.Conflict, + responseBody = """{"error":"mutation_id_conflict","message":"This mutation id was used for a different write"}""", + ) + + val result = api.putValue( + key = "playback.preferred_quality", + scope = SettingScopeIdentity.profile(), + value = JsonPrimitive("720p"), + mutationId = "mut-reused", + ) + + assertIs(result) + assertEquals(409, result.code) + assertEquals("mutation_id_conflict", result.error) + } + + @Test + fun `putValue lets an explicit profile id override the session header`() = runTest { + val (api, captured) = api(responseBody = """{"key":"k","scope":"profile","value":true}""") + + api.putValue( + key = "playback.auto_play_next", + scope = SettingScopeIdentity.profile(), + value = JsonPrimitive(true), + mutationId = "mut-3", + profileId = "child-profile", + ) + + assertEquals("child-profile", captured.headers["X-Profile-Id"]) + } + + // ---- deletes ---- + + @Test + fun `deleteValue sends the scope identity and maps 204 to success`() = runTest { + val (api, captured) = api(status = HttpStatusCode.NoContent, responseBody = "") + + val result = api.deleteValue( + key = "playback.subtitle_language", + scope = SettingScopeIdentity.profileSeries("series-9"), + ) + + assertEquals(HttpMethod.Delete, captured.method) + assertEquals("/api/v1/settings/values/playback.subtitle_language", captured.path) + assertEquals("profile_series", captured.query["scope"]) + assertEquals("series-9", captured.query["series_id"]) + assertIs>(result) + } + + @Test + fun `deleteValue reports nothing-set-here as a typed 404`() = runTest { + val (api, _) = api( + status = HttpStatusCode.NotFound, + responseBody = """{"error":"not_found","message":"No value is set at this scope"}""", + ) + + val result = api.deleteValue( + key = "playback.subtitle_language", + scope = SettingScopeIdentity.account(), + ) + + assertIs(result) + assertEquals(404, result.code) + assertEquals("not_found", result.error) + } + + // ---- mutation ids ---- + + @Test + fun `newSettingMutationId yields distinct non-blank ids`() { + val first = newSettingMutationId() + val second = newSettingMutationId() + assertTrue(first.isNotBlank()) + assertTrue(second.isNotBlank()) + assertTrue(first != second) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/overlays/OverlayRegistryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/overlays/OverlayRegistryTest.kt index 27ea053e3..90832d5e9 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/overlays/OverlayRegistryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/overlays/OverlayRegistryTest.kt @@ -41,6 +41,7 @@ class OverlayRegistryTest { @Test fun enabled_respectsUserOrder() { val base = OverlaySchema.buildDefaults() + // Defaults enable resolution, hdr, audio at top-left. val ordered = base.copy(order = listOf(OverlayId.Audio, OverlayId.Hdr, OverlayId.Resolution)) val ids = OverlayRegistry.enabled(OverlayPosition.TopLeft, ordered).map { it.id } assertEquals(listOf(OverlayId.Audio, OverlayId.Hdr, OverlayId.Resolution), ids) @@ -50,6 +51,7 @@ class OverlayRegistryTest { fun enabled_fallsBackToRegistryOrder_whenOrderEmpty() { val prefs = OverlaySchema.buildDefaults() val ids = OverlayRegistry.enabled(OverlayPosition.TopLeft, prefs).map { it.id } + // Registry order for the top-left defaults: resolution, hdr, audio. assertEquals(listOf(OverlayId.Resolution, OverlayId.Hdr, OverlayId.Audio), ids) } @@ -58,8 +60,6 @@ class OverlayRegistryTest { val def = OverlayRegistry.def(OverlayId.ResolutionHdr)!! assertEquals("4K DV", def.getValue(OverlayData(resolution = "2160p", hdr = "DV"))) assertEquals("4K HDR", def.getValue(OverlayData(resolution = "4k", hdr = "HDR10"))) - assertEquals("8K", def.getValue(OverlayData(resolution = "4320p", hdr = null))) - assertEquals("720p", def.getValue(OverlayData(resolution = "720p", hdr = null))) assertEquals("1080p", def.getValue(OverlayData(resolution = "1080p", hdr = null))) assertNull(def.getValue(OverlayData(resolution = null))) } @@ -81,49 +81,10 @@ class OverlayRegistryTest { } @Test - fun techRatingAndMetadataGetValuesCoverHelpers() { - assertEquals("HDR10", OverlayRegistry.def(OverlayId.Hdr)!!.getValue(OverlayData(hdr = "HDR10"))) - assertEquals(OverlayIconId.DolbyVision, OverlayRegistry.def(OverlayId.Hdr)!!.getIcon!!(OverlayData(hdr = "DV"))) - assertEquals(OverlayIconId.Hdr10, OverlayRegistry.def(OverlayId.Hdr)!!.getIcon!!(OverlayData(hdr = "HDR10"))) - assertEquals(OverlayIconId.Hdr, OverlayRegistry.def(OverlayId.Hdr)!!.getIcon!!(OverlayData(hdr = "HLG"))) - - assertEquals("TrueHD Atmos", OverlayRegistry.def(OverlayId.Audio)!!.getValue(OverlayData(audio = "TrueHD Atmos"))) - assertEquals(OverlayIconId.Atmos, OverlayRegistry.def(OverlayId.Audio)!!.getIcon!!(OverlayData(audio = "Atmos"))) - assertEquals(OverlayIconId.Volume, OverlayRegistry.def(OverlayId.Audio)!!.getIcon!!(OverlayData(audio = "DTS"))) - - assertEquals("AV1", OverlayRegistry.def(OverlayId.VideoCodec)!!.getValue(OverlayData(videoCodec = "AV1"))) - assertEquals(OverlayIconId.Av1, OverlayRegistry.def(OverlayId.VideoCodec)!!.getIcon!!(OverlayData(videoCodec = "AV1"))) - assertEquals(OverlayIconId.Film, OverlayRegistry.def(OverlayId.VideoCodec)!!.getIcon!!(OverlayData(videoCodec = "HEVC"))) - - assertEquals("Multi-Audio", OverlayRegistry.def(OverlayId.MultiAudio)!!.getValue(OverlayData(multiAudio = true))) - assertNull(OverlayRegistry.def(OverlayId.MultiAudio)!!.getValue(OverlayData(multiAudio = false))) - assertEquals("CC", OverlayRegistry.def(OverlayId.MultiSub)!!.getValue(OverlayData(multiSub = true))) - - assertEquals("92%", OverlayRegistry.def(OverlayId.RatingRt)!!.getValue(OverlayData(ratingRtCritic = 92))) - assertEquals("88%", OverlayRegistry.def(OverlayId.RatingRtAudience)!!.getValue(OverlayData(ratingRtAudience = 88))) - assertEquals("7.5", OverlayRegistry.def(OverlayId.RatingTmdb)!!.getValue(OverlayData(ratingTmdb = 7.5))) - assertEquals("PG-13", OverlayRegistry.def(OverlayId.ContentRating)!!.getValue(OverlayData(contentRating = "PG-13"))) - assertEquals("2020", OverlayRegistry.def(OverlayId.Year)!!.getValue(OverlayData(year = 2020))) - assertEquals("A24", OverlayRegistry.def(OverlayId.Studio)!!.getValue(OverlayData(studio = "A24"))) - assertEquals("HBO", OverlayRegistry.def(OverlayId.Network)!!.getValue(OverlayData(network = "HBO"))) - assertEquals("EN", OverlayRegistry.def(OverlayId.OriginalLanguage)!!.getValue(OverlayData(originalLanguage = "en"))) - // Non-standard resolution falls through to uppercase (prettyResolution else). - assertEquals("FOO", OverlayRegistry.def(OverlayId.ResolutionHdr)!!.getValue(OverlayData(resolution = "foo"))) - assertEquals("5.1", OverlayRegistry.def(OverlayId.AudioChannels)!!.getValue(OverlayData(audioChannels = "5.1"))) - assertEquals("mkv", OverlayRegistry.def(OverlayId.Container)!!.getValue(OverlayData(container = "mkv"))) - assertEquals("2.39:1", OverlayRegistry.def(OverlayId.AspectRatio)!!.getValue(OverlayData(aspectRatio = "2.39:1"))) - assertEquals("BluRay", OverlayRegistry.def(OverlayId.ReleaseType)!!.getValue(OverlayData(releaseType = "BluRay"))) - assertEquals("Extended", OverlayRegistry.def(OverlayId.Edition)!!.getValue(OverlayData(edition = "Extended"))) - assertEquals("Ended", OverlayRegistry.def(OverlayId.ShowStatus)!!.getValue(OverlayData(showStatus = "Ended"))) - assertEquals("Returning", OverlayRegistry.def(OverlayId.ShowStatus)!!.getValue(OverlayData(showStatus = "returning series"))) - assertEquals("Cancelled", OverlayRegistry.def(OverlayId.ShowStatus)!!.getValue(OverlayData(showStatus = "canceled"))) - assertEquals("Pilot", OverlayRegistry.def(OverlayId.ShowStatus)!!.getValue(OverlayData(showStatus = "Pilot"))) - assertNull(OverlayRegistry.def(OverlayId.ImdbTop250)!!.getValue(OverlayData())) - assertEquals("#12", OverlayRegistry.def(OverlayId.ImdbTop250)!!.getValue(OverlayData(imdbTop250 = 12))) - assertNull(OverlayRegistry.def(OverlayId.RtCertifiedFresh)!!.getValue(OverlayData())) - assertEquals( - "Certified Fresh", - OverlayRegistry.def(OverlayId.RtCertifiedFresh)!!.getValue(OverlayData(rtCertifiedFresh = true)), - ) + fun showStatus_getValue_trimsBeforeMappingAndFallback() { + val def = OverlayRegistry.def(OverlayId.ShowStatus)!! + assertEquals("Returning", def.getValue(OverlayData(showStatus = " returning series "))) + assertEquals("Limited Series", def.getValue(OverlayData(showStatus = " Limited Series "))) + assertNull(def.getValue(OverlayData(showStatus = " "))) } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt index 6f7bc9d35..935302136 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackMarkersUpdateTest.kt @@ -16,6 +16,18 @@ class PlaybackMarkersUpdateTest { ) assertEquals(TimeRange(0.0, 30.0), m.intro) assertEquals(TimeRange(1200.0, 1260.0), m.credits) + assertNull(m.recap) + assertNull(m.preview) + } + + @Test fun decodesRecapAndPreview() { + val m = decodeMarkersUpdate( + payload("""{"file_id":7,"recap":{"start":0.0,"end":45.0},"preview":{"start":1500.0,"end":1530.0}}"""), + ) + assertNull(m.intro) + assertNull(m.credits) + assertEquals(TimeRange(0.0, 45.0), m.recap) + assertEquals(TimeRange(1500.0, 1530.0), m.preview) } @Test fun nullMarkerClears() { diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentityTest.kt new file mode 100644 index 000000000..d1b00fc1e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleIdentityTest.kt @@ -0,0 +1,99 @@ +package org.prairieserver.prairie.playback + +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import org.prairieserver.prairie.model.playback.SubtitleIdentity +import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PlaybackSubtitleIdentityTest { + @Test + fun serverIdentityWinsOverLocalDownloadMetadata() { + val identity = playbackSubtitleIdentity( + downloadedRow( + downloadId = 91, + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ), + ) + + val sidecar = assertIs(identity) + assertEquals("file:22:subtitle:4", sidecar.media?.trackId) + } + + @Test + fun legacyDownloadedIdentityResolvesByUniquePositiveMetadata() { + val identity = SubtitleIdentity.Downloaded( + downloadId = 91, + media = SubtitleMediaIdentity( + trackId = "silo-downloaded-subtitle:91", + label = "Downloaded English", + language = "eng", + codecFamily = "vtt", + forced = false, + hearingImpaired = false, + ), + ) + val rows = listOf( + downloadedRow( + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ), + ) + + assertEquals(0, resolveDownloadedSubtitlePreferenceOrdinal(identity, rows)) + } + + @Test + fun legacySyntheticIdAndNegativeBooleansAreNotEnoughToSelect() { + val identity = SubtitleIdentity.Downloaded( + downloadId = 91, + media = SubtitleMediaIdentity( + trackId = "silo-downloaded-subtitle:91", + forced = false, + hearingImpaired = false, + ), + ) + val rows = listOf( + downloadedRow( + serverTrackId = "file:22:subtitle:4", + serverDelivery = "sidecar", + ), + ) + + assertNull(resolveDownloadedSubtitlePreferenceOrdinal(identity, rows)) + } + + @Test + fun sharedSubtitleMetadataHelpersStayCanonical() { + assertEquals("silo-downloaded-subtitle:91", downloadedSubtitleArtifactTrackId(91)) + assertTrue(subtitleLabelIndicatesHearingImpaired("English SDH")) + assertTrue(subtitleLabelIndicatesHearingImpaired("English CC")) + assertFalse(subtitleLabelIndicatesHearingImpaired("hi")) + assertFalse(subtitleLabelIndicatesHearingImpaired("EN - HI")) + assertFalse(subtitleLabelIndicatesHearingImpaired("English")) + assertTrue(isBitmapSubtitleCodecFamily("application/pgs")) + assertFalse(isBitmapSubtitleCodecFamily("text/vtt")) + } + + private fun downloadedRow( + downloadId: Int? = null, + serverTrackId: String? = null, + serverDelivery: String? = null, + ): PlayerSubtitleInfo = PlayerSubtitleInfo( + index = 4, + language = "en", + codec = "webvtt", + label = "Downloaded English", + source = "downloaded", + forced = false, + url = "/stream/s1/subtitles/4.vtt", + downloadId = downloadId, + serverTrackId = serverTrackId, + serverDelivery = serverDelivery, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReadyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReadyTest.kt new file mode 100644 index 000000000..9e11d008f --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/PlaybackSubtitleReadyTest.kt @@ -0,0 +1,114 @@ +package org.prairieserver.prairie.playback + +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlaybackSubtitleReadyTest { + @Test + fun exactServerTrackReplacesNoIdentityOrOrdinal() { + val existing = (0..2).map { index -> + PlayerSubtitleInfo( + index = index, + source = if (index == 1) "embedded" else "external", + url = if (index == 1) "" else "/stream/s/subtitles/$index.vtt", + serverTrackId = "file:9:subtitle:$index", + serverDelivery = if (index == 1) "burn_in_only" else "sidecar", + ) + } + val update = decodePlaybackSubtitleReady( + buildJsonObject { + put("session_id", "s") + put("file_id", 9) + put("subtitle_id", 77) + putJsonObject("track") { + put("track_id", "file:9:subtitle:3") + put("combined_index", 3) + put("source", "downloaded") + put("codec", "ass") + put("language", "es") + put("label", "Spanish") + put("delivery", "sidecar") + put("url", "/stream/s/subtitles/3.ass") + } + }, + ) + + val rows = requireNotNull(applyAuthoritativeSubtitleReadyTrack(existing, update)) + val added = rows.last() + assertEquals(listOf(0, 1, 2, 3), rows.map(PlayerSubtitleInfo::index)) + assertEquals("file:9:subtitle:3", added.serverTrackId) + assertEquals("sidecar", added.serverDelivery) + assertEquals("/stream/s/subtitles/3.ass", added.url) + assertEquals(77, added.downloadId) + } + + @Test + fun aServerGapRequiresAPlanRefreshInsteadOfOrdinalSynthesis() { + val update = decodePlaybackSubtitleReady( + buildJsonObject { + put("subtitle_id", 77) + putJsonObject("track") { + put("track_id", "file:9:subtitle:3") + put("combined_index", 3) + put("source", "downloaded") + put("delivery", "sidecar") + put("url", "/stream/s/subtitles/3.vtt") + } + }, + ) + + assertNull(applyAuthoritativeSubtitleReadyTrack(emptyList(), update)) + } + + @Test + fun malformedSessionIdDoesNotDiscardValidControlFields() { + val update = decodePlaybackSubtitleReady( + buildJsonObject { + putJsonObject("session_id") { put("unexpected", true) } + put("file_id", 9) + put("subtitle_id", 77) + }, + ) + + assertNull(update.sessionId) + assertEquals(9, update.mediaFileId) + assertEquals(77, update.subtitleId) + } + + @Test + fun malformedFileIdDoesNotDiscardValidControlFields() { + val update = decodePlaybackSubtitleReady( + buildJsonObject { + put("session_id", "s") + putJsonArray("file_id") { add(JsonPrimitive(9)) } + put("subtitle_id", 77) + }, + ) + + assertEquals("s", update.sessionId) + assertNull(update.mediaFileId) + assertEquals(77, update.subtitleId) + } + + @Test + fun malformedSubtitleIdDoesNotDiscardValidControlFields() { + val update = decodePlaybackSubtitleReady( + buildJsonObject { + put("session_id", "s") + put("file_id", 9) + putJsonObject("subtitle_id") { put("unexpected", 77) } + }, + ) + + assertEquals("s", update.sessionId) + assertEquals(9, update.mediaFileId) + assertNull(update.subtitleId) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt index f5c8d517f..f99869b6b 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/TrackSelectionFingerprintTest.kt @@ -361,6 +361,25 @@ class TrackSelectionFingerprintTest { assertEquals("fr", identity.media?.language) } + @Test + fun catalogPreferenceDoesNotTreatHindiCodeAsHearingImpaired() { + val catalog = listOf( + SubtitleTrack( + index = 7, + codec = "srt", + language = "hin", + title = "EN - HI", + external = true, + ), + ) + + val identity = assertIs( + decodeSubtitleIdentityPreference(encodeCatalogSubtitlePreference(catalog, 0)), + ) + + assertNull(identity.media?.hearingImpaired) + } + @Test fun playerCanonicalLanguagePreferenceSafelyMissesDifferentCatalogLanguage() { val preference = encodedPlayerServerPreference("Dialogue", "en", "subrip") diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AdminRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AdminRepositoryTest.kt deleted file mode 100644 index 25be90058..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AdminRepositoryTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -// shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AdminRepositoryTest.kt -package org.prairieserver.prairie.repository - -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.api.AdminApi -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -class AdminRepositoryTest { - - /** Records calls and returns canned successes. */ - private class FakeAdminApi : AdminApi { - val calls = mutableListOf() - - var statsRefresh: Boolean? = null - var lastSessionControl: Triple? = null - var lastAppLogLimit: Int? = null - - override suspend fun getStats(refresh: Boolean): ApiResult { - calls += "getStats" - statsRefresh = refresh - return ApiResult.Success(AdminStats(totalUsers = 9)) - } - - override suspend fun getUsers(): ApiResult> { - calls += "getUsers" - return ApiResult.Success(emptyList()) - } - - override suspend fun getUser(id: Int): ApiResult { - calls += "getUser:$id" - return ApiResult.Success( - AdminUser(id = id, username = "u", email = "u@x.io", role = "user"), - ) - } - - override suspend fun createUser(request: CreateUserRequest): ApiResult { - calls += "createUser:${request.username}" - return ApiResult.Success( - AdminUser(id = 1, username = request.username, email = request.email, role = request.role), - ) - } - - override suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult { - calls += "updateUser:$id" - return ApiResult.Success( - AdminUser(id = id, username = "u", email = "u@x.io", role = "user"), - ) - } - - override suspend fun deleteUser(id: Int): ApiResult { - calls += "deleteUser:$id" - return ApiResult.Success(Unit) - } - - override suspend fun getSessions(): ApiResult> { - calls += "getSessions" - return ApiResult.Success(emptyList()) - } - - override suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest, - ): ApiResult { - calls += "sessionControl:$sessionId:${action.wire}" - lastSessionControl = Triple(sessionId, action, request) - return ApiResult.Success(SessionControlResponse(commandId = "c", status = "dispatched")) - } - - override suspend fun getAppLogs( - level: String?, component: String?, nodeId: String?, requestId: String?, - sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, query: String?, cursor: String?, limit: Int, - ): ApiResult { - calls += "getAppLogs" - lastAppLogLimit = limit - return ApiResult.Success(AdminLogPage()) - } - - override suspend fun getAuditLogs( - method: String?, pathPrefix: String?, statusCode: Int?, clientIp: String?, - requestId: String?, sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, cursor: String?, limit: Int, - ): ApiResult { - calls += "getAuditLogs" - return ApiResult.Success(AdminAuditPage()) - } - - override suspend fun triggerScan(request: ScanRequest): ApiResult { - calls += "triggerScan:${request.libraryId}" - return ApiResult.Success( - ScanResponse(status = "scanning", mode = "incremental", libraryId = request.libraryId ?: -1), - ) - } - - override suspend fun cancelScan(request: ScanCancelRequest): ApiResult { - calls += "cancelScan:${request.libraryId}" - return ApiResult.Success(ScanCancelResponse(cancelled = 1, libraryId = request.libraryId)) - } - } - - @Test - fun `getStats passes refresh through and returns api result`() = runTest { - val api = FakeAdminApi() - val repo = AdminRepository(api) - - val result = repo.getStats(refresh = true) - - assertEquals(listOf("getStats"), api.calls) - assertEquals(true, api.statsRefresh) - assertIs>(result) - assertEquals(9, (result as ApiResult.Success).data.totalUsers) - } - - @Test - fun `user CRUD pass-throughs delegate to api`() = runTest { - val api = FakeAdminApi() - val repo = AdminRepository(api) - - repo.getUsers() - repo.getUser(7) - repo.createUser(CreateUserRequest("bob", "b@x.io", "pw", "user")) - repo.updateUser(7, UpdateUserRequest(enabled = false)) - repo.deleteUser(7) - - assertEquals( - listOf("getUsers", "getUser:7", "createUser:bob", "updateUser:7", "deleteUser:7"), - api.calls, - ) - } - - @Test - fun `sessions and control pass-throughs delegate to api`() = runTest { - val api = FakeAdminApi() - val repo = AdminRepository(api) - - repo.getSessions() - val result = repo.sessionControl( - "sess-1", SessionControlAction.Stop, SessionControlRequest(reason = "policy"), - ) - - assertEquals(listOf("getSessions", "sessionControl:sess-1:stop"), api.calls) - assertEquals("sess-1", api.lastSessionControl?.first) - assertEquals(SessionControlAction.Stop, api.lastSessionControl?.second) - assertIs>(result) - } - - @Test - fun `log pass-throughs forward limit`() = runTest { - val api = FakeAdminApi() - val repo = AdminRepository(api) - - repo.getAppLogs(level = "error", limit = 25) - repo.getAuditLogs(method = "POST") - - assertEquals(listOf("getAppLogs", "getAuditLogs"), api.calls) - assertEquals(25, api.lastAppLogLimit) - } - - @Test - fun `scan pass-throughs delegate to api`() = runTest { - val api = FakeAdminApi() - val repo = AdminRepository(api) - - val scan = repo.triggerScan(ScanRequest(libraryId = 4)) - val cancel = repo.cancelScan(ScanCancelRequest(libraryId = 4)) - - assertEquals(listOf("triggerScan:4", "cancelScan:4"), api.calls) - assertIs>(scan) - assertEquals(4, (scan as ApiResult.Success).data.libraryId) - assertIs>(cancel) - assertEquals(1, (cancel as ApiResult.Success).data.cancelled) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryAccountReplacementTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryAccountReplacementTest.kt new file mode 100644 index 000000000..38a123037 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryAccountReplacementTest.kt @@ -0,0 +1,152 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ProfileIdentity +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.AuthApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class AuthRepositoryAccountReplacementTest { + @Test + fun invitationAcceptanceInstallsTheSessionThroughOneExplicitAccountReplacement() = runTest { + val tokenManager = RecordingAccountReplacementTokenManager() + val registry = RecordingInvitationRegistry() + val client = HttpClient( + MockEngine { request -> + assertEquals( + "/api/v1/invitations/invite-token/accept", + request.url.encodedPath, + ) + respond( + content = + """{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600,"user":{"id":7,"username":"new-user","email":"new@example.com","role":"user"}}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val repository = AuthRepository( + authApi = AuthApi(client), + tokenManager = tokenManager, + serverRegistry = registry, + ) + + val result = repository.acceptInvitation( + serverUrl = "https://invited.example", + token = "invite-token", + password = "password", + ) + + assertEquals("new-user", assertIs>(result).data.let { + (it as org.prairieserver.prairie.model.auth.User).username + }) + assertEquals( + AccountReplacement( + serverId = "invited-server", + accessToken = "new-access", + refreshToken = "new-refresh", + expiresIn = 3600, + profileId = null, + profileToken = null, + ), + tokenManager.replacement, + ) + assertEquals("https://invited.example", registry.addedUrl) + assertEquals(0, registry.switchCalls) + } +} + +private data class AccountReplacement( + val serverId: String?, + val accessToken: String, + val refreshToken: String, + val expiresIn: Long, + val profileId: String?, + val profileToken: String?, +) + +private class RecordingAccountReplacementTokenManager : TokenManager { + override val sessionExpired = MutableSharedFlow() + var replacement: AccountReplacement? = null + + override suspend fun replaceAccountSession( + serverId: String?, + serverUrl: String?, + accessToken: String, + refreshToken: String, + expiresIn: Long, + profileId: String?, + profileToken: String?, + ) { + check(serverUrl == null) { "a registry-backed invitation must install by server id" } + check(replacement == null) { "the session must be installed exactly once" } + replacement = AccountReplacement( + serverId, + accessToken, + refreshToken, + expiresIn, + profileId, + profileToken, + ) + } + + override suspend fun getAccessToken(): String? = null + override suspend fun getRefreshToken(): String? = null + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long): Unit = + error("explicit invitation credentials must not use refresh-token persistence") + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getProfileIdentity() = ProfileIdentity(null, null) + override suspend fun getServerUrl(): String = "https://old.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String? = "old-server" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit +} + +private class RecordingInvitationRegistry : ServerRegistry { + private val oldEntry = ServerEntry(id = "old-server", url = "https://old.example") + override val entries: StateFlow> = MutableStateFlow(listOf(oldEntry)) + override val activeServerId: StateFlow = MutableStateFlow(oldEntry.id) + override val activeEntry: StateFlow = MutableStateFlow(oldEntry) + var addedUrl: String? = null + var switchCalls = 0 + + override suspend fun addOrUpdate(url: String, fetchedName: String?): String { + addedUrl = url + return "invited-server" + } + + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) = Unit + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) { + switchCalls += 1 + } + override suspend fun touchActive() = Unit +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryServerNameTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryServerNameTest.kt new file mode 100644 index 000000000..4c2c79609 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/AuthRepositoryServerNameTest.kt @@ -0,0 +1,167 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.server.ServerEntry +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.ServerRegistry +import org.prairieserver.prairie.network.TokenManager +import org.prairieserver.prairie.network.api.AuthApi +import org.prairieserver.prairie.network.api.BrandingApi +import org.prairieserver.prairie.network.api.BrandingStatus +import org.prairieserver.prairie.network.api.HealthApi +import org.prairieserver.prairie.network.api.HealthStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AuthRepositoryServerNameTest { + @Test + fun `refresh prefers native branding over compat-backed health name`() = runTest { + val registry = RecordingServerRegistry() + val health = FakeHealthApi(ApiResult.Success(HealthStatus("ok", "StreamApp"))) + val repository = repository( + registry = registry, + branding = FakeBrandingApi(ApiResult.Success(BrandingStatus(" Home Silo "))), + health = health, + ) + + repository.refreshActiveServerName() + + assertEquals("Home Silo", registry.fetchedName) + assertEquals(0, health.calls) + } + + @Test + fun `refresh falls back to health when branding is unavailable`() = runTest { + val registry = RecordingServerRegistry() + val repository = repository( + registry = registry, + branding = FakeBrandingApi(ApiResult.Error(404, "not_found", "missing")), + health = FakeHealthApi(ApiResult.Success(HealthStatus("ok", "Legacy Home"))), + ) + + repository.refreshActiveServerName() + + assertEquals("Legacy Home", registry.fetchedName) + } + + @Test + fun `refresh falls back to health when branding name is blank`() = runTest { + val registry = RecordingServerRegistry() + val repository = repository( + registry = registry, + branding = FakeBrandingApi(ApiResult.Success(BrandingStatus(" "))), + health = FakeHealthApi(ApiResult.Success(HealthStatus("ok", "Fallback"))), + ) + + repository.refreshActiveServerName() + + assertEquals("Fallback", registry.fetchedName) + } + + @Test + fun `refresh ignores response after active server changes`() = runTest { + val registry = RecordingServerRegistry() + val repository = repository( + registry = registry, + branding = FakeBrandingApi(ApiResult.Success(BrandingStatus("Wrong Server"))) { + registry.activate("other") + }, + health = FakeHealthApi(ApiResult.Success(HealthStatus("ok", "Fallback"))), + ) + + repository.refreshActiveServerName() + + assertNull(registry.fetchedName) + } + + private fun repository( + registry: RecordingServerRegistry, + branding: BrandingApi, + health: HealthApi, + ) = AuthRepository( + authApi = AuthApi(unusedClient()), + tokenManager = FakeTokenManager, + serverRegistry = registry, + healthApi = health, + brandingApi = branding, + ) +} + +private class FakeBrandingApi( + private val result: ApiResult, + private val beforeReturn: suspend () -> Unit = {}, +) : BrandingApi(unusedClient()) { + override suspend fun getBranding(): ApiResult { + beforeReturn() + return result + } +} + +private class FakeHealthApi( + private val result: ApiResult, +) : HealthApi(unusedClient()) { + var calls = 0 + private set + + override suspend fun checkHealth(): ApiResult { + calls += 1 + return result + } +} + +private class RecordingServerRegistry : ServerRegistry { + private val activeId = MutableStateFlow("active") + private val savedEntries = MutableStateFlow( + listOf(ServerEntry(id = "active", url = "https://silo.example")), + ) + + var fetchedName: String? = null + private set + + override val entries: StateFlow> = savedEntries + override val activeServerId: StateFlow = activeId + override val activeEntry: StateFlow = MutableStateFlow(savedEntries.value.single()) + + fun activate(serverId: String) { + activeId.value = serverId + } + + override suspend fun addOrUpdate(url: String, fetchedName: String?): String = "active" + override suspend fun rename(serverId: String, userOverrideName: String?) = Unit + override suspend fun setFetchedName(serverId: String, fetchedName: String?) { + this.fetchedName = fetchedName + } + override suspend fun setProfileId(serverId: String, profileId: String?) = Unit + override suspend fun remove(serverId: String) = Unit + override suspend fun signOut(serverId: String) = Unit + override suspend fun switchTo(serverId: String) { + activeId.value = serverId + } + override suspend fun touchActive() = Unit +} + +private object FakeTokenManager : TokenManager { + override val sessionExpired = MutableSharedFlow() + override suspend fun getAccessToken(): String? = null + override suspend fun getRefreshToken(): String? = null + override suspend fun saveTokens(accessToken: String, refreshToken: String, expiresIn: Long) = Unit + override suspend fun clearTokens() = Unit + override suspend fun invalidateSession() = Unit + override suspend fun getProfileId(): String? = null + override suspend fun setProfileId(profileId: String?) = Unit + override suspend fun getProfileToken(): String? = null + override suspend fun setProfileToken(token: String?) = Unit + override suspend fun getServerUrl(): String = "https://silo.example" + override suspend fun setServerUrl(url: String) = Unit + override suspend fun getCurrentServerId(): String? = "active" + override suspend fun switchActiveServer(serverId: String?) = Unit + override suspend fun signOutCurrentServer() = Unit +} + +private fun unusedClient(): HttpClient = HttpClient(MockEngine { error("Unexpected request") }) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt index b15d06808..09a5e484f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/CatalogRepositoryDetailCacheTest.kt @@ -3,9 +3,13 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.catalog.ItemDetail import org.prairieserver.prairie.model.catalog.SeasonsResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.IdentityTransitionBarrier import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.CatalogApi import org.prairieserver.prairie.repository.port.CatalogCachePort +import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -15,6 +19,8 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -25,9 +31,23 @@ class CatalogRepositoryDetailCacheTest { private class FakeCache( val preset: ItemDetail? = null, val seasonsPreset: SeasonsResponse? = null, + val identityTransitions: IdentityTransitionBarrier? = null, + val beforeItemCache: suspend () -> Unit = {}, ) : CatalogCachePort { var cachedId: String? = null - override suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) { cachedId = contentId } + override suspend fun cacheItemDetail( + contentId: String, + detail: ItemDetail, + lease: CatalogCacheWriteLease, + ) { + beforeItemCache() + if ( + identityTransitions == null || + lease.identityGeneration == identityTransitions.generation.value + ) { + cachedId = contentId + } + } override suspend fun getCachedItemDetail(contentId: String): ItemDetail? = preset override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? = seasonsPreset } @@ -74,6 +94,22 @@ class CatalogRepositoryDetailCacheTest { assertEquals("Cached", detail?.title) } + @Test + fun prefetchUsesCachedDetailWithoutNetwork() = runTest { + val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) + val result = repoThatFailsOnNetwork(cache).getItemDetailForPrefetch("c1") + assertEquals("Cached", (result as ApiResult.Success).data.title) + } + + @Test + fun prefetchFetchesAndCachesWhenDetailIsAbsent() = runTest { + val cache = FakeCache() + val result = repo(HttpStatusCode.OK, """{"content_id":"c2","type":"movie","title":"Fresh"}""", cache) + .getItemDetailForPrefetch("c2") + assertEquals("Fresh", (result as ApiResult.Success).data.title) + assertEquals("c2", cache.cachedId) + } + @Test fun doesNotServeCacheOn4xx() = runTest { val cache = FakeCache(preset = ItemDetail(contentId = "c1", type = "movie", title = "Cached")) @@ -87,4 +123,68 @@ class CatalogRepositoryDetailCacheTest { val result = repo(HttpStatusCode.ServiceUnavailable, "{}", cache).getSeasons("series-1") assertTrue(result is ApiResult.Success) } + + @Test + fun detailResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"content_id":"c1","type":"movie","title":"Profile A"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = CatalogRepository( + catalogApi = CatalogApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getItemDetail("c1") } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedId) + } + + @Test + fun profileSwitchBetweenRepositoryGuardAndCacheWriteDoesNotCacheOldDetail() = runTest { + val identityTransitions = DefaultIdentityTransitionBarrier() + val cache = FakeCache( + identityTransitions = identityTransitions, + beforeItemCache = { + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + }, + ) + val client = HttpClient( + MockEngine { + respond( + """{"content_id":"c1","type":"movie","title":"Profile A"}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val repository = CatalogRepository( + catalogApi = CatalogApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + assertTrue(repository.getItemDetail("c1") is ApiResult.Success) + assertEquals(null, cache.cachedId) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt new file mode 100644 index 000000000..ece4a7aee --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/PersonalDataRepositoryCacheTest.kt @@ -0,0 +1,68 @@ +package org.prairieserver.prairie.repository + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.prairieserver.prairie.model.personal.UserLibrary +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.repository.port.CatalogCachePort +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PersonalDataRepositoryCacheTest { + + private class FakeCache : CatalogCachePort { + var cachedLibraries: List? = null + + override suspend fun cacheLibraries(libraries: List) { + cachedLibraries = libraries + } + } + + @Test + fun librariesResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """[{"id":1,"name":"Profile A","type":"movie"}]""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = PersonalDataRepository( + personalDataApi = PersonalDataApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.listUserLibraries() } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedLibraries) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileIdentityCommitTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileIdentityCommitTest.kt new file mode 100644 index 000000000..3930d1b93 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/ProfileIdentityCommitTest.kt @@ -0,0 +1,257 @@ +package org.prairieserver.prairie.repository + +import org.prairieserver.prairie.model.profile.VerifyPinResponse +import org.prairieserver.prairie.model.profile.authorizedProfileToken +import org.prairieserver.prairie.network.AuthScopeSnapshot +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.TokenManagerImpl +import org.prairieserver.prairie.network.api.ProfileApi +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The profile id and the profile token are one identity. These cover the ways + * they used to come apart — the client would claim one profile while holding + * another's proof, or commit an answer that arrived after the user had moved on. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileIdentityCommitTest { + + private val noOpClient = HttpClient(MockEngine { _ -> + respond(content = "{}", status = HttpStatusCode.OK, headers = headersOf("Content-Type", "application/json")) + }) + + private fun repository( + tokenManager: org.prairieserver.prairie.network.TokenManager, + barrier: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + ) = ProfileRepository( + profileApi = ProfileApi(noOpClient), + tokenManager = tokenManager, + identityTransitions = barrier, + ) + + /** + * The deterministic phone bug: switching from a PIN-protected profile to an + * unprotected one left the protected profile's token in place, so requests + * went out as `X-Profile-Id: B` with A's `X-Profile-Token`. + */ + @Test + fun `selecting an unprotected profile drops the previous profile's token`() = runTest { + val tokenManager = TokenManagerImpl() + val repo = repository(tokenManager) + + repo.selectProfile(profileId = "profile-a", profileToken = "token-for-a") + assertEquals("token-for-a", tokenManager.getProfileToken()) + + repo.selectProfile(profileId = "profile-b", profileToken = null) + + assertEquals("profile-b", tokenManager.getProfileId()) + assertNull( + tokenManager.getProfileToken(), + "profile B must not inherit profile A's proof", + ) + } + + @Test + fun `selecting a protected profile commits that profile's own token`() = runTest { + val tokenManager = TokenManagerImpl() + val repo = repository(tokenManager) + + repo.selectProfile(profileId = "profile-a", profileToken = "token-for-a") + repo.selectProfile(profileId = "profile-b", profileToken = "token-for-b") + + assertEquals("profile-b", tokenManager.getProfileId()) + assertEquals("token-for-b", tokenManager.getProfileToken()) + } + + /** + * A token manager that models identity scopes, like the production + * (encrypted) one does. Plain [TokenManagerImpl] reports no scope at all, + * which deliberately leaves the guard inert — so it cannot exercise this. + */ + private class ScopedTokenManager( + private val barrier: IdentityTransitionBarrier, + private val delegate: TokenManagerImpl = TokenManagerImpl(), + ) : org.prairieserver.prairie.network.TokenManager by delegate { + // Reads the SAME barrier the repository commits through, so the + // generation moves exactly as it does in production. A double with a + // hand-set generation hid a real bug: `changing` bumps the generation + // on entry, so an in-block comparison against the captured value + // reported "changed" for every ordinary selection. + override suspend fun snapshotCurrentScope() = AuthScopeSnapshot( + serverId = "server-1", + profileId = delegate.getProfileId(), + serverUrl = "https://one.example", + profileToken = delegate.getProfileToken(), + identityGeneration = barrier.generation.value, + ) + } + + /** + * A verification captured against one identity must not be applied to + * whoever is active by the time it lands — the remote-playback overlay + * case, where committing would put this profile's proof in someone + * else's session. + */ + @Test + fun `a commit whose scope moved is discarded`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokenManager = ScopedTokenManager(barrier) + val repo = repository(tokenManager, barrier) + repo.selectProfile(profileId = "profile-a", profileToken = "token-for-a") + + val captured = repo.captureIdentityScope() + // Something else moves the identity while verification is in flight — + // a remote-playback overlay, a server switch, a sign-out. + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + + val result = repo.selectProfile( + profileId = "profile-x", + profileToken = "token-for-x", + expectedScope = captured, + ) + + assertEquals(ProfileCommitResult.ScopeChanged, result) + assertEquals("profile-a", tokenManager.getProfileId(), "identity must be untouched") + assertEquals("token-for-a", tokenManager.getProfileToken()) + } + + /** Same manager, unmoved scope: the ordinary path must still commit. */ + @Test + fun `a commit whose scope held is applied`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokenManager = ScopedTokenManager(barrier) + val repo = repository(tokenManager, barrier) + + val captured = repo.captureIdentityScope() + val result = repo.selectProfile( + profileId = "profile-b", + profileToken = "token-for-b", + expectedScope = captured, + ) + + assertEquals(ProfileCommitResult.Committed, result) + assertEquals("profile-b", tokenManager.getProfileId()) + assertEquals("token-for-b", tokenManager.getProfileToken()) + } + + /** + * A remote-playback overlay owns identity while it exists, and an + * unprotected selection carries no scope to compare — so the repository + * refuses the commit outright rather than relying on the token manager to + * absorb it. Both layers now decline: without the repository check the + * managers would no-op the write but the caller would be told `Committed` + * and would run the downstream side effects (registry write, cache resets, + * navigation) for a switch that never happened. + */ + @Test + fun `no profile commits while a temporary overlay owns identity`() = runTest { + val tokenManager = TokenManagerImpl() + val repo = repository(tokenManager) + // Seed a real persistent identity so we can prove it survives intact. + repo.selectProfile(profileId = "profile-a", profileToken = "token-for-a") + tokenManager.beginTemporaryScope( + org.prairieserver.prairie.network.TemporaryAuthScope( + generationId = "overlay-1", + serverId = "server-1", + serverUrl = "https://one.example", + accessToken = "overlay-access", + refreshToken = "overlay-refresh", + profileId = "overlay-profile", + profileToken = "overlay-token", + expiresAtEpochMs = Long.MAX_VALUE, + ), + ) + + val result = repo.selectProfile(profileId = "profile-b", profileToken = null) + + assertEquals(ProfileCommitResult.ScopeChanged, result) + // The overlay's own identity is untouched... + assertEquals("overlay-profile", tokenManager.getProfileId()) + assertEquals("overlay-token", tokenManager.getProfileToken()) + + // ...and so is the persistent identity underneath it, which is what + // the user returns to when the handoff ends. + tokenManager.endTemporaryScope() + assertEquals("profile-a", tokenManager.getProfileId()) + assertEquals("token-for-a", tokenManager.getProfileToken()) + } + + /** No captured scope means the guard stays inert rather than failing closed. */ + @Test + fun `a commit with no captured scope still applies`() = runTest { + val tokenManager = TokenManagerImpl() + val repo = repository(tokenManager) + + val result = repo.selectProfile( + profileId = "profile-b", + profileToken = "token-for-b", + expectedScope = null, + ) + + assertEquals(ProfileCommitResult.Committed, result) + assertEquals("profile-b", tokenManager.getProfileId()) + } + + /** + * The profile list is identity-bound. A response fetched under an identity + * that has since been replaced must be dropped, or the grid offers profiles + * from a session the app no longer holds — and an unprotected tap on one of + * those carries no scope to reject it. + */ + @Test + fun `a list fetched under a replaced identity is not accepted`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokenManager = ScopedTokenManager(barrier) + val repo = repository(tokenManager, barrier) + + val captured = repo.captureIdentityScope() + barrier.changing(IdentityTransitionKind.SIGN_OUT) { } + + assertFalse(repo.identityScopeUnchanged(captured)) + } + + @Test + fun `a list fetched under the current identity is accepted`() = runTest { + val barrier = DefaultIdentityTransitionBarrier() + val tokenManager = ScopedTokenManager(barrier) + val repo = repository(tokenManager, barrier) + + val captured = repo.captureIdentityScope() + + assertTrue(repo.identityScopeUnchanged(captured)) + } + + /** + * `valid` alone was the old gate. A 200 carrying no usable proof let the + * client enter a protected profile holding nothing to present, which + * surfaced much later as a confusing 403 on an unrelated action. + */ + @Test + fun `a verification without a usable token does not authorize`() { + assertNull(VerifyPinResponse(valid = true, profileToken = null).authorizedProfileToken()) + assertNull(VerifyPinResponse(valid = true, profileToken = "").authorizedProfileToken()) + assertNull(VerifyPinResponse(valid = true, profileToken = " ").authorizedProfileToken()) + assertNull(VerifyPinResponse(valid = false, profileToken = "token").authorizedProfileToken()) + } + + @Test + fun `a valid verification authorizes with its token`() { + assertEquals( + "token-for-a", + VerifyPinResponse(valid = true, profileToken = "token-for-a").authorizedProfileToken(), + ) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt index 426c7bd19..e25ac9fb6 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/SectionRepositoryCacheTest.kt @@ -2,6 +2,9 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.section.ResolvedSection import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind import org.prairieserver.prairie.network.PrairieJson import org.prairieserver.prairie.network.api.SectionApi import org.prairieserver.prairie.repository.port.CatalogCachePort @@ -13,7 +16,15 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -42,6 +53,36 @@ class SectionRepositoryCacheTest { return SectionRepository(SectionApi(client), cache) } + private fun gatedRepository( + homeRequestDispatcher: CoroutineDispatcher, + requestEntered: CompletableDeferred, + releaseResponse: CompletableDeferred, + body: () -> String, + onRequest: () -> Unit = {}, + identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(), + ): SectionRepository { + val client = HttpClient( + MockEngine { + onRequest() + val responseBody = body() + requestEntered.complete(Unit) + releaseResponse.await() + respond( + responseBody, + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + return SectionRepository( + sectionApi = SectionApi(client), + identityTransitions = identityTransitions, + homeRequestDispatcher = homeRequestDispatcher, + ) + } + private fun section(id: String) = ResolvedSection(id = id, sectionType = id, title = id) @Test @@ -68,4 +109,149 @@ class SectionRepositoryCacheTest { val result = repo(HttpStatusCode.NotFound, "{}", cache).getLibrarySections(7) assertTrue(result is ApiResult.Error) } + + @Test + fun concurrentHomeRequestsShareOneAggregateCall() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"sections":[]}""" }, + onRequest = { calls += 1 }, + ) + + val requests = listOf( + async { repository.getHomeSections() }, + async { repository.getHomeSections() }, + ) + entered.await() + repeat(10) { yield() } + release.complete(Unit) + requests.awaitAll() + + assertEquals(1, calls) + } + + @Test + fun concurrentHomeItemRequestsShareOneCallPerSection() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"items":[],"total":0}""" }, + onRequest = { calls += 1 }, + ) + + val requests = listOf( + async { repository.getHomeSectionItems("same") }, + async { repository.getHomeSectionItems("same") }, + ) + entered.await() + repeat(10) { yield() } + release.complete(Unit) + requests.awaitAll() + + assertEquals(1, calls) + } + + @Test + fun cancelingFirstHomeCallerDoesNotCancelSharedRequestOrPoisonNextCall() = runTest { + var calls = 0 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = entered, + releaseResponse = release, + body = { """{"sections":[]}""" }, + onRequest = { calls += 1 }, + ) + + val firstCaller = launch { repository.getHomeSections() } + entered.await() + val survivingCaller = async { repository.getHomeSections() } + repeat(10) { yield() } + firstCaller.cancelAndJoin() + release.complete(Unit) + + assertTrue(survivingCaller.await() is ApiResult.Success) + assertTrue(repository.getHomeSections() is ApiResult.Success) + assertEquals(2, calls) + } + + @Test + fun homeRequestStartedBeforeProfileSwitchDoesNotServeOldProfileSectionsToNewProfile() = runTest { + var calls = 0 + val oldRequestEntered = CompletableDeferred() + val releaseOldRequest = CompletableDeferred() + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = gatedRepository( + homeRequestDispatcher = StandardTestDispatcher(testScheduler), + requestEntered = oldRequestEntered, + releaseResponse = releaseOldRequest, + body = { + if (calls == 1) { + """{"sections":[{"id":"old","section_type":"old","title":"Old"}]}""" + } else { + """{"sections":[{"id":"new","section_type":"new","title":"New"}]}""" + } + }, + onRequest = { calls += 1 }, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getHomeSections() } + oldRequestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + val newProfileRequest = async { repository.getHomeSections() } + + releaseOldRequest.complete(Unit) + val oldProfileResult = oldProfileRequest.await() + val newProfileResult = newProfileRequest.await() + + assertEquals("Old", (oldProfileResult as ApiResult.Success).data.sections.single().title) + assertEquals("New", (newProfileResult as ApiResult.Success).data.sections.single().title) + assertEquals(2, calls) + } + + @Test + fun librarySectionsStartedBeforeProfileSwitchAreNotCachedForNewProfile() = runTest { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"sections":[{"id":"old","section_type":"old","title":"Profile A"}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val cache = FakeCache(preset = null) + val identityTransitions = DefaultIdentityTransitionBarrier() + val repository = SectionRepository( + sectionApi = SectionApi(client), + catalogCache = cache, + identityTransitions = identityTransitions, + ) + + val oldProfileRequest = async { repository.getLibrarySections(7) } + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + + assertTrue(oldProfileRequest.await() is ApiResult.Success) + assertEquals(null, cache.cachedFor) + assertEquals(null, cache.cachedSections) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt index 610c343ca..e51beac9e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/WatchTogetherRepositoryTest.kt @@ -3,8 +3,10 @@ package org.prairieserver.prairie.repository import org.prairieserver.prairie.model.watchtogether.AddSuggestionRequest import org.prairieserver.prairie.model.watchtogether.CreateRoomRequest import org.prairieserver.prairie.model.watchtogether.JoinRoomRequest +import org.prairieserver.prairie.model.watchtogether.MemberRole import org.prairieserver.prairie.model.watchtogether.PromoteSuggestionRequest import org.prairieserver.prairie.model.watchtogether.RoomResponse +import org.prairieserver.prairie.model.watchtogether.RoomSelectionMode import org.prairieserver.prairie.model.watchtogether.RoomSnapshot import org.prairieserver.prairie.model.watchtogether.SetSelectionRequest import org.prairieserver.prairie.model.watchtogether.Suggestion @@ -58,6 +60,11 @@ class WatchTogetherRepositoryTest { RoomResponse(RoomSnapshot(roomId = "room-1", code = "ABCD1234"), "jwt-room"), ), ) : WatchTogetherApi { + var createCalls = 0 + var addSuggestionCalls = 0 + var voteCalls = 0 + var promoteCalls = 0 + var closeCalls = 0 var lastRoomToken: String? = null var lastRoomId: String? = null var lastSelection: SetSelectionRequest? = null @@ -69,6 +76,7 @@ class WatchTogetherRepositoryTest { var listSuggestionsResult: CompletableDeferred>? = null var listSuggestionsCalls = 0 override suspend fun createRoom(request: CreateRoomRequest, scope: AuthScopeSnapshot): ApiResult { + createCalls++ lastAuthScope = scope return createResult?.await() ?: createResponse } @@ -99,6 +107,7 @@ class WatchTogetherRepositoryTest { roomToken: String, scope: AuthScopeSnapshot, ): ApiResult { + closeCalls++ lastRoomToken = roomToken lastAuthScope = scope return ApiResult.Success(Unit) @@ -114,7 +123,12 @@ class WatchTogetherRepositoryTest { roomToken: String, request: AddSuggestionRequest, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + addSuggestionCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) + } override suspend fun deleteSuggestion( roomId: String, roomToken: String, @@ -126,7 +140,12 @@ class WatchTogetherRepositoryTest { roomToken: String, suggestionId: String, scope: AuthScopeSnapshot, - ) = ApiResult.Success(SuggestionsResponse()).also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + voteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return ApiResult.Success(SuggestionsResponse()) + } override suspend fun unvote( roomId: String, roomToken: String, @@ -138,7 +157,12 @@ class WatchTogetherRepositoryTest { roomToken: String, request: PromoteSuggestionRequest, scope: AuthScopeSnapshot, - ) = createResponse.also { lastRoomToken = roomToken; lastAuthScope = scope } + ): ApiResult { + promoteCalls++ + lastRoomToken = roomToken + lastAuthScope = scope + return createResponse + } } private class FakeRealtime( @@ -247,6 +271,46 @@ class WatchTogetherRepositoryTest { assertEquals("tt-9", api.lastSelection?.contentId) } + @Test + fun `empty vote room owner keeps existing suggestion vote override and close authority`() = runTest { + val api = FakeApi( + createResponse = ApiResult.Success( + RoomResponse( + room = RoomSnapshot( + roomId = "room-1", + selectionMode = RoomSelectionMode.Vote, + selfRole = MemberRole.Host, + selfCanManageRoom = true, + ), + roomAccessToken = "room-token", + ), + ), + ) + val repository = WatchTogetherRepository( + api = api, + authScopeProvider = { scopeA }, + ) + + repository.createRoom(CreateRoomRequest(selectionMode = RoomSelectionMode.Vote.wire)) + repository.addSuggestion( + AddSuggestionRequest( + contentId = "movie-1", + contentType = "movie", + title = "Movie One", + ), + ) + repository.vote("suggestion-1") + repository.promoteSuggestion(PromoteSuggestionRequest("suggestion-1")) + repository.closeRoom() + + assertEquals(1, api.createCalls) + assertEquals(1, api.addSuggestionCalls) + assertEquals(1, api.voteCalls) + assertEquals(1, api.promoteCalls) + assertEquals(1, api.closeCalls) + assertEquals("room-token", api.lastRoomToken) + } + @Test fun `join hydrates suggestions that predate the websocket connection`() = runTest { val api = FakeApi().apply { @@ -775,6 +839,84 @@ class WatchTogetherRepositoryTest { // ---- suggestions fold + voted_by_me re-merge ------------------------------ + @Test + fun `opened refreshes suggestions after reconnect without refreshing after room closed`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse(suggestions = listOf(suggestion("before-drop"))), + ) + } + val realtime = FakeRealtime() + val repository = repo(api = api, realtime = realtime) + repository.createRoom(CreateRoomRequest()) + val connection = launch { repository.connect("room-1") } + runCurrent() + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(1, api.listSuggestionsCalls) + assertEquals(listOf("before-drop"), repository.suggestions.value.map { it.id }) + + realtime.events.emit(RoomRealtimeEvent.TransportTerminated()) + runCurrent() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("before-drop"), + suggestion("missed-during-drop"), + ), + ), + ) + advanceTimeBy(WatchTogetherRepository.BACKOFF_MS.first()) + runCurrent() + assertEquals(2, realtime.connectCount) + + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + assertEquals(2, api.listSuggestionsCalls) + assertEquals( + listOf("before-drop", "missed-during-drop"), + repository.suggestions.value.map { it.id }, + ) + + realtime.events.emit(RoomRealtimeEvent.Closed("host_left")) + advanceUntilIdle() + assertEquals(2, realtime.connectCount) + assertEquals(2, api.listSuggestionsCalls) + assertTrue(connection.isCompleted || connection.isCancelled) + } + + @Test + fun `rest refresh replaces authoritative local vote set`() = runTest { + val api = FakeApi().apply { + listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("removed-vote", votedByMe = true), + suggestion("preserved-vote", votedByMe = true), + ), + ), + ) + } + val repository = repo(api = api) + repository.createRoom(CreateRoomRequest()) + + repository.refreshSuggestions() + api.listSuggestionsResponse = ApiResult.Success( + SuggestionsResponse( + suggestions = listOf( + suggestion("removed-vote", votedByMe = false), + suggestion("preserved-vote", votedByMe = true), + ), + ), + ) + repository.refreshSuggestions() + + val byId = repository.suggestions.value.associateBy { it.id } + assertFalse(byId.getValue("removed-vote").votedByMe) + assertTrue(byId.getValue("preserved-vote").votedByMe) + } + @Test fun `suggestions event re-merges voted_by_me from local vote set`() = runTest { val api = FakeApi() @@ -1050,6 +1192,43 @@ class WatchTogetherRepositoryTest { job.cancel() } + @Test + fun `attach echo retains its observed epoch until the reconnect receives a fresh snapshot`() = runTest { + val attachedSnapshot = snapshot().copy(attachedSessionId = "playback-1") + val realtime = FakeRealtime().apply { + connectBehavior = { attempt -> + if (attempt == 1) { + flow { + emit(RoomRealtimeEvent.Opened) + emit(RoomRealtimeEvent.SnapshotEvent(attachedSnapshot)) + emit(RoomRealtimeEvent.TransportTerminated()) + } + } else { + events.asSharedFlow() + } + } + } + val repository = repo(realtime = realtime) + repository.createRoom(CreateRoomRequest()) + val job = launch { repository.connect("room-1") } + runCurrent() + + assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) + advanceTimeBy(500) + runCurrent() + realtime.events.emit(RoomRealtimeEvent.Opened) + runCurrent() + + assertEquals(2L, repository.connectionState.value.epoch) + assertEquals(1L, repository.roomDeliveryEcho.value?.connectionEpoch) + + realtime.events.emit(RoomRealtimeEvent.SnapshotEvent(attachedSnapshot)) + runCurrent() + + assertEquals(2L, repository.roomDeliveryEcho.value?.connectionEpoch) + job.cancel() + } + @Test fun `send fails before the active connection reports writable`() = runTest { val realtime = FakeRealtime() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModelTest.kt deleted file mode 100644 index d7cd0d215..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminStatsViewModelTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.model.admin.WatchProviderActivity -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.api.AdminApi -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull - -@OptIn(ExperimentalCoroutinesApi::class) -class AdminStatsViewModelTest { - - private val dispatcher = UnconfinedTestDispatcher() - @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @AfterTest - fun tearDown() { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() - Dispatchers.resetMain() - } - - // Cancel viewModelScope coroutines BEFORE resetting Main: a coroutine - // still parked on Dispatchers.Main when a later test calls setMain/resetMain - // throws IllegalStateException from TestMainDispatcher. - private val createdViewModels = mutableListOf() - - private fun track(viewModel: T): T { - createdViewModels += viewModel - return viewModel - } - - - private fun stats() = AdminStats( - totalItems = 10, totalFiles = 20, totalUsers = 3, - totalMovies = 4, totalMovieFiles = 4, totalShows = 6, totalShowFiles = 16, - activeStreams = 2, totalStorageBytes = 1024L * 1024L * 1024L, - watchProviderActivity = WatchProviderActivity(traktConnectedProfiles = 1, scrobbles24h = 7), - ) - - @Test fun `loads stats on init`() = runTest(dispatcher) { - val api = FakeAdminApi(ApiResult.Success(stats())) - val state = track(AdminStatsViewModel(AdminRepository(api))).uiState.value - assertFalse(state.isLoading) - assertNull(state.error) - assertEquals(2, state.stats?.activeStreams) - assertEquals(false, api.calls.last()) // initial load: refresh=false - } - - @Test fun `refresh requests a server recompute`() = runTest(dispatcher) { - val api = FakeAdminApi(ApiResult.Success(stats())) - val vm = track(AdminStatsViewModel(AdminRepository(api))) - vm.refresh() - assertEquals(true, api.calls.last()) // refresh=true - assertFalse(vm.uiState.value.isRefreshing) - } - - @Test fun `error surfaces server message with fallback`() = runTest(dispatcher) { - val api = FakeAdminApi(ApiResult.Error(code = 500, error = "internal", message = "")) - assertEquals("Failed to load admin stats", track(AdminStatsViewModel(AdminRepository(api))).uiState.value.error) - } - - @Test fun `network failure surfaces standard copy`() = runTest(dispatcher) { - val api = FakeAdminApi(ApiResult.NetworkError(IllegalStateException("offline"))) - assertEquals("Network error. Check your connection.", track(AdminStatsViewModel(AdminRepository(api))).uiState.value.error) - } -} - -private class FakeAdminApi(var result: ApiResult) : AdminApi { - val calls = mutableListOf() - - override suspend fun getStats(refresh: Boolean): ApiResult { - calls += refresh - return result - } - - override suspend fun getUsers(): ApiResult> = error("unused") - override suspend fun getUser(id: Int): ApiResult = error("unused") - override suspend fun createUser(request: CreateUserRequest): ApiResult = error("unused") - override suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult = error("unused") - override suspend fun deleteUser(id: Int): ApiResult = error("unused") - override suspend fun getSessions(): ApiResult> = error("unused") - override suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest, - ): ApiResult = error("unused") - override suspend fun getAppLogs( - level: String?, component: String?, nodeId: String?, requestId: String?, - sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, query: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun getAuditLogs( - method: String?, pathPrefix: String?, statusCode: Int?, clientIp: String?, - requestId: String?, sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun triggerScan(request: ScanRequest): ApiResult = error("unused") - override suspend fun cancelScan(request: ScanCancelRequest): ApiResult = error("unused") -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModelTest.kt deleted file mode 100644 index 563e35028..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserEditViewModelTest.kt +++ /dev/null @@ -1,174 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.api.AdminApi -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class AdminUserEditViewModelTest { - - private val dispatcher = UnconfinedTestDispatcher() - @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @AfterTest - fun tearDown() { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() - Dispatchers.resetMain() - } - - // Cancel viewModelScope coroutines BEFORE resetting Main: a coroutine - // still parked on Dispatchers.Main when a later test calls setMain/resetMain - // throws IllegalStateException from TestMainDispatcher. - private val createdViewModels = mutableListOf() - - private fun track(viewModel: T): T { - createdViewModels += viewModel - return viewModel - } - - - @Test fun `create validates required fields before calling api`() = runTest(dispatcher) { - val api = FakeEditApi() - val vm = track(AdminUserEditViewModel(AdminRepository(api))) - vm.load(null) - vm.onUsernameChange("u") - vm.onEmailChange("bad") - vm.onPasswordChange("secret1") - vm.submit() - assertEquals("Enter a valid email", vm.uiState.value.error) - assertNull(api.lastCreate) - } - - @Test fun `create submits a CreateUserRequest and signals success`() = runTest(dispatcher) { - val api = FakeEditApi() - val vm = track(AdminUserEditViewModel(AdminRepository(api))) - vm.load(null) - vm.onUsernameChange("alice") - vm.onEmailChange("alice@x.io") - vm.onPasswordChange("secret1") - vm.onRoleChange("admin") - vm.onLibraryIdsChange("1, 2") - vm.onMaxStreamsChange("3") - vm.submit() - val req = api.lastCreate - assertTrue(vm.uiState.value.saveSuccess) - assertEquals("alice", req?.username) - assertEquals("admin", req?.role) - assertEquals(listOf(1, 2), req?.libraryIds) - assertEquals(3, req?.maxStreams) - } - - @Test fun `edit loads the user and omits a blank password on update`() = runTest(dispatcher) { - val existing = AdminUser( - id = 7, username = "bob", email = "bob@x.io", role = "user", - enabled = true, maxStreams = 2, - ) - val api = FakeEditApi(user = existing) - val vm = track(AdminUserEditViewModel(AdminRepository(api))) - vm.load(7) - assertEquals("bob", vm.uiState.value.username) - assertEquals("2", vm.uiState.value.maxStreamsText) - vm.onEnabledChange(false) - vm.submit() // password left blank - assertTrue(vm.uiState.value.saveSuccess) - assertEquals(7, api.lastUpdateId) - assertEquals(false, api.lastUpdate?.enabled) - assertNull(api.lastUpdate?.password) - } - - @Test fun `edit rejects a too-short password reset`() = runTest(dispatcher) { - val api = FakeEditApi(user = AdminUser(id = 7, username = "bob", email = "b@x.io", role = "user")) - val vm = track(AdminUserEditViewModel(AdminRepository(api))) - vm.load(7) - vm.onPasswordChange("123") - vm.submit() - assertFalse(vm.uiState.value.saveSuccess) - assertEquals("Password must be at least 6 characters", vm.uiState.value.error) - assertNull(api.lastUpdate) - } - - @Test fun `edit omits libraryIds when the library field is blank`() = runTest(dispatcher) { - val existing = AdminUser( - id = 5, username = "carol", email = "carol@x.io", role = "user", - enabled = true, libraryIds = listOf(3, 4), - ) - val api = FakeEditApi(user = existing) - val vm = track(AdminUserEditViewModel(AdminRepository(api))) - vm.load(5) - vm.onLibraryIdsChange("") - vm.submit() - assertTrue(vm.uiState.value.saveSuccess) - assertNull(api.lastUpdate?.libraryIds, "libraryIds must be null (omitted) when the field is blank") - } -} - -private class FakeEditApi( - private val user: AdminUser? = null, -) : AdminApi { - var lastCreate: CreateUserRequest? = null - var lastUpdate: UpdateUserRequest? = null - var lastUpdateId: Int? = null - - override suspend fun getUser(id: Int): ApiResult = - user?.let { ApiResult.Success(it) } ?: ApiResult.Error(404, "nf", "") - override suspend fun createUser(request: CreateUserRequest): ApiResult { - lastCreate = request - return ApiResult.Success(AdminUser(id = 1, username = request.username, email = request.email, role = request.role)) - } - override suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult { - lastUpdateId = id - lastUpdate = request - return ApiResult.Success(user ?: AdminUser(id = id, username = "x", email = "x@x.io", role = "user")) - } - - override suspend fun getUsers(): ApiResult> = error("unused") - override suspend fun deleteUser(id: Int): ApiResult = error("unused") - override suspend fun getStats(refresh: Boolean): ApiResult = error("unused") - override suspend fun getSessions(): ApiResult> = error("unused") - override suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest, - ): ApiResult = error("unused") - override suspend fun getAppLogs( - level: String?, component: String?, nodeId: String?, requestId: String?, - sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, query: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun getAuditLogs( - method: String?, pathPrefix: String?, statusCode: Int?, clientIp: String?, - requestId: String?, sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun triggerScan(request: ScanRequest): ApiResult = error("unused") - override suspend fun cancelScan(request: ScanCancelRequest): ApiResult = error("unused") -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserFormTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserFormTest.kt deleted file mode 100644 index df7d1f7a6..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUserFormTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class AdminUserFormTest { - - @Test fun `role display capitalizes`() { - assertEquals("Admin", roleDisplayName("admin")) - assertEquals("User", roleDisplayName("user")) - assertEquals("Unknown", roleDisplayName("")) - } - - @Test fun `create validation requires username email and password`() { - assertNotNull(validateCreateUser(username = "", email = "a@b.io", password = "secret1")) - assertNotNull(validateCreateUser(username = "u", email = "bad", password = "secret1")) - assertNotNull(validateCreateUser(username = "u", email = "a@b.io", password = "123")) - assertNull(validateCreateUser(username = "u", email = "a@b.io", password = "secret1")) - } - - @Test fun `password reset validation allows blank but rejects short`() { - assertNull(validatePasswordReset("")) - assertNotNull(validatePasswordReset("123")) - assertNull(validatePasswordReset("secret1")) - } - - @Test fun `quota parsing rejects negatives and non-numbers`() { - assertNull(parseQuota("")) // blank -> unlimited / unchanged - assertNull(parseQuota("abc")) // non-numeric -> null - assertNull(parseQuota("-1")) // negative -> null - assertEquals(3, parseQuota("3")) - assertEquals(0, parseQuota("0")) - } - - @Test fun `library ids parsing tolerates whitespace and ignores junk`() { - assertEquals(emptyList(), parseLibraryIds("")) - assertEquals(listOf(1, 2, 3), parseLibraryIds("1, 2 , 3")) - assertEquals(listOf(5), parseLibraryIds("5, x, -2")) - } -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModelTest.kt deleted file mode 100644 index 4563b92e0..000000000 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/AdminUsersViewModelTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.prairieserver.prairie.viewmodel - -import androidx.lifecycle.viewModelScope -import org.prairieserver.prairie.model.admin.AdminAuditPage -import org.prairieserver.prairie.model.admin.AdminLogPage -import org.prairieserver.prairie.model.admin.AdminSession -import org.prairieserver.prairie.model.admin.AdminStats -import org.prairieserver.prairie.model.admin.AdminUser -import org.prairieserver.prairie.model.admin.CreateUserRequest -import org.prairieserver.prairie.model.admin.ScanCancelRequest -import org.prairieserver.prairie.model.admin.ScanCancelResponse -import org.prairieserver.prairie.model.admin.ScanRequest -import org.prairieserver.prairie.model.admin.ScanResponse -import org.prairieserver.prairie.model.admin.SessionControlAction -import org.prairieserver.prairie.model.admin.SessionControlRequest -import org.prairieserver.prairie.model.admin.SessionControlResponse -import org.prairieserver.prairie.model.admin.UpdateUserRequest -import org.prairieserver.prairie.network.ApiResult -import org.prairieserver.prairie.network.api.AdminApi -import org.prairieserver.prairie.repository.AdminRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class AdminUsersViewModelTest { - - private val dispatcher = UnconfinedTestDispatcher() - @BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) } - @AfterTest - fun tearDown() { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() - Dispatchers.resetMain() - } - - // Cancel viewModelScope coroutines BEFORE resetting Main: a coroutine - // still parked on Dispatchers.Main when a later test calls setMain/resetMain - // throws IllegalStateException from TestMainDispatcher. - private val createdViewModels = mutableListOf() - - private fun track(viewModel: T): T { - createdViewModels += viewModel - return viewModel - } - - - private fun u(id: Int, name: String, enabled: Boolean = true) = AdminUser( - id = id, username = name, email = "$name@x.io", role = "user", - permissions = emptyList(), enabled = enabled, - ) - - @Test fun `loads users on init`() = runTest(dispatcher) { - val api = FakeAdminUsersApi(users = mutableListOf(u(1, "a"), u(2, "b"))) - val vm = track(AdminUsersViewModel(AdminRepository(api))) - assertEquals(2, vm.uiState.value.users.size) - } - - @Test fun `delete removes user and surfaces message`() = runTest(dispatcher) { - val api = FakeAdminUsersApi(users = mutableListOf(u(1, "a"), u(2, "b"))) - val vm = track(AdminUsersViewModel(AdminRepository(api))) - vm.deleteUser(1) - assertTrue(vm.uiState.value.users.none { it.id == 1 }) - assertEquals("User deleted", vm.uiState.value.message) - } - - @Test fun `delete failure keeps list and surfaces error message`() = runTest(dispatcher) { - val api = FakeAdminUsersApi( - users = mutableListOf(u(1, "a")), - deleteError = ApiResult.Error(code = 403, error = "forbidden", message = "Cannot delete"), - ) - val vm = track(AdminUsersViewModel(AdminRepository(api))) - vm.deleteUser(1) - assertTrue(vm.uiState.value.users.any { it.id == 1 }) - assertEquals("Cannot delete", vm.uiState.value.message) - } - - @Test fun `error surfaces server message`() = runTest(dispatcher) { - val api = FakeAdminUsersApi(listError = ApiResult.Error(code = 500, error = "x", message = "")) - assertEquals("Failed to load users", track(AdminUsersViewModel(AdminRepository(api))).uiState.value.error) - } -} - -private class FakeAdminUsersApi( - private val users: MutableList = mutableListOf(), - private val listError: ApiResult>? = null, - private val deleteError: ApiResult? = null, -) : AdminApi { - override suspend fun getUsers(): ApiResult> = listError ?: ApiResult.Success(users.toList()) - override suspend fun getUser(id: Int): ApiResult { - val match = users.firstOrNull { it.id == id } - return if (match != null) ApiResult.Success(match) else ApiResult.Error(404, "nf", "") - } - override suspend fun createUser(request: CreateUserRequest): ApiResult { - val created = AdminUser( - id = (users.maxOfOrNull { it.id } ?: 0) + 1, - username = request.username, email = request.email, - role = request.role, permissions = emptyList(), enabled = true, - ) - users += created - return ApiResult.Success(created) - } - override suspend fun updateUser(id: Int, request: UpdateUserRequest): ApiResult { - val idx = users.indexOfFirst { it.id == id } - return if (idx >= 0) ApiResult.Success(users[idx]) else ApiResult.Error(404, "nf", "") - } - override suspend fun deleteUser(id: Int): ApiResult { - deleteError?.let { return it } - users.removeAll { it.id == id } - return ApiResult.Success(Unit) - } - - override suspend fun getStats(refresh: Boolean): ApiResult = error("unused") - override suspend fun getSessions(): ApiResult> = error("unused") - override suspend fun sessionControl( - sessionId: String, - action: SessionControlAction, - request: SessionControlRequest, - ): ApiResult = error("unused") - override suspend fun getAppLogs( - level: String?, component: String?, nodeId: String?, requestId: String?, - sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, query: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun getAuditLogs( - method: String?, pathPrefix: String?, statusCode: Int?, clientIp: String?, - requestId: String?, sessionId: String?, playbackSessionId: String?, userId: Int?, - from: String?, to: String?, cursor: String?, limit: Int, - ): ApiResult = error("unused") - override suspend fun triggerScan(request: ScanRequest): ApiResult = error("unused") - override suspend fun cancelScan(request: ScanCancelRequest): ApiResult = error("unused") -} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModelTest.kt index 01a5fba2d..8f4252163 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/CalendarViewModelTest.kt @@ -1,6 +1,5 @@ package org.prairieserver.prairie.viewmodel -import androidx.lifecycle.viewModelScope import org.prairieserver.prairie.model.calendar.CalendarDay import org.prairieserver.prairie.model.calendar.CalendarFilter import org.prairieserver.prairie.model.calendar.CalendarItem @@ -11,7 +10,6 @@ import org.prairieserver.prairie.network.api.CalendarApi import org.prairieserver.prairie.repository.CalendarRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -37,27 +35,19 @@ class CalendarViewModelTest { @AfterTest fun tearDown() { - createdViewModels.forEach { it.viewModelScope.cancel() } - createdViewModels.clear() Dispatchers.resetMain() } - // Cancel viewModelScope coroutines BEFORE resetting Main: a coroutine - // still parked on Dispatchers.Main when a later test calls setMain/resetMain - // throws IllegalStateException from TestMainDispatcher. - private val createdViewModels = mutableListOf() - - private fun track(viewModel: T): T { - createdViewModels += viewModel - return viewModel - } - - - private fun viewModel(api: FakeCalendarApi, today: String = "2026-06-12") = track(CalendarViewModel( + private fun viewModel( + api: FakeCalendarApi, + today: String = "2026-06-12", + filterStore: CalendarFilterStore = CalendarFilterStore.InMemory(), + ) = CalendarViewModel( repository = CalendarRepository(api), timezoneId = "Europe/Amsterdam", todayProvider = { today }, - )) + filterStore = filterStore, + ) @Test fun `loads the monday-anchored week containing today on init`() = runTest(dispatcher) { @@ -180,11 +170,11 @@ class CalendarViewModelTest { immediateResult = ApiResult.Success(CalendarResponse(listOf(weekBItem))), ) - val vm = track(CalendarViewModel( + val vm = CalendarViewModel( repository = CalendarRepository(gatedApi), timezoneId = "Europe/Amsterdam", todayProvider = { "2026-06-12" }, - )) + ) // vm.init triggers load for week A — it is now blocked on weekAGate // Advance to week B; its response returns immediately @@ -200,6 +190,105 @@ class CalendarViewModelTest { assertEquals(listOf(weekBItem), vm.uiState.value.days) assertEquals("2026-06-15", vm.uiState.value.weekStart) } + @Test + fun `filter is read from the store on init and written on change`() = runTest(dispatcher) { + val store = CalendarFilterStore.InMemory(CalendarFilter.Trending) + val api = FakeCalendarApi(ApiResult.Success(CalendarResponse())) + val vm = viewModel(api, filterStore = store) + + assertEquals(CalendarFilter.Trending, vm.uiState.value.filter) + assertEquals(CalendarFilter.Trending, api.calls.single().filter) + + vm.setFilter(CalendarFilter.Everything) + assertEquals(CalendarFilter.Everything, store.read()) + } + + @Test + fun `a previously loaded week renders from cache without a loading blank`() = runTest(dispatcher) { + val weekA = CalendarResponse(events = listOf(CalendarDay(date = "2026-06-09", items = listOf(stubItem("a"))))) + val weekB = CalendarResponse(events = listOf(CalendarDay(date = "2026-06-16", items = listOf(stubItem("b"))))) + val api = FakeCalendarApi(weekA.let { ApiResult.Success(it) }) + val vm = viewModel(api) + assertEquals("a", vm.uiState.value.days.single().items.single().contentId) + + api.result = ApiResult.Success(weekB) + vm.nextWeek() + assertEquals("b", vm.uiState.value.days.single().items.single().contentId) + + // Back to week A: the API now answers something else, but the cached + // rows show immediately and the request still goes out to revalidate. + val gate = CompletableDeferred() + api.beforeAnswer = { gate.await() } + vm.prevWeek() + assertFalse(vm.uiState.value.isLoading) + assertEquals("a", vm.uiState.value.days.single().items.single().contentId) + assertEquals(3, api.calls.size) + gate.complete(Unit) + } + + @Test + fun `an unseen week clears the previous rows while it loads`() = runTest(dispatcher) { + val weekA = CalendarResponse(events = listOf(CalendarDay(date = "2026-06-09", items = listOf(stubItem("a"))))) + val api = FakeCalendarApi(ApiResult.Success(weekA)) + val vm = viewModel(api) + + val gate = CompletableDeferred() + api.beforeAnswer = { gate.await() } + vm.nextWeek() + assertTrue(vm.uiState.value.isLoading) + assertTrue(vm.uiState.value.days.isEmpty()) + gate.complete(Unit) + } + + @Test + fun `a failed revalidation keeps cached rows and does not surface an error`() = runTest(dispatcher) { + val weekA = CalendarResponse(events = listOf(CalendarDay(date = "2026-06-09", items = listOf(stubItem("a"))))) + val api = FakeCalendarApi(ApiResult.Success(weekA)) + val vm = viewModel(api) + + api.result = ApiResult.NetworkError(RuntimeException("offline")) + vm.load() + + assertEquals("a", vm.uiState.value.days.single().items.single().contentId) + assertNull(vm.uiState.value.error) + } + + @Test + fun `a week change during a refresh clears the refreshing flag`() = runTest(dispatcher) { + val api = FakeCalendarApi(ApiResult.Success(CalendarResponse())) + val vm = viewModel(api) + + val gate = CompletableDeferred() + api.beforeAnswer = { gate.await() } + vm.refresh() + assertTrue(vm.uiState.value.isRefreshing) + + api.beforeAnswer = {} + vm.nextWeek() + assertFalse(vm.uiState.value.isRefreshing) + gate.complete(Unit) + assertFalse(vm.uiState.value.isRefreshing) + } + + @Test + fun `refresh evicts the cache so a failure shows the error`() = runTest(dispatcher) { + val weekA = CalendarResponse(events = listOf(CalendarDay(date = "2026-06-09", items = listOf(stubItem("a"))))) + val api = FakeCalendarApi(ApiResult.Success(weekA)) + val vm = viewModel(api) + + api.result = ApiResult.NetworkError(RuntimeException("offline")) + vm.refresh() + + // The stale rows are still on screen (they were not cleared), but the + // cache entry is gone: a later load of the same week starts blank. + assertFalse(vm.uiState.value.isRefreshing) + val gate = CompletableDeferred() + api.beforeAnswer = { gate.await() } + vm.load() + assertTrue(vm.uiState.value.isLoading) + assertTrue(vm.uiState.value.days.isEmpty()) + gate.complete(Unit) + } } private data class CalendarCall( @@ -216,6 +305,9 @@ private class FakeCalendarApi( val calls = mutableListOf() + /** Optional suspension point before answering, to hold a request in flight. */ + var beforeAnswer: suspend () -> Unit = {} + override suspend fun getCalendar( start: String, end: String, @@ -224,6 +316,7 @@ private class FakeCalendarApi( timezone: String?, ): ApiResult { calls += CalendarCall(start, end, filter, libraryId, timezone) + beforeAnswer() return result } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingDecisionOrderingTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingDecisionOrderingTest.kt new file mode 100644 index 000000000..8433fd2e6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingDecisionOrderingTest.kt @@ -0,0 +1,133 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.auth.DeviceLoginDecisionResponse +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import org.prairieserver.prairie.model.auth.DeviceLoginPollResponse +import org.prairieserver.prairie.model.auth.DeviceLoginStartResponse +import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.api.DeviceLoginApi +import org.prairieserver.prairie.repository.DeviceLoginRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A decision is more authoritative than a lookup, and it can finish first. + * + * canDecide stays true while an existing lookup refreshes — the previous + * result is deliberately left on screen rather than blanked — so approving + * mid-lookup is an ordinary thing to do, not a contrived one. The lookup then + * lands last, and without a guard it overwrites the outcome of the decision + * the viewer actually made. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DevicePairingDecisionOrderingTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() = Dispatchers.setMain(dispatcher) + + @AfterTest + fun tearDown() = Dispatchers.resetMain() + + private fun lookupResponse() = DeviceLoginLookupResponse( + status = "pending", + userCode = "ABCD-1234", + matchCode = "42", + deviceName = "Living Room TV", + devicePlatform = "tvOS", + ipAddressHint = "192.0.2.10", + ) + + private class FakeApi : DeviceLoginApi { + val lookups = ArrayDeque>>() + val decisions = ArrayDeque>>() + + override suspend fun lookupDeviceLogin(token: String?, code: String?) = + CompletableDeferred>() + .also { lookups.addLast(it) } + .await() + + override suspend fun approveDeviceLogin(token: String?, code: String?) = + CompletableDeferred>() + .also { decisions.addLast(it) } + .await() + + override suspend fun denyDeviceLogin(token: String?, code: String?) = + CompletableDeferred>() + .also { decisions.addLast(it) } + .await() + + override suspend fun startDeviceLogin( + deviceName: String?, + devicePlatform: String?, + ): ApiResult = error("unused") + + override suspend fun pollDeviceLogin( + deviceCode: String, + ): ApiResult = error("unused") + } + + private fun viewModel(api: FakeApi) = DevicePairingViewModel( + repository = DeviceLoginRepository(api), + initialToken = "tok", + initialCode = null, + ) + + @Test + fun aLateLookupCannotOverwriteACompletedApproval() = runTest { + val api = FakeApi() + val vm = viewModel(api) + + // The initial lookup resolves, so the decision is informed. + api.lookups.removeFirst().complete(ApiResult.Success(lookupResponse())) + assertEquals(true, vm.uiState.value.canDecide) + + // Refreshing keeps the previous result on screen, so approving is still + // offered — and taken — while that refresh is in flight. + vm.lookup() + val staleLookup = api.lookups.removeFirst() + vm.approve() + api.decisions.removeFirst().complete( + ApiResult.Success(DeviceLoginDecisionResponse(status = "approved")), + ) + assertEquals("approved", vm.uiState.value.completedStatus) + + // The retired lookup lands last. It must not paint an error over an + // approval that already succeeded, nor blank the decision's outcome. + staleLookup.complete(ApiResult.Error(code = 404, error = "gone", message = "Expired")) + assertEquals("approved", vm.uiState.value.completedStatus) + assertNull(vm.uiState.value.error) + assertEquals(false, vm.uiState.value.isLoading) + } + + @Test + fun aLateLookupCannotClearADecisionError() = runTest { + val api = FakeApi() + val vm = viewModel(api) + api.lookups.removeFirst().complete(ApiResult.Success(lookupResponse())) + + vm.lookup() + val staleLookup = api.lookups.removeFirst() + vm.deny() + api.decisions.removeFirst().complete( + ApiResult.Error(code = 409, error = "conflict", message = "Already decided"), + ) + val decisionError = vm.uiState.value.error + + // A successful stale lookup would otherwise clear the error the + // decision reported, leaving the viewer believing the deny worked. + staleLookup.complete(ApiResult.Success(lookupResponse())) + assertEquals(decisionError, vm.uiState.value.error) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt new file mode 100644 index 000000000..7d92ef143 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/DevicePairingUiStateTest.kt @@ -0,0 +1,84 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.auth.DeviceLoginLookupResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Approving a device login is an irreversible grant of access to someone else's + * session, so what makes it available is worth pinning down. + */ +class DevicePairingUiStateTest { + + private fun lookup() = DeviceLoginLookupResponse( + status = "pending", + userCode = "ABCD-1234", + matchCode = "42", + deviceName = "Living Room TV", + devicePlatform = "tvOS", + ipAddressHint = "192.0.2.10", + ) + + @Test + fun `a deep link cannot be approved before its lookup resolves`() { + // The regression this pins: a prairie://device?token=… link arrives with + // the token already set and starts its lookup automatically. Gating on + // "there is an identifier to submit" made Approve live from the first + // frame — before the device name, IP or match code existed to show, so + // the viewer would have been approving a request they could not see. + val state = DevicePairingUiState(token = "tok_abc") + assertFalse(state.canDecide) + } + + @Test + fun `a typed code cannot be approved before its lookup resolves`() { + assertFalse(DevicePairingUiState(code = "ABCD-1234").canDecide) + } + + @Test + fun `a resolved lookup can be approved`() { + assertTrue(DevicePairingUiState(token = "tok_abc", lookup = lookup()).canDecide) + } + + @Test + fun `a failed lookup cannot be approved even though the token survives`() { + // The error path clears the lookup and keeps the identifier, which is + // exactly the state reached when the server calls the request invalid + // or expired. Approving it anyway was possible before. + val state = DevicePairingUiState( + token = "tok_abc", + lookup = null, + error = "That code has expired.", + ) + assertFalse(state.canDecide) + } + + @Test + fun `a decision already in flight cannot be submitted again`() { + val state = DevicePairingUiState( + token = "tok_abc", + lookup = lookup(), + isSubmitting = true, + ) + assertFalse(state.canDecide) + } + + @Test + fun `an initial lookup cannot be approved while it is still running`() { + assertFalse(DevicePairingUiState(token = "tok_abc", isLoading = true).canDecide) + } + + @Test + fun `a refresh over an already-resolved lookup stays approvable`() { + // isLoading is not itself a gate. Pressing Check on a resolved request + // keeps the details on screen, so the decision the viewer can see is + // still the decision they would be making. + val state = DevicePairingUiState( + token = "tok_abc", + lookup = lookup(), + isLoading = true, + ) + assertTrue(state.canDecide) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt new file mode 100644 index 000000000..93515448b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeSectionHydratorTest.kt @@ -0,0 +1,122 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.section.HomeSectionItemsResponse +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HomeSectionHydratorTest { + + @Test + fun inlineSectionsRequireNoFallbackRequests() = runTest { + var calls = 0 + + val result = hydrateHomeSections( + sections = listOf(section("inline", total = 1, items = listOf(item("a")))), + ) { + calls += 1 + error("fallback must not run") + } + + assertEquals(0, calls) + assertEquals(listOf("a"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun topLevelFallbackItemsHydrateTheOriginalSection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("b")))) + } + + assertEquals("missing", result.sections.single().id) + assertEquals(listOf("b"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun nestedFallbackSectionWinsWhenItContainsItems() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.Success( + HomeSectionItemsResponse( + section = section("server", total = 1, items = listOf(item("nested"))), + items = listOf(item("top-level")), + ), + ) + } + + assertEquals("server", result.sections.single().id) + assertEquals(listOf("nested"), result.sections.single().items.map { it.contentId }) + assertTrue(result.fullyResolved) + } + + @Test + fun failedFallbackMarksSnapshotPartialAndOmitsEmptySection() = runTest { + val result = hydrateHomeSections(listOf(section("missing", total = 1))) { + ApiResult.NetworkError(IllegalStateException("offline")) + } + + assertTrue(result.sections.isEmpty()) + assertFalse(result.fullyResolved) + } + + @Test + fun fallbackHydrationNeverExceedsFourConcurrentRequests() = runTest { + val started = Channel(Channel.UNLIMITED) + val release = Channel(Channel.UNLIMITED) + var active = 0 + var maximum = 0 + val ids = (1..12).map { "section-$it" } + + val hydration = async { + hydrateHomeSections(ids.map { section(it, total = 1) }) { id -> + active += 1 + maximum = maxOf(maximum, active) + started.send(id) + release.receive() + active -= 1 + ApiResult.Success(HomeSectionItemsResponse(items = listOf(item("item-$id")))) + } + } + + val observedStarts = mutableListOf() + repeat(3) { + repeat(4) { observedStarts += started.receive() } + assertNull(withTimeoutOrNull(1) { started.receive() }) + repeat(4) { release.send(Unit) } + } + + val result = hydration.await() + assertEquals(4, maximum) + assertEquals(ids.toSet(), observedStarts.toSet()) + assertEquals(ids, result.sections.map { it.id }) + assertTrue(result.fullyResolved) + } + + private fun section( + id: String, + total: Int, + items: List = emptyList(), + ) = ResolvedSection( + id = id, + sectionType = "test", + title = id, + totalCount = total, + items = items, + ) + + private fun item(id: String) = SectionItem( + contentId = id, + type = "movie", + title = id, + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt new file mode 100644 index 000000000..0f12c29ea --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -0,0 +1,100 @@ +package org.prairieserver.prairie.viewmodel + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.prairieserver.prairie.domain.MediaActionsCoordinator +import org.prairieserver.prairie.model.section.ResolvedSection +import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier +import org.prairieserver.prairie.network.IdentityTransitionKind +import org.prairieserver.prairie.network.PrairieJson +import org.prairieserver.prairie.network.api.PersonalDataApi +import org.prairieserver.prairie.network.api.SectionApi +import org.prairieserver.prairie.repository.PersonalDataRepository +import org.prairieserver.prairie.repository.SectionRepository +import org.prairieserver.prairie.repository.port.HomeCachePort +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelCacheIdentityTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun homeResponseStartedBeforeProfileSwitchIsNotCachedForNewProfile() = runTest(dispatcher) { + val requestEntered = CompletableDeferred() + val releaseResponse = CompletableDeferred() + val client = HttpClient( + MockEngine { + requestEntered.complete(Unit) + releaseResponse.await() + respond( + """{"sections":[{"id":"old","section_type":"row","title":"Profile A","items":[]}]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + }, + ) { + install(ContentNegotiation) { json(PrairieJson) } + } + val identityTransitions = DefaultIdentityTransitionBarrier() + val cache = RecordingHomeCache() + val viewModel = HomeViewModel( + sectionRepository = SectionRepository( + sectionApi = SectionApi(client), + identityTransitions = identityTransitions, + ), + mediaActions = mediaActions(), + homeCache = cache, + identityTransitions = identityTransitions, + ) + + requestEntered.await() + identityTransitions.changing(IdentityTransitionKind.PROFILE_SWITCH) { } + releaseResponse.complete(Unit) + viewModel.uiState.first { !it.isLoading } + + assertEquals(null, cache.sections) + } + + private class RecordingHomeCache : HomeCachePort { + var sections: List? = null + + override suspend fun cacheHome(sections: List) { + this.sections = sections + } + } + + private fun mediaActions(): MediaActionsCoordinator { + val client = HttpClient(MockEngine { error("Personal data network should not be used") }) { + install(ContentNegotiation) { json(PrairieJson) } + } + return MediaActionsCoordinator(PersonalDataRepository(PersonalDataApi(client))) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModelGenerationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModelGenerationTest.kt new file mode 100644 index 000000000..02101fb12 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/PersonalListViewModelGenerationTest.kt @@ -0,0 +1,243 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.catalog.BrowseItem +import org.prairieserver.prairie.model.catalog.CatalogResponse +import org.prairieserver.prairie.network.ApiResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A page fetched at `offset = N` describes a list that a refresh has since + * thrown away. Gating the TRIGGERS cannot prevent this on its own — the page is + * already in flight when the refresh starts, and nothing cancels it — so the + * check that matters happens when the page lands. + * + * These lists refresh on every resume, which is exactly when a viewer comes + * back from a detail page, so the interleaving is ordinary rather than exotic. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PersonalListViewModelGenerationTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun item(id: String) = BrowseItem(contentId = id, type = "movie", title = id) + + private fun page(vararg ids: String, hasMore: Boolean = false) = ApiResult.Success( + CatalogResponse(items = ids.map(::item), hasMore = hasMore, total = ids.size), + ) + + private class TestList : PersonalListViewModel(pageSize = 2) { + val pending = ArrayDeque>>() + val offsets = mutableListOf() + /** The query each fetch actually went out under, in order. */ + val queries = mutableListOf() + + override suspend fun fetchPage( + offset: Int, + limit: Int, + query: PersonalListQuery, + ): ApiResult { + offsets += offset + queries += query + val deferred = CompletableDeferred>() + pending.addLast(deferred) + return deferred.await() + } + + fun start() = loadInitial() + } + + @Test + fun refreshDiscardsAPageThatWasAlreadyInFlight() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + assertEquals(listOf("a", "b"), vm.uiState.value.items.map { it.contentId }) + + // Page two goes out, then a resume refresh replaces the whole list. + vm.loadMore() + val pageTwo = vm.pending.removeFirst() + vm.refresh() + val refreshed = vm.pending.removeFirst() + assertEquals(listOf(0, 2, 0), vm.offsets) + + // The refresh lands first and publishes a coherent page one. + refreshed.complete(page("x", "y", hasMore = true)) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + + // The superseded page must not append: items fetched at offset 2 of the + // OLD list would land after "y" and leave a hole where the middle was. + pageTwo.complete(page("c", "d")) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + assertFalse(vm.uiState.value.isLoadingMore) + } + + @Test + fun aSupersededPageDoesNotPublishItsError() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + + vm.loadMore() + val pageTwo = vm.pending.removeFirst() + vm.refresh() + vm.pending.removeFirst().complete(page("x", "y", hasMore = true)) + + // A stale request's failure is not this list's failure. Showing it would + // put an error banner over content that loaded perfectly well. + pageTwo.complete(ApiResult.Error(code = 500, error = "stale", message = "stale page")) + assertEquals(null, vm.uiState.value.error) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + assertFalse(vm.uiState.value.isLoadingMore) + } + + /** + * The trigger gate only works if the state it reads has already been + * claimed. Under a queuing dispatcher, a refresh that has not yet run its + * own body is invisible to loadMore() — so a page goes out, captures the + * refresh's generation once it finally runs, looks current, and appends at + * an offset belonging to the list the refresh replaced. + * + * An unconfined dispatcher cannot express this: it runs refresh eagerly to + * its first suspension, which claims the flag as a side effect and hides + * the very ordering under test. + */ + @Test + fun aRefreshQueuedButNotYetRunStillBlocksPaging() = runTest { + val scheduler = TestCoroutineScheduler() + val queuing = StandardTestDispatcher(scheduler) + Dispatchers.setMain(queuing) + try { + val vm = TestList() + vm.start() + scheduler.advanceUntilIdle() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + scheduler.advanceUntilIdle() + + // Neither body has run yet; the gate has only the claimed state. + vm.refresh() + vm.loadMore() + scheduler.advanceUntilIdle() + + assertEquals(listOf(0, 0), vm.offsets, "paging must not go out behind a queued refresh") + vm.pending.removeFirst().complete(page("x", "y")) + scheduler.advanceUntilIdle() + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + } finally { + Dispatchers.setMain(dispatcher) + } + } + + /** + * A reset and a refresh claim DIFFERENT flags, so neither can be trusted to + * clear the other's on its way past. These two cover both orderings; before + * each request released the flag it actually owned, one of them left the + * surface spinning forever. + */ + @Test + fun aResetSupersededByARefreshDoesNotStrandIsLoading() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + + vm.retry() + val staleReset = vm.pending.removeFirst() + assertTrue(vm.uiState.value.isLoading, "the reset should have claimed isLoading") + + vm.refresh() + vm.pending.removeFirst().complete(page("x", "y")) + staleReset.complete(page("stale")) + + assertFalse(vm.uiState.value.isLoading, "isLoading must not outlive the reset that claimed it") + assertFalse(vm.uiState.value.isRefreshing) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + } + + @Test + fun aRefreshSupersededByAResetDoesNotStrandIsRefreshing() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + + vm.refresh() + val staleRefresh = vm.pending.removeFirst() + assertTrue(vm.uiState.value.isRefreshing, "the refresh should have claimed isRefreshing") + + vm.retry() + vm.pending.removeFirst().complete(page("x", "y")) + staleRefresh.complete(page("stale")) + + assertFalse(vm.uiState.value.isRefreshing, "isRefreshing must not outlive the refresh that claimed it") + assertFalse(vm.uiState.value.isLoading) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + } + + /** + * A sort/filter change reloads from zero under the new query, and does so + * through the same generation bump every other replacement uses — so a page + * still in flight under the OLD query cannot append its differently-ordered + * items onto the new list. + */ + @Test + fun applyQueryReloadsUnderTheNewQueryAndDropsTheSupersededPage() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + + vm.loadMore() + val stalePage = vm.pending.removeFirst() + + val sorted = PersonalListQuery(sort = "title", order = "asc") + vm.applyQuery(sorted) + assertEquals(listOf(0, 2, 0), vm.offsets) + assertEquals(sorted, vm.queries.last(), "the reload must carry the new query") + assertEquals(sorted, vm.uiState.value.query) + + vm.pending.removeFirst().complete(page("x", "y")) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + + stalePage.complete(page("c", "d")) + assertEquals(listOf("x", "y"), vm.uiState.value.items.map { it.contentId }) + assertFalse(vm.uiState.value.isLoadingMore) + + // Re-applying the same query is a no-op — nothing re-fetches. + vm.applyQuery(sorted) + assertEquals(3, vm.offsets.size) + } + + @Test + fun anUncontestedPageStillAppends() = runTest { + val vm = TestList() + vm.start() + vm.pending.removeFirst().complete(page("a", "b", hasMore = true)) + + // The guard must not swallow ordinary pagination. + vm.loadMore() + vm.pending.removeFirst().complete(page("c", "d")) + assertEquals(listOf("a", "b", "c", "d"), vm.uiState.value.items.map { it.contentId }) + assertFalse(vm.uiState.value.isLoadingMore) + } +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsFeaturedRowTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsFeaturedRowTest.kt new file mode 100644 index 000000000..f2dd8359b --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsFeaturedRowTest.kt @@ -0,0 +1,57 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.recommendation.DiscoverRow +import org.prairieserver.prairie.model.section.SectionItem +import org.prairieserver.prairie.model.section.splitFeatured +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Discover rows have no `featured` field on the wire, so the hero the phone + * renders comes entirely from the conversion marking "for-you-main". If that + * mark moved to another row — or spread to several — [splitFeatured] would + * promote the wrong section into the hero carousel. + */ +class RecommendationsFeaturedRowTest { + @Test + fun onlyTheForYouMainRowIsFeatured() { + val sections = listOf( + row("popular", "Popular on This Server", "popular", "movie-popular"), + row("for_you", "For You", "for-you-main", "movie-personal"), + row("recently_added", "Recently Added", "recently-added", "movie-new"), + ).toResolvedSections() + + val featured = sections.splitFeatured().featured + + assertEquals("For You", featured?.title) + assertEquals(1, sections.count { it.featured }) + } + + /** A server that sends no personalised row must leave the flag clear, so + * the client falls back to its own hero choice instead of guessing here. */ + @Test + fun feedsWithoutAForYouMainRowHaveNoFeaturedSection() { + val sections = listOf( + row("popular", "Popular on This Server", "popular", "movie-popular"), + row("cluster", "Because you enjoy Drama", "cluster", "movie-drama", sectionKey = "2"), + ).toResolvedSections() + + assertTrue(sections.none { it.featured }) + assertEquals(null, sections.splitFeatured().featured) + } + + private fun row( + type: String, + label: String, + sectionKind: String, + contentId: String, + sectionKey: String? = null, + ) = DiscoverRow( + type = type, + label = label, + sectionKind = sectionKind, + sectionKey = sectionKey, + items = listOf(SectionItem(contentId = contentId, type = "movie", title = contentId)), + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsSectionIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsSectionIdentityTest.kt new file mode 100644 index 000000000..60ee55fad --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/RecommendationsSectionIdentityTest.kt @@ -0,0 +1,221 @@ +package org.prairieserver.prairie.viewmodel + +import org.prairieserver.prairie.model.recommendation.DiscoverRow +import org.prairieserver.prairie.model.section.SectionItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +class RecommendationsSectionIdentityTest { + @Test + fun productionSectionIdsSurviveRowInsertionAndReorder() { + val returnedFrom = row( + type = "cluster", + label = "Because you enjoy Drama", + sectionKind = "cluster", + sectionKey = "2", + contentId = "movie-b", + ) + val popular = row( + type = "popular", + label = "Popular on This Server", + sectionKind = "popular", + contentId = "movie-popular", + ) + + val beforeRefresh = listOf(returnedFrom, popular).toResolvedSections() + val afterRefresh = listOf( + row( + type = "cluster", + label = "Because you enjoy Comedy", + sectionKind = "cluster", + sectionKey = "7", + contentId = "movie-new", + ), + popular, + returnedFrom, + ).toResolvedSections() + + val originalId = beforeRefresh.single { section -> + section.items.any { it.contentId == "movie-b" } + }.id + val refreshedId = afterRefresh.single { section -> + section.items.any { it.contentId == "movie-b" } + }.id + val insertedId = afterRefresh.single { section -> + section.items.any { it.contentId == "movie-new" } + }.id + + assertEquals(originalId, refreshedId) + assertNotEquals(insertedId, refreshedId) + } + + /** + * `discoverRowSectionKey` returns an empty kind for row types it does not + * recognise, and both identity fields are `omitempty`, so unrecognised rows + * — and every row from a server predating `section_kind` — fall back to + * type+label. Section IDs key a LazyColumn, where a duplicate is a crash, + * so collisions must resolve rather than propagate. + */ + @Test + fun collidingSectionIdentitiesStayUniqueForLazyListKeys() { + val keyless = { contentId: String -> + DiscoverRow( + type = "server_row_this_client_does_not_know", + label = "Handpicked", + items = listOf(SectionItem(contentId = contentId, type = "movie", title = contentId)), + ) + } + + val ids = listOf(keyless("movie-a"), keyless("movie-b"), keyless("movie-c")) + .toResolvedSections() + .map { it.id } + + assertEquals(3, ids.size) + assertEquals(ids.size, ids.toSet().size, "section ids must be unique: $ids") + } + + /** + * `discoverRowSectionKey` sends no key for the singleton kinds — + * for-you-main, similar-users, popular, recently-added, top-rated — and + * `section_key` is `omitempty`, so the client sees null. Those rows change + * contents on every refresh, so their identity must come from the kind + * alone; deriving it from contents would break the detail return on exactly + * the rows that churn most. + * + * The kind strings are the server's wire values (hyphenated), not the row + * `type` values (underscored) — the two differ, and only the former is + * matched. + */ + @Test + fun keylessServerKindsKeepTheirIdentityAcrossContentChurn() { + val recentlyAdded = { contentIds: List -> + DiscoverRow( + type = "recently_added", + label = "Recently Added", + sectionKind = "recently-added", + sectionKey = null, + items = contentIds.map { SectionItem(contentId = it, type = "movie", title = it) }, + ) + } + + val before = listOf(recentlyAdded(listOf("a", "b", "c"))).toResolvedSections().single().id + val afterNewMedia = + listOf(recentlyAdded(listOf("new", "a", "b"))).toResolvedSections().single().id + + assertEquals(before, afterNewMedia) + } + + /** Kinds that legitimately repeat still separate on their key. */ + @Test + fun keyedServerKindsStayDistinctPerKey() { + val ids = listOf( + row("cluster", "Because you enjoy Drama", "cluster", "2", "movie-a"), + row("cluster", "Because you enjoy Comedy", "cluster", "7", "movie-b"), + ).toResolvedSections().map { it.id } + + assertEquals(ids.size, ids.toSet().size, "keyed rows must not collapse: $ids") + } + + /** + * The regression that motivated the singleton allowlist. Identifying a row + * by a bare kind is only sound for kinds that appear at most once. A + * repeatable kind arriving without a key must NOT collapse onto one id, + * because [toResolvedSections] resolves duplicate ids by dropping rows — + * the second section would disappear from the feed entirely. + */ + @Test + fun repeatableKindsWithoutKeysDoNotCollapseIntoOneSection() { + val sections = listOf( + row("cluster", "Because you enjoy Drama", "cluster", null, "movie-a"), + row("cluster", "Because you enjoy Comedy", "cluster", null, "movie-b"), + ).toResolvedSections() + + assertEquals(2, sections.size, "keyless repeatable rows must both survive") + val ids = sections.map { it.id } + assertEquals(ids.size, ids.toSet().size, "section ids must be unique: $ids") + } + + /** + * A kind this client has never heard of is treated as potentially + * repeatable for the same reason: the client cannot know it is a singleton, + * so identity falls back to contents rather than risking a silent drop. + */ + @Test + fun unrecognisedKeylessKindsFallBackToContentIdentity() { + val sections = listOf( + row("mood", "Rainy Sunday", "mood-of-the-day", null, "movie-a"), + row("mood", "Late Night", "mood-of-the-day", null, "movie-b"), + ).toResolvedSections() + + assertEquals(2, sections.size, "unknown keyless rows must both survive") + assertEquals(2, sections.map { it.id }.toSet().size) + } + + @Test + fun keylessSectionIdsSurviveInsertionAndReorder() { + val first = keylessRow(label = "Handpicked", contentId = "movie-a") + val second = keylessRow(label = "Handpicked", contentId = "movie-b") + + val beforeRefresh = listOf(first, second).toResolvedSections() + val afterRefresh = listOf( + keylessRow(label = "Handpicked", contentId = "movie-new"), + second, + first, + ).toResolvedSections() + + beforeRefresh.forEach { original -> + val contentId = original.items.single().contentId + val refreshed = afterRefresh.single { it.items.single().contentId == contentId } + assertEquals(original.id, refreshed.id) + } + } + + @Test + fun suffixLikeLegacyLabelsCannotCollideWithGeneratedIds() { + val ids = listOf( + keylessRow(label = "Handpicked", contentId = "movie-a"), + keylessRow(label = "Handpicked", contentId = "movie-b"), + keylessRow(label = "Handpicked#1", contentId = "movie-a"), + ).toResolvedSections().map { it.id } + + assertEquals(ids.size, ids.toSet().size, "section ids must be delimiter-safe: $ids") + } + + @Test + fun indistinguishableLegacyRowsCollapseToOneSection() { + val duplicate = keylessRow(label = "Handpicked", contentId = "movie-a") + + val sections = listOf(duplicate, duplicate).toResolvedSections() + + assertEquals(1, sections.size) + assertSame(duplicate.items.single(), sections.single().items.single()) + } + + private fun row( + type: String, + label: String, + sectionKind: String, + sectionKey: String? = null, + contentId: String, + ) = DiscoverRow( + type = type, + label = label, + sectionKind = sectionKind, + sectionKey = sectionKey, + items = listOf( + SectionItem( + contentId = contentId, + type = "movie", + title = contentId, + ), + ), + ) + + private fun keylessRow(label: String, contentId: String) = DiscoverRow( + type = "server_row_this_client_does_not_know", + label = label, + items = listOf(SectionItem(contentId = contentId, type = "movie", title = contentId)), + ) +} diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt index 56d4c57c9..a80b5c2b3 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomDeliveryLatchTest.kt @@ -77,6 +77,73 @@ class RoomDeliveryLatchTest { assertFalse(latch.isAttached(key)) } + @Test + fun `session traffic waits for both attach delivery and the server echo`() { + val latch = RoomDeliveryLatch() + val key = assertNotNull(latch.keyOrNull(open1, "session-a")) + + assertFalse(latch.isServerAttached(key, echo = null)) + latch.recordAttach(key, delivered = true) + assertFalse(latch.isServerAttached(key, echo = null)) + assertFalse( + latch.isServerAttached( + key, + RoomDeliveryEcho(7, 1, "session-b"), + ), + ) + assertTrue( + latch.isServerAttached( + key, + RoomDeliveryEcho(7, 1, "session-a"), + ), + ) + + val replacementEpoch = assertNotNull(latch.keyOrNull(open2, "session-a")) + assertFalse( + latch.isServerAttached( + replacementEpoch, + RoomDeliveryEcho(7, 1, "session-a"), + ), + ) + } + + @Test + fun `server attach rejects nullable keys and stale echoes`() { + val latch = RoomDeliveryLatch() + val key = assertNotNull(latch.keyOrNull(open1, "session-a")) + val matchingEcho = RoomDeliveryEcho(7, 1, "session-a") + latch.recordAttach(key, delivered = true) + + assertFalse(latch.isServerAttached(key = null, echo = matchingEcho)) + assertFalse(latch.isServerAttached(key = key, echo = null)) + assertFalse( + latch.isServerAttached( + key = key, + echo = RoomDeliveryEcho(7, 2, "session-a"), + ), + ) + } + + @Test + fun `stale prior epoch echo cannot authorize a newly delivered reconnect attach`() { + val latch = RoomDeliveryLatch() + val firstEpoch = assertNotNull(latch.keyOrNull(open1, "session-a")) + latch.recordAttach(firstEpoch, delivered = true) + val firstEcho = RoomDeliveryEcho(7, 1, "session-a") + assertTrue(latch.isServerAttached(firstEpoch, firstEcho)) + + val replacementEpoch = assertNotNull(latch.keyOrNull(open2, "session-a")) + latch.recordAttach(replacementEpoch, delivered = true) + + assertFalse(latch.isServerAttached(replacementEpoch, firstEcho)) + assertTrue( + latch.isServerAttached( + replacementEpoch, + RoomDeliveryEcho(7, 2, "session-a"), + ), + ) + } + @Test fun `delayed command for session A cannot mutate replacement session B`() { val command = TransportCommand( diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt index 2a878d236..77154587f 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/RoomSessionTest.kt @@ -162,6 +162,37 @@ class RoomSessionTest { assertEquals(1, repository.resetCount) } + @Test + fun `room remains adopted until explicit leave or identity transition`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + + session.adopt("room-a").join() + runCurrent() + assertTrue(session.isActive()) + assertEquals(0, repository.resetCount) + + barrier.changing(IdentityTransitionKind.PROFILE_SWITCH) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } + } + + @Test + fun `sign out clears the adopted room before identity mutation`() = runTest { + val repository = FakeRoomSessionRepository() + val barrier = DefaultIdentityTransitionBarrier() + val session = RoomSession(repository, backgroundScope, barrier) + session.adopt("room-a").join() + runCurrent() + + barrier.changing(IdentityTransitionKind.SIGN_OUT) { + assertTrue(!session.isActive()) + assertEquals(1, repository.resetCount) + } + } + @Test fun `every identity transition kind resets the room before mutation`() = runTest { IdentityTransitionKind.entries.forEach { kind -> diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt new file mode 100644 index 000000000..d132f0474 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/watchtogether/WatchTogetherEntryPolicyTest.kt @@ -0,0 +1,57 @@ +package org.prairieserver.prairie.watchtogether + +import org.prairieserver.prairie.model.watchtogether.MemberRole +import org.prairieserver.prairie.model.watchtogether.RoomPhase +import org.prairieserver.prairie.model.watchtogether.RoomSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WatchTogetherEntryPolicyTest { + @Test + fun selectedGuestRoutesToPlayer() { + val room = RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Guest, + memberCount = 2, + ) + assertEquals(WatchTogetherEntryTarget.Player, watchTogetherEntryTarget(room)) + } + + @Test + fun emptyRoomAndSoloHostRouteToLobby() { + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget(RoomSnapshot(roomId = "room-1")), + ) + assertEquals( + WatchTogetherEntryTarget.Lobby, + watchTogetherEntryTarget( + RoomSnapshot( + roomId = "room-1", + selectedContentId = "movie-1", + selfRole = MemberRole.Host, + memberCount = 1, + ), + ), + ) + } + + @Test + fun onlyNonTerminalNonBlankRoomIsResumable() { + assertNull(resumableWatchTogetherRoom(null)) + assertNull(resumableWatchTogetherRoom(RoomSnapshot(roomId = ""))) + assertNull( + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Ended), + ), + ) + assertEquals( + "room-1", + resumableWatchTogetherRoom( + RoomSnapshot(roomId = "room-1", phase = RoomPhase.Lobby), + )?.roomId, + ) + } +} diff --git a/shared/src/commonTest/resources/diagnostics/v1/SOURCE b/shared/src/commonTest/resources/diagnostics/v1/SOURCE index d6287202b..a9fe83293 100644 --- a/shared/src/commonTest/resources/diagnostics/v1/SOURCE +++ b/shared/src/commonTest/resources/diagnostics/v1/SOURCE @@ -1,3 +1,3 @@ -repository=https://github.com/Prairie-Server/prairie-server -commit=0a914441ea54d02ffc7bcdd24f5b8e3b8353d06a +repository=https://github.com/Silo-Server/silo-server +commit=e4627b76cb746fb7a31148c18f41beb3df8673c8 path=docs/design/schemas/client-diagnostics/v1 diff --git a/shared/src/commonTest/resources/diagnostics/v1/attr-registry.json b/shared/src/commonTest/resources/diagnostics/v1/attr-registry.json index 57e40505d..d93640b36 100644 --- a/shared/src/commonTest/resources/diagnostics/v1/attr-registry.json +++ b/shared/src/commonTest/resources/diagnostics/v1/attr-registry.json @@ -39,6 +39,22 @@ "audio_underruns": { "type": "integer", "description": "Audio underrun count." + }, + "session_id": { + "type": "string", + "description": "Playback session identifier for correlation with operational logs." + }, + "play_method": { + "type": "string", + "description": "Selected playback delivery method such as direct, remux, or transcode." + }, + "reason": { + "type": "string", + "description": "Playback route, interruption, or lifecycle reason." + }, + "position_ms": { + "type": "integer", + "description": "Current playback position in milliseconds." } }, "focus": { @@ -67,12 +83,44 @@ "duration_ms": { "type": "integer", "description": "Request duration in milliseconds." + }, + "outcome": { + "type": "string", + "description": "Request outcome such as success, http_error, transport_error, or cancelled." + }, + "error_code": { + "type": "string", + "description": "Stable error classification for a failed request." + }, + "attempt": { + "type": "integer", + "description": "Attempt ordinal for a retried request." } }, "lifecycle": { "state": { "type": "string", "description": "App lifecycle state." + }, + "phase": { + "type": "string", + "description": "Startup or lifecycle phase identifier." + }, + "duration_ms": { + "type": "integer", + "description": "Elapsed duration for the phase in milliseconds." + }, + "outcome": { + "type": "string", + "description": "Result of the phase, such as success, failure, or skipped." + }, + "reason": { + "type": "string", + "description": "Classification for the phase outcome." + }, + "launch_type": { + "type": "string", + "description": "Launch classification, such as cold or warm." } }, "crash": { diff --git a/shared/src/commonTest/resources/playback/v3/SOURCE b/shared/src/commonTest/resources/playback/v3/SOURCE new file mode 100644 index 000000000..6c6da16a0 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/SOURCE @@ -0,0 +1,18 @@ +repository=https://github.com/Silo-Server/silo-server +path=internal/playback/testdata/protocol_v3 +commit=79e3e761ad391b1aa9f2c280eeceeb23df9d3c81 +protocol_version=3 + +Byte-identical copies of the server's golden playback-v3 wire fixtures; do not +hand-edit any of them. They are generated from the live Go contract types by +`cmd/playbackfixtures`, and the server's `make verify-playback-fixtures` +regenerates and diffs them, so a contract change cannot merge there without +refreshing what these files assert. Re-vendor by copying the directory again +and updating the commit above, then run :shared:testDebugUnitTest. + +The direction of authority runs one way: the server defines the protocol and +this client proves conformance against it. That is why +PlaybackProtocolV3ConformanceTest compares against these values as opaque +expected output rather than recomputing them — in particular attempt_keys.json, +whose server-minted keys can only be echoed, never derived here. Under the +neutral contract the client no longer mints keys at all. diff --git a/shared/src/commonTest/resources/playback/v3/attempt_keys.json b/shared/src/commonTest/resources/playback/v3/attempt_keys.json new file mode 100644 index 000000000..39846cd8e --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/attempt_keys.json @@ -0,0 +1,29 @@ +[ + { + "name": "hls_burn_in_sorted_transformations_and_pcm_mutations", + "server_plan_attempt_key": "v3:a90828494166fe72", + "replan_echo": "v3:a90828494166fe72", + "attempted_plan_keys": [ + "v3:a90828494166fe72" + ], + "expected_server_action": "reject_already_attempted_plan" + }, + { + "name": "direct_client_dv81_executor_and_version", + "server_plan_attempt_key": "v3:9f82315867a70b80", + "replan_echo": "v3:9f82315867a70b80", + "attempted_plan_keys": [ + "v3:9f82315867a70b80" + ], + "expected_server_action": "reject_already_attempted_plan" + }, + { + "name": "direct_device_quirk_and_runtime_correction_identity", + "server_plan_attempt_key": "v3:32a3a37d71bc4f43", + "replan_echo": "v3:32a3a37d71bc4f43", + "attempted_plan_keys": [ + "v3:32a3a37d71bc4f43" + ], + "expected_server_action": "reject_already_attempted_plan" + } +] diff --git a/shared/src/commonTest/resources/playback/v3/capability_response.json b/shared/src/commonTest/resources/playback/v3/capability_response.json new file mode 100644 index 000000000..c66a81004 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/capability_response.json @@ -0,0 +1,50 @@ +{ + "enabled": true, + "protocol_versions": [ + 3 + ], + "features": [ + "playback_plan_v3", + "neutral_playback_v3_contract_v1", + "layout_aware_passthrough", + "playback_route_diagnostics", + "device_quirks_v1", + "seek_reanchor_v1", + "direct_stream_resume_v1", + "plan_source_duration_v1" + ], + "deliveries": [ + "original_http", + "server_remux_progressive", + "server_remux_hls", + "server_transcode_hls" + ], + "transformations": [ + { + "name": "audio_to_aac", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "audio_decode" + ] + }, + { + "name": "server_dv7_to_hdr10", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "dolby_vision_metadata_removed", + "hdr10_base_layer_preserved", + "enhancement_layer_discarded" + ] + }, + { + "name": "video_to_h264", + "executor": "server", + "recipe_version": "2", + "validated_claims": [ + "h264_decode" + ] + } + ] +} diff --git a/shared/src/commonTest/resources/playback/v3/conformance_matrix.json b/shared/src/commonTest/resources/playback/v3/conformance_matrix.json new file mode 100644 index 000000000..21decdee5 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/conformance_matrix.json @@ -0,0 +1,5481 @@ +{ + "schema_version": 1, + "planner_scenarios": [ + { + "name": "evidence_exact", + "category": "evidence_tier_gating", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-evidence-exact", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "server_transcode_hls", + "decision_reason": "quality_original", + "plan_id": "plan:7b3b4cdf37a1a1084395bc810c9ac179", + "plan_attempt_key": "v3:5f2c9f8566a979cc", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "server_audio_adaptation" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "transformations": [ + { + "name": "video_to_h264", + "executor": "server", + "recipe_version": "2", + "validated_claims": [ + "h264_decode" + ] + }, + { + "name": "audio_to_aac", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "audio_decode" + ] + } + ], + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "evidence_platform_attested", + "category": "evidence_tier_gating", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-evidence-platform_attested", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "platform_attested", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:af10638f68c79ddc3e228cb93259e4cc", + "plan_attempt_key": "v3:0c345038da67f548", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "evidence_declared", + "category": "evidence_tier_gating", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-evidence-declared", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:112292a396dba70825480f538c5e1ab3", + "plan_attempt_key": "v3:407f095ab06f0d06", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "delivery_original", + "category": "deliveries_negotiation", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-delivery-chain", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:11cf586f0837878a8bd7fb56aad2b114", + "plan_attempt_key": "v3:aea14da7eb994836", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "delivery_progressive", + "category": "deliveries_negotiation", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-delivery-chain", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "attempted_plan_keys": [ + "v3:aea14da7eb994836" + ], + "expected": { + "outcome": "playable", + "delivery": "server_remux_progressive", + "decision_reason": "container_normalization", + "plan_id": "plan:56abab9075fd926b4a2901fdeac7f7bb", + "plan_attempt_key": "v3:e02a401930e003f8", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "delivery_hls", + "category": "deliveries_negotiation", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-delivery-chain", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "attempted_plan_keys": [ + "v3:aea14da7eb994836", + "v3:e02a401930e003f8" + ], + "expected": { + "outcome": "playable", + "delivery": "server_remux_hls", + "decision_reason": "hls_packaging_required", + "plan_id": "plan:2dc0d6fc38a49b49d17ac9a60d8beba2", + "plan_attempt_key": "v3:464f01a4d6ecc0e8", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "delivery_transcode", + "category": "deliveries_negotiation", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-delivery-transcode", + "quality_preference": "720p", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "server_transcode_hls", + "decision_reason": "quality_fixed_rung", + "plan_id": "plan:36db80a93b9d295f44c2c3be6a7cb0ce", + "plan_attempt_key": "v3:aad5ccea009fea2a", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "server_audio_adaptation" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "transformations": [ + { + "name": "video_to_h264", + "executor": "server", + "recipe_version": "2", + "validated_claims": [ + "h264_decode" + ] + }, + { + "name": "audio_to_aac", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "audio_decode" + ] + } + ], + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "audio_only_original", + "category": "audio_only_planning", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 77, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-audio-only", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 77, + "duration_seconds": 39600, + "container": "mp4", + "bitrate_kbps": 128, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:603b26dbc39763a3f935467f56e6902a", + "plan_attempt_key": "v3:e6a9fa3fb77cea40", + "selected_tracks": { + "audio": { + "id": "file:77:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "bitrate_kbps": 128, + "preserves_source": true + } + ] + } + }, + { + "name": "hdr10_exact_direct", + "category": "hdr_dv_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-hdr10-direct", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "hdr10", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:cb1eb35f5bf87af3b5f07bd08aad0c5b", + "plan_attempt_key": "v3:dfd23f2418e283b5", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "dolby_vision_8_exact_direct", + "category": "hdr_dv_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-dv8-direct", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [ + 8 + ] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [ + 8 + ] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "dolby_vision", + "hdr10_plus": false, + "dolby_vision_profile": 8, + "dv_bl_compat_id": 1, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:dc16d317492afc93b5e082f54c3b5c34", + "plan_attempt_key": "v3:3ce8b4a367030abc", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": true, + "dolby_vision_reason": "native_profile_supported" + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "dolby_vision_7_hdr10_fallback", + "category": "hdr_dv_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-dv7-hdr10", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "dolby_vision", + "hdr10_plus": false, + "dolby_vision_profile": 7, + "dv_bl_compat_id": 6, + "dv_enhancement_layer": "unknown", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "server_remux_progressive", + "decision_reason": "container_normalization", + "plan_id": "plan:e88fbdcf1a40478d50f19e6a1c9d2e41", + "plan_attempt_key": "v3:e3784d73c47008f0", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "transformations": [ + { + "name": "server_dv7_to_hdr10", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "dolby_vision_metadata_removed", + "hdr10_base_layer_preserved", + "enhancement_layer_discarded" + ] + } + ], + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "truehd_audio_conversion", + "category": "audio_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-truehd-aac", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "hdr10", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "truehd", + "audio_channels": 8, + "audio_layout": "7.1" + }, + "expected": { + "outcome": "playable", + "delivery": "server_remux_progressive", + "decision_reason": "audio_adaptation", + "plan_id": "plan:cccf704a0487d09d54a95b51d095c474", + "plan_attempt_key": "v3:8a79df3157b88939", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "server_audio_adaptation" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "transformations": [ + { + "name": "audio_to_aac", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "audio_decode" + ] + } + ], + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "truehd_exact_layout_passthrough", + "category": "audio_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3", + "layout_aware_passthrough" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-truehd-passthrough", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "truehd" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "audio_passthrough": { + "passthrough_codecs": [ + "truehd" + ], + "spatializer_enabled": false, + "max_channels": 8, + "entries": [ + { + "codec": "truehd", + "channel_counts": [ + 8 + ], + "layouts": [ + "7.1" + ] + } + ] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [ + "truehd" + ], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "hdr10", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "truehd", + "audio_channels": 8, + "audio_layout": "7.1" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:eb0f4227bac497cc2f43f2a7b3a90318", + "plan_attempt_key": "v3:8630acbb4ae17a89", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "truehd", + "passthrough": true, + "atmos_preserved": false, + "reason": "sink_passthrough_validated" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "embedded_pgs_sidecar", + "category": "subtitle_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-pgs-sidecar", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "subtitle_track_id": "file:42:subtitle:0", + "subtitle_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": true, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": true, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": true, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "hdr10", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:797ce06d50c911460783eff5e16e678c", + "plan_attempt_key": "v3:b424954be87ffe90", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + }, + "subtitle": { + "id": "file:42:subtitle:0", + "index": 0 + } + }, + "subtitle": { + "mode": "render", + "track_id": "file:42:subtitle:0", + "inventory": [ + { + "track_id": "file:42:subtitle:0", + "combined_index": 0, + "source": "embedded", + "codec": "hdmv_pgs_subtitle", + "language": "jpn", + "label": "Japanese", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar" + } + ] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": true, + "reason": "client_bitmap_render_supported" + } + }, + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "embedded_ass_authored_render", + "category": "subtitle_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-ass-authored", + "quality_preference": "original", + "subtitle_fidelity_preference": "preserve", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "subtitle_track_id": "file:42:subtitle:0", + "subtitle_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "2160p", + "hdr": true, + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 153 + ], + "bit_depths": [ + 10 + ], + "max_width": 3840, + "max_height": 2160, + "max_frame_rate": 60, + "max_bitrate_kbps": 80000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "hdr_details": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision_profiles": [] + }, + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": false, + "ass_styling": true, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": true + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": false, + "ass_styling": true, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": true + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": false, + "ass_styling": true, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": true + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main 10", + "video_level": 153, + "bit_depth": 10, + "color_range": "tv", + "width": 3840, + "height": 2160, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 60000, + "dynamic_range": "hdr10", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:fb18d142b453e46d895b516db09f7c8a", + "plan_attempt_key": "v3:f50777d24ac9fe32", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + }, + "subtitle": { + "id": "file:42:subtitle:0", + "index": 0 + } + }, + "subtitle": { + "mode": "render", + "track_id": "file:42:subtitle:0", + "inventory": [ + { + "track_id": "file:42:subtitle:0", + "combined_index": 0, + "source": "embedded", + "codec": "ass", + "language": "eng", + "label": "English Signs", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar" + } + ] + }, + "claims": { + "video": { + "hdr10": true, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": true, + "bitmap_overlay": false, + "bitmap_sidecar": false, + "reason": "client_render_supported" + } + }, + "available_qualities": [ + { + "label": "original", + "height": 2160, + "bitrate_kbps": 60000, + "preserves_source": true + } + ] + } + }, + { + "name": "embedded_dvd_burn_in", + "category": "subtitle_matrix", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-dvd-burn-in", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "subtitle_track_id": "file:42:subtitle:0", + "subtitle_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "hevc" + ], + "codecs_video_hardware": [ + "hevc" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mkv" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "hevc", + "profiles": [ + "main 10" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mkv", + "video_codec": "hevc", + "video_profile": "main", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "server_transcode_hls", + "decision_reason": "subtitle_burn_in_required", + "plan_id": "plan:67e9593ed921a129645e41427a081943", + "plan_attempt_key": "v3:4d2de636abf938b1", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + }, + "subtitle": { + "id": "file:42:subtitle:0", + "index": 0 + } + }, + "subtitle": { + "mode": "burn_in", + "track_id": "file:42:subtitle:0", + "inventory": [ + { + "track_id": "file:42:subtitle:0", + "combined_index": 0, + "source": "embedded", + "codec": "dvd_subtitle", + "language": "eng", + "label": "English", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "burn_in_only" + } + ] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "server_audio_adaptation" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": true, + "bitmap_sidecar": false, + "reason": "server_burn_in_required" + } + }, + "transformations": [ + { + "name": "video_to_h264", + "executor": "server", + "recipe_version": "2", + "validated_claims": [ + "h264_decode" + ] + }, + { + "name": "audio_to_aac", + "executor": "server", + "recipe_version": "1", + "validated_claims": [ + "audio_decode" + ] + } + ], + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + }, + { + "name": "available_qualities", + "category": "available_qualities", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-available-qualities", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "declared", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "fixture" + }, + "output": { + "output_context_id": "output-a" + }, + "deliveries": { + "hls": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "hls" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mkv", + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + }, + "progressive": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "hevc", + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": false, + "sidecar_text": false, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": false, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "expected": { + "outcome": "playable", + "delivery": "original_http", + "decision_reason": "validated_original_playback", + "plan_id": "plan:4da88570903f3211bf0b1cfd5c4b534b", + "plan_attempt_key": "v3:b469cbb1ebe407fa", + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "subtitle": { + "mode": "off", + "inventory": [] + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + }, + { + "label": "720p", + "height": 720, + "bitrate_kbps": 2000, + "preserves_source": false + }, + { + "label": "480p", + "height": 480, + "bitrate_kbps": 1500, + "preserves_source": false + } + ] + } + } + ], + "replan_scenarios": [ + { + "name": "track_change", + "category": "track_change_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "track_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-track-change-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "", + "index": 1 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "http_status": 200, + "preserve_unmodified_tracks": true + } + }, + { + "name": "quality_change", + "category": "quality_change_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "quality_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-quality-change-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "720p", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "http_status": 200, + "selected_quality": "720p" + } + }, + { + "name": "track_change_idempotent_duplicate", + "category": "idempotent_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "track_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-track-duplicate-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "", + "index": 1 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "same_request_and_body_status": 200, + "response_replayed_verbatim": true, + "changed_body_status": 409, + "changed_body_error": "idempotency_key_reused" + } + }, + { + "name": "quality_change_idempotent_duplicate", + "category": "idempotent_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "quality_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-quality-duplicate-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "720p", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "same_request_and_body_status": 200, + "response_replayed_verbatim": true, + "changed_body_status": 409, + "changed_body_error": "idempotency_key_reused" + } + }, + { + "name": "track_change_concurrent_duplicate", + "category": "concurrent_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "track_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-track-concurrent-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "", + "index": 1 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "response_replayed_verbatim": true, + "while_first_lease_active_status": 409, + "concurrent_error": "replan_in_progress", + "after_completion_status": 200 + } + }, + { + "name": "quality_change_concurrent_duplicate", + "category": "concurrent_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "quality_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-quality-concurrent-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "720p", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "response_replayed_verbatim": true, + "while_first_lease_active_status": 409, + "concurrent_error": "replan_in_progress", + "after_completion_status": 200 + } + }, + { + "name": "track_change_mid_seek", + "category": "mid_seek_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "track_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-track-mid-seek-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "", + "index": 1 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "http_status": 200, + "position_seconds": 321.25, + "position_preserved": true + } + }, + { + "name": "quality_change_mid_seek", + "category": "mid_seek_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "quality_change", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-quality-mid-seek-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "720p", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "http_status": 200, + "position_seconds": 321.25, + "position_preserved": true + } + }, + { + "name": "mid_seek_reanchor", + "category": "mid_seek_replan", + "request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "seek_reanchor", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-seek-reanchor-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "expected": { + "http_status": 200, + "position_seconds": 321.25, + "position_preserved": true + } + } + ], + "protocol_scenarios": [ + { + "name": "legacy_start_requires_upgrade", + "category": "legacy_426", + "input": { + "body": { + "file_id": 42 + } + }, + "expected": { + "http_status": 426, + "error": "client_upgrade_required" + } + }, + { + "name": "draft_v3_start_requires_upgrade", + "category": "draft_v3_426", + "input": { + "body": { + "protocol_version": 3, + "file_id": 42, + "client_capabilities": { + "codecs_video": [ + "h264" + ] + } + } + }, + "expected": { + "http_status": 426, + "error": "client_upgrade_required" + } + }, + { + "name": "output_context_change_invalidates_attempt", + "category": "output_context_invalidation", + "input": { + "plan_id": "plan:478677870860e5e5108c18bff749b34b", + "first_output_context_id": "output-a", + "second_output_context_id": "output-b", + "first_plan_attempt_key": "v3:28cb8a408ea7f446", + "second_plan_attempt_key": "v3:28c88c408ea5c1d5" + }, + "expected": { + "plan_id_unchanged": true, + "plan_attempt_key_changed": true + } + }, + { + "name": "opaque_attempt_key_loop", + "category": "attempt_key_echo_and_loop", + "input": { + "server_plan_attempt_key": "v3:f0144c47fa349e3e", + "replan_echo": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ] + }, + "expected": { + "action": "reject_already_attempted_plan" + } + }, + { + "name": "failure_recovery_preserves_intent", + "category": "recovery_matrix", + "input": { + "replan_request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "failure_recovery", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-failure-matrix-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 321.25, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + }, + "subtitle": { + "id": "file:42:subtitle:2", + "index": 2 + } + }, + "failure": { + "classification": "network_degraded" + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + } + }, + "expected": { + "http_status": 200, + "selection_preserved": true, + "position_preserved": true, + "action": "preserve_selected_tracks_and_position" + } + }, + { + "name": "restart_replays_terminal_attempt", + "category": "restart_matrix", + "input": { + "start_request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-restart-terminal", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "persisted_decision": { + "protocol_version": 3, + "server_features": [ + "playback_plan_v3", + "neutral_playback_v3_contract_v1", + "layout_aware_passthrough", + "playback_route_diagnostics", + "device_quirks_v1", + "seek_reanchor_v1", + "direct_stream_resume_v1", + "plan_source_duration_v1" + ], + "outcome": "adaptation_unavailable", + "terminal": { + "reason": "transcode_start_failed", + "message": "The playback transport did not become ready in time.", + "retryable": true + } + }, + "restarted": true + }, + "expected": { + "http_status": 201, + "outcome": "adaptation_unavailable", + "terminal_reason": "transcode_start_failed", + "response_replayed_verbatim": true, + "capacity_delta": 0 + } + }, + { + "name": "capacity_unavailable_cleans_up", + "category": "capacity_matrix", + "input": { + "start_request": { + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-capacity-unavailable", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } + }, + "capacity_available": false + }, + "expected": { + "http_status": 201, + "outcome": "adaptation_unavailable", + "terminal_reason": "capacity_unavailable", + "capacity_delta": 0, + "cleanup_complete": true + } + }, + { + "name": "route_event_diagnostic_limit", + "category": "route_event_limits", + "input": { + "route_event": { + "protocol_version": 3, + "playback_attempt_id": "attempt-route-limit", + "session_id": "11111111-1111-4111-8111-111111111111", + "plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "event": "first_frame", + "output_context_id": "7", + "diagnostics": { + "diagnostic_00": "value", + "diagnostic_01": "value", + "diagnostic_02": "value", + "diagnostic_03": "value", + "diagnostic_04": "value", + "diagnostic_05": "value", + "diagnostic_06": "value", + "diagnostic_07": "value", + "diagnostic_08": "value", + "diagnostic_09": "value", + "diagnostic_10": "value", + "diagnostic_11": "value", + "diagnostic_12": "value", + "diagnostic_13": "value", + "diagnostic_14": "value", + "diagnostic_15": "value", + "diagnostic_16": "value", + "diagnostic_17": "value", + "diagnostic_18": "value", + "diagnostic_19": "value", + "diagnostic_20": "value", + "diagnostic_21": "value", + "diagnostic_22": "value", + "diagnostic_23": "value", + "diagnostic_24": "value", + "diagnostic_25": "value", + "diagnostic_26": "value", + "diagnostic_27": "value", + "diagnostic_28": "value", + "diagnostic_29": "value", + "diagnostic_30": "value", + "diagnostic_31": "value", + "diagnostic_32": "value" + } + } + }, + "expected": { + "http_status": 400, + "error": "bad_request", + "action": "reject_without_persisting" + } + } + ] +} diff --git a/shared/src/commonTest/resources/playback/v3/decision_response.json b/shared/src/commonTest/resources/playback/v3/decision_response.json new file mode 100644 index 000000000..d7345acf9 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/decision_response.json @@ -0,0 +1,180 @@ +{ + "protocol_version": 3, + "server_features": [ + "playback_plan_v3", + "neutral_playback_v3_contract_v1", + "layout_aware_passthrough", + "playback_route_diagnostics", + "device_quirks_v1", + "seek_reanchor_v1", + "direct_stream_resume_v1", + "plan_source_duration_v1" + ], + "outcome": "playable", + "session_id": "11111111-1111-4111-8111-111111111111", + "playback_plan": { + "protocol_version": 3, + "plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "session_id": "11111111-1111-4111-8111-111111111111", + "expires_at": "2030-01-01T00:00:00Z", + "delivery": "original_http", + "stream": { + "url": "/stream/11111111-1111-4111-8111-111111111111", + "protocol": "http_progressive", + "container": "mp4", + "mime_type": "video/mp4", + "headers": {}, + "header_refresh": "none" + }, + "timeline": { + "source_start_seconds": 12.5, + "stream_origin_seconds": 0, + "player_start_seconds": 12.5, + "timeline_offset_seconds": 0, + "can_seek_anywhere": true, + "seek_restoration": "player_position" + }, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "effective_recipe": { + "video_codec": "h264", + "audio_codec": "aac", + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "claims": { + "video": { + "hdr10": false, + "hdr10_plus": false, + "hlg": false, + "dolby_vision": false + }, + "audio": { + "codec": "aac", + "passthrough": false, + "atmos_preserved": false, + "reason": "client_decode_supported" + }, + "subtitles": { + "ass_styling_preserved": false, + "bitmap_overlay": false, + "bitmap_sidecar": false + } + }, + "subtitle": { + "mode": "off", + "inventory": [ + { + "track_id": "file:42:subtitle:0", + "combined_index": 0, + "source": "external", + "codec": "srt", + "language": "eng", + "label": "English", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/0.vtt?file_id=42" + }, + { + "track_id": "file:42:subtitle:1", + "combined_index": 1, + "source": "embedded", + "codec": "ass", + "language": "eng", + "label": "English (Signs)", + "forced": true, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1.ass?file_id=42", + "font_bundle_url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1/fonts?file_id=42" + }, + { + "track_id": "file:42:subtitle:2", + "combined_index": 2, + "source": "embedded", + "codec": "pgs", + "language": "jpn", + "label": "Japanese", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/2.sup?file_id=42" + }, + { + "track_id": "file:42:subtitle:3", + "combined_index": 3, + "source": "embedded", + "codec": "dvd_subtitle", + "language": "fre", + "label": "French", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "burn_in_only" + }, + { + "track_id": "file:42:subtitle:4", + "combined_index": 4, + "source": "downloaded", + "codec": "srt", + "language": "spa", + "label": "Spanish (downloaded)", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/4.vtt?file_id=42" + } + ] + }, + "transformations": [], + "applied_quirks": [], + "runtime_corrections": [], + "available_qualities": [ + { + "label": "original", + "height": 1080, + "bitrate_kbps": 8000, + "preserves_source": true + } + ], + "degradation_warnings": [], + "decision_reason": "validated_original_playback", + "requested_media_file_id": 42, + "effective_media_file_id": 42, + "source": { + "media_file_id": 42, + "duration_seconds": 7200, + "container": "mp4", + "video_codec": "h264", + "video_profile": "high", + "video_level": 41, + "bit_depth": 8, + "width": 1920, + "height": 1080, + "frame_rate": 23.976023976023978, + "bitrate_kbps": 8000, + "dynamic_range": "sdr", + "hdr10_plus": false, + "dv_enhancement_layer": "none", + "audio_codec": "aac", + "audio_channels": 2, + "audio_layout": "stereo" + }, + "subtitle_fidelity_policy": "allow_simplified_rendering" + } +} diff --git a/shared/src/commonTest/resources/playback/v3/error_response.json b/shared/src/commonTest/resources/playback/v3/error_response.json new file mode 100644 index 000000000..04223e04b --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/error_response.json @@ -0,0 +1,4 @@ +{ + "error": "client_upgrade_required", + "message": "This server requires playback protocol v3. Update the app to continue." +} diff --git a/shared/src/commonTest/resources/playback/v3/replan_request.json b/shared/src/commonTest/resources/playback/v3/replan_request.json new file mode 100644 index 000000000..9df1887a9 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/replan_request.json @@ -0,0 +1,115 @@ +{ + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "operation": "failure_recovery", + "playback_attempt_id": "attempt-golden-0001", + "replan_request_id": "replan-golden-0001", + "failed_plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "attempted_plan_keys": [ + "v3:f0144c47fa349e3e" + ], + "attempt_count": 1, + "quality_preference": "auto", + "position_seconds": 42.5, + "metered": true, + "bandwidth_estimate_kbps": 3500, + "bandwidth_cap_kbps": 4000, + "selected_tracks": { + "audio": { + "id": "file:42:audio:0", + "index": 0 + } + }, + "failure": { + "classification": "network_degraded" + }, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "app_build": "5", + "app_channel": "production", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } +} diff --git a/shared/src/commonTest/resources/playback/v3/route_event.json b/shared/src/commonTest/resources/playback/v3/route_event.json new file mode 100644 index 000000000..d99e1d18e --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/route_event.json @@ -0,0 +1,15 @@ +{ + "protocol_version": 3, + "playback_attempt_id": "attempt-golden-0001", + "session_id": "11111111-1111-4111-8111-111111111111", + "plan_id": "plan:478677870860e5e5108c18bff749b34b", + "plan_attempt_id": "plan-attempt-golden-0001", + "plan_attempt_key": "v3:f0144c47fa349e3e", + "event": "first_frame", + "output_context_id": "7", + "diagnostics": { + "decoder_name": "c2.android.avc.decoder", + "first_frame_ms": "412", + "video_mime": "video/avc" + } +} diff --git a/shared/src/commonTest/resources/playback/v3/start_request.json b/shared/src/commonTest/resources/playback/v3/start_request.json new file mode 100644 index 000000000..c73c4a489 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/start_request.json @@ -0,0 +1,101 @@ +{ + "protocol_version": 3, + "client_features": [ + "playback_plan_v3" + ], + "file_id": 42, + "profile_id": "profile-1", + "playback_attempt_id": "attempt-golden-0001", + "quality_preference": "original", + "subtitle_fidelity_preference": "compatible", + "start_position": 12.5, + "progress_persistence": "client", + "audio_track_id": "file:42:audio:0", + "audio_track_index": 0, + "metered": false, + "client_capabilities": { + "video_evidence": "exact", + "audio_evidence": "exact", + "codecs_video": [ + "h264" + ], + "codecs_video_hardware": [ + "h264" + ], + "codecs_audio": [ + "aac" + ], + "containers": [ + "mp4" + ], + "max_resolution": "1080p", + "hdr": false, + "video_decode": [ + { + "codec": "h264", + "profiles": [ + "high" + ], + "levels": [ + 41 + ], + "bit_depths": [ + 8 + ], + "max_width": 1920, + "max_height": 1080, + "max_frame_rate": 60, + "max_bitrate_kbps": 20000, + "hardware": true + } + ] + }, + "client_playback_context": { + "protocol_version": 3, + "form_factor": "tv", + "app_version": "3.0-test", + "app_build": "5", + "app_channel": "production", + "device": { + "platform": "android", + "os_version": "15", + "manufacturer": "NVIDIA", + "model": "SHIELD Android TV", + "platform_details": { + "abis": "arm64-v8a", + "sdk_int": "35" + } + }, + "output": { + "output_context_id": "7" + }, + "deliveries": { + "original_http": { + "enabled": true, + "supported_on_device": true, + "containers": [ + "mp4" + ], + "video_codecs": [ + "h264" + ], + "audio_decode_codecs": [ + "aac" + ], + "audio_passthrough_codecs": [], + "subtitles": { + "embedded_text": true, + "sidecar_text": true, + "ass_styling": false, + "embedded_bitmap": false, + "sidecar_bitmap": false, + "font_attachments": false + }, + "features": [], + "auth_header_refresh": true, + "validated_claims": [], + "transformations": [] + } + } + } +} diff --git a/shared/src/commonTest/resources/playback/v3/subtitle_inventory.json b/shared/src/commonTest/resources/playback/v3/subtitle_inventory.json new file mode 100644 index 000000000..477d27948 --- /dev/null +++ b/shared/src/commonTest/resources/playback/v3/subtitle_inventory.json @@ -0,0 +1,128 @@ +{ + "description": "Combined subtitle ordinals are dense and gap-free across externals, embedded tracks, then downloaded tracks. A track with no sidecar representation keeps its ordinal and is published as burn_in_only without a URL rather than omitted.", + "session_id": "11111111-1111-4111-8111-111111111111", + "media_file_id": 42, + "source": { + "external_subtitles": [ + { + "path": "/library/movie.en.srt", + "language": "eng", + "format": "srt", + "title": "English", + "forced": false, + "default": false, + "hearing_impaired": false + } + ], + "subtitle_tracks": [ + { + "index": 0, + "language": "eng", + "codec": "ass", + "title": "English (Signs)", + "forced": true, + "default": false, + "hearing_impaired": false, + "external": false + }, + { + "index": 1, + "language": "jpn", + "codec": "pgs", + "title": "Japanese", + "forced": false, + "default": false, + "hearing_impaired": false, + "external": false + }, + { + "index": 2, + "language": "fre", + "codec": "dvd_subtitle", + "title": "French", + "forced": false, + "default": false, + "hearing_impaired": false, + "external": false + } + ], + "downloaded": [ + { + "CombinedIndex": 0, + "Codec": "srt", + "Source": "downloaded", + "Language": "spa", + "Label": "Spanish (downloaded)", + "Forced": false, + "HearingImpaired": false + } + ] + }, + "inventory": [ + { + "track_id": "file:42:subtitle:0", + "combined_index": 0, + "source": "external", + "codec": "srt", + "language": "eng", + "label": "English", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/0.vtt?file_id=42" + }, + { + "track_id": "file:42:subtitle:1", + "combined_index": 1, + "source": "embedded", + "codec": "ass", + "language": "eng", + "label": "English (Signs)", + "forced": true, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1.ass?file_id=42", + "font_bundle_url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/1/fonts?file_id=42" + }, + { + "track_id": "file:42:subtitle:2", + "combined_index": 2, + "source": "embedded", + "codec": "pgs", + "language": "jpn", + "label": "Japanese", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/2.sup?file_id=42" + }, + { + "track_id": "file:42:subtitle:3", + "combined_index": 3, + "source": "embedded", + "codec": "dvd_subtitle", + "language": "fre", + "label": "French", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "burn_in_only" + }, + { + "track_id": "file:42:subtitle:4", + "combined_index": 4, + "source": "downloaded", + "codec": "srt", + "language": "spa", + "label": "Spanish (downloaded)", + "forced": false, + "default": false, + "hearing_impaired": false, + "delivery": "sidecar", + "url": "/stream/11111111-1111-4111-8111-111111111111/subtitles/4.vtt?file_id=42" + } + ] +} diff --git a/shared/src/commonTest/resources/settings/v1/SOURCE b/shared/src/commonTest/resources/settings/v1/SOURCE new file mode 100644 index 000000000..8bba1e6bf --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/SOURCE @@ -0,0 +1,25 @@ +repository=https://github.com/Silo-Server/silo-server +path=contracts/settings/v1 +manifest_revision=7 +fixture_version=1 + +Both files are byte-identical copies of the server's canonical contract; do not +hand-edit either one. Re-vendor by copying them again and updating the commits +below, then run :shared:testDebugUnitTest — SettingsConformanceTest fails when +the fixture's manifest_revision, the vendored manifest's revision, and the +generated SettingKeys.REVISION stop agreeing, which is the whole point of +carrying the pair rather than the fixture alone. + +conformance.json commit=2c5e7e6d683250b122e327e5c2f66724c16aba34 +manifest.json commit=2c5e7e6d683250b122e327e5c2f66724c16aba34 +copied from commit=2c5e7e6d683250b122e327e5c2f66724c16aba34 + +manifest.json is vendored whole, including the maintainer `notes` the server +strips before serving /api/v1/settings/contract. Keeping it byte-identical is +what lets a re-vendor be verified with a plain diff against the server repo. + +The runner reads the manifest for the facts the generated bindings in +shared/src/commonMain/.../SettingKeys.kt do not carry: resolution_order, +default_value, value_schema.ordered plus its enum member order, and +constrained_by. Those are what the fixture's expectations actually test, so the +resolver is driven by the contract rather than by a hand-copied table. diff --git a/shared/src/commonTest/resources/settings/v1/conformance.json b/shared/src/commonTest/resources/settings/v1/conformance.json new file mode 100644 index 000000000..63fb19d7b --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/conformance.json @@ -0,0 +1,738 @@ +{ + "fixture_version": 1, + "manifest_revision": 7, + "description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.", + "cases": [ + { + "name": "profile_client_sits_between_device_and_profile", + "description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.", + "keys": ["ui.card_presentation"], + "context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" }, + "stored": [ + { + "key": "ui.card_presentation", + "scope": "profile", + "profile_id": "p1", + "value": { "poster_size": "standard", "caption": "title" } + }, + { + "key": "ui.card_presentation", + "scope": "profile_client", + "profile_id": "p1", + "client_family": "tv", + "value": { "poster_size": "large", "caption": "artwork" } + }, + { + "key": "ui.card_presentation", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "living-room", + "value": { "poster_size": "compact", "caption": "title_metadata" } + } + ], + "expected": [ + { + "key": "ui.card_presentation", + "value": { "poster_size": "compact", "caption": "title_metadata" }, + "source": "profile_device" + } + ] + }, + { + "name": "profile_client_roams_only_within_its_family", + "description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.", + "keys": ["nav.primary_menu"], + "context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" }, + "stored": [ + { + "key": "nav.primary_menu", + "scope": "profile_client", + "profile_id": "p1", + "client_family": "mobile", + "value": { "items": [{ "type": "builtin", "destination": "home" }] } + }, + { + "key": "nav.primary_menu", + "scope": "profile_client", + "profile_id": "p1", + "client_family": "tv", + "value": { + "items": [ + { "type": "builtin", "destination": "home" }, + { "type": "builtin", "destination": "movies" } + ] + } + } + ], + "expected": [ + { + "key": "nav.primary_menu", + "value": { + "items": [ + { "type": "builtin", "destination": "home" }, + { "type": "builtin", "destination": "movies" } + ] + }, + "source": "profile_client" + } + ] + }, + { + "name": "resolution_order_series_wins", + "description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_library", + "profile_id": "p1", + "library_id": 7, + "value": "fr" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_series", + "profile_id": "p1", + "series_id": "s-101", + "value": "ja" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "ja", "source": "profile_series" } + ] + }, + { + "name": "resolution_order_library_beats_device", + "description": "Without a series row, the library row wins over the device and profile rows.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_library", + "profile_id": "p1", + "library_id": 7, + "value": "fr" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "fr", "source": "profile_library" } + ] + }, + { + "name": "resolution_order_device_beats_profile", + "description": "Without content rows, the device override wins over the profile fallback even though the context names a library and a series.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + } + ], + "expected": [ + { "key": "playback.subtitle_language", "value": "de", "source": "profile_device" } + ] + }, + { + "name": "resolution_order_profile_alone", + "description": "A profile row alone resolves at profile scope.", + "keys": ["playback.subtitle_language"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" } + ], + "expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }] + }, + { + "name": "device_override_beats_profile_for_quality", + "description": "playback.preferred_quality has no content scopes; its device override wins over the profile value.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "720p" + }, + { + "key": "playback.preferred_quality", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "1080p" + } + ], + "expected": [ + { "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" } + ] + }, + { + "name": "missing_device_identity_drops_device_scope", + "description": "A caller with no device identity must not see a device override; the profile row answers instead. This is the anonymous jellycompat seed: a device row leaking here hands one device's settings to every client.", + "keys": ["playback.subtitle_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p1", "value": "en" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "de" + } + ], + "expected": [{ "key": "playback.subtitle_language", "value": "en", "source": "profile" }] + }, + { + "name": "foreign_identity_rows_never_resolve", + "description": "Rows for another profile, another device, or another series must not resolve just because a batched read returned them; the answer falls to the contract default.", + "keys": ["playback.subtitle_language"], + "context": { "profile_id": "p1", "device_id": "d1", "series_ids": ["s-101"] }, + "stored": [ + { "key": "playback.subtitle_language", "scope": "profile", "profile_id": "p2", "value": "xx" }, + { + "key": "playback.subtitle_language", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d2", + "value": "yy" + }, + { + "key": "playback.subtitle_language", + "scope": "profile_series", + "profile_id": "p1", + "series_id": "s-other", + "value": "zz" + } + ], + "expected": [{ "key": "playback.subtitle_language", "value": null, "source": "default" }] + }, + { + "name": "absent_values_resolve_to_contract_defaults", + "description": "Nothing stored resolves to each definition's default_value with source \"default\": enum, boolean, integer, and nullable language tag.", + "keys": [ + "playback.subtitle_mode", + "playback.show_forced_subtitles", + "playback.next_up_prompt_seconds", + "playback.audio_language" + ], + "context": { "profile_id": "p1", "device_id": "d1" }, + "expected": [ + { "key": "playback.subtitle_mode", "value": "auto", "source": "default" }, + { "key": "playback.show_forced_subtitles", "value": true, "source": "default" }, + { "key": "playback.next_up_prompt_seconds", "value": 30, "source": "default" }, + { "key": "playback.audio_language", "value": null, "source": "default" } + ] + }, + { + "name": "batch_resolves_each_key_independently", + "description": "One batch, three keys, three different sources: a device override, a profile value, and a default.", + "keys": [ + "playback.preferred_quality", + "playback.subtitle_mode", + "playback.audio_language" + ], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "1080p" + }, + { "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "always" } + ], + "expected": [ + { "key": "playback.preferred_quality", "value": "1080p", "source": "profile_device" }, + { "key": "playback.subtitle_mode", "value": "always", "source": "profile" }, + { "key": "playback.audio_language", "value": null, "source": "default" } + ] + }, + { + "name": "ceiling_caps_stored_quality_and_reports_the_stored_value", + "description": "The manifest binds playback.preferred_quality to the max_playback_quality ceiling. A stored 2160p over a 1080p cap resolves to 1080p while the authored value survives, reported as stored_value with constrained:true.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "2160p" + } + ], + "constraints": { "max_playback_quality": "1080p" }, + "expected": [ + { + "key": "playback.preferred_quality", + "value": "1080p", + "source": "profile", + "constrained": true, + "stored_value": "2160p", + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "ceiling_leaves_quality_under_the_cap_alone", + "description": "A value at or under the cap passes through untouched and is not reported as constrained.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "720p" + } + ], + "constraints": { "max_playback_quality": "1080p" }, + "expected": [{ "key": "playback.preferred_quality", "value": "720p", "source": "profile" }] + }, + { + "name": "ceiling_ranks_auto_below_every_cap", + "description": "The ordered enum lists \"auto\" first because it never exceeds a cap: even the lowest cap leaves it alone.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "auto" + } + ], + "constraints": { "max_playback_quality": "480p" }, + "expected": [{ "key": "playback.preferred_quality", "value": "auto", "source": "profile" }] + }, + { + "name": "ceiling_caps_original_as_the_highest_member", + "description": "\"original\" is the uncapped source and ranks above every resolution, so any cap brings it down.", + "keys": ["playback.preferred_quality"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.preferred_quality", + "scope": "profile", + "profile_id": "p1", + "value": "original" + } + ], + "constraints": { "max_playback_quality": "2160p" }, + "expected": [ + { + "key": "playback.preferred_quality", + "value": "2160p", + "source": "profile", + "constrained": true, + "stored_value": "original", + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "null_bitrate_is_unbounded_and_a_ceiling_caps_it", + "description": "null on the nullable integer playback.max_bitrate_kbps means \"no cap of my own\", which is unbounded above. It has no numeric rank, so a resolver that compares it as equal lets the one value that most needs capping slip past; a ceiling must bring it down to the limit.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null } + ], + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "max_bitrate_kbps", + "constraint": "ceiling" + } + ], + "constraints": { "max_bitrate_kbps": 8000 }, + "expected": [ + { + "key": "playback.max_bitrate_kbps", + "value": 8000, + "source": "profile", + "constrained": true, + "stored_value": null, + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "default_null_bitrate_is_capped_by_a_ceiling", + "description": "The contract default for playback.max_bitrate_kbps is null, so even with nothing stored a ceiling caps the resolved default; source stays \"default\" and the null is reported as stored_value.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "max_bitrate_kbps", + "constraint": "ceiling" + } + ], + "constraints": { "max_bitrate_kbps": 8000 }, + "expected": [ + { + "key": "playback.max_bitrate_kbps", + "value": 8000, + "source": "default", + "constrained": true, + "stored_value": null, + "constraint_kind": "ceiling" + } + ] + }, + { + "name": "floor_leaves_an_unbounded_bitrate_alone", + "description": "The mirror rule: unbounded already satisfies any floor, so a floor must not touch a null numeric.", + "keys": ["playback.max_bitrate_kbps"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.max_bitrate_kbps", "scope": "profile", "profile_id": "p1", "value": null } + ], + "constraint_bindings": [ + { + "key": "playback.max_bitrate_kbps", + "policy_input": "min_bitrate_kbps", + "constraint": "floor" + } + ], + "constraints": { "min_bitrate_kbps": 8000 }, + "expected": [{ "key": "playback.max_bitrate_kbps", "value": null, "source": "profile" }] + }, + { + "name": "allowlist_falls_back_when_the_default_is_outside_the_list", + "description": "With nothing stored, catalog.metadata_language resolves to its default null, which is outside the allowlist. The fallback is the first allowed member — not the definition default, which is exactly the value the policy forbids.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [ + { + "key": "catalog.metadata_language", + "value": "en", + "source": "default", + "constrained": true, + "stored_value": null, + "constraint_kind": "allowlist" + } + ] + }, + { + "name": "allowlist_replaces_a_forbidden_choice", + "description": "A stored value outside the allowlist is replaced by the first allowed member, with the authored choice preserved as stored_value.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "ja" } + ], + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [ + { + "key": "catalog.metadata_language", + "value": "en", + "source": "profile", + "constrained": true, + "stored_value": "ja", + "constraint_kind": "allowlist" + } + ] + }, + { + "name": "allowlist_passes_a_permitted_choice", + "description": "A stored value inside the allowlist passes through untouched.", + "keys": ["catalog.metadata_language"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "catalog.metadata_language", "scope": "profile", "profile_id": "p1", "value": "fr" } + ], + "constraint_bindings": [ + { + "key": "catalog.metadata_language", + "policy_input": "allowed_metadata_languages", + "constraint": "allowlist" + } + ], + "constraints": { "allowed_metadata_languages": ["en", "fr"] }, + "expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }] + }, + { + "name": "metadata_language_exceptions_resolve_as_one_object", + "description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.", + "keys": ["catalog.metadata_language_overrides"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "catalog.metadata_language_overrides", + "scope": "profile", + "profile_id": "p1", + "value": { "ja": "en", "no": "x-silo-original" } + } + ], + "expected": [ + { + "key": "catalog.metadata_language_overrides", + "value": { "ja": "en", "no": "x-silo-original" }, + "source": "profile" + } + ] + }, + { + "name": "locked_replaces_a_differing_choice", + "description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "stored": [ + { "key": "playback.subtitle_mode", "scope": "profile", "profile_id": "p1", "value": "off" } + ], + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [ + { + "key": "playback.subtitle_mode", + "value": "always", + "source": "profile", + "constrained": true, + "stored_value": "off", + "constraint_kind": "locked" + } + ] + }, + { + "name": "locked_leaves_an_equal_value_unconstrained", + "description": "A stored value already equal to the lock is not a narrowing: it passes through with no constrained flag, so clients do not tell the user their own choice was overridden.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.subtitle_mode", + "scope": "profile", + "profile_id": "p1", + "value": "always" + } + ], + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [{ "key": "playback.subtitle_mode", "value": "always", "source": "profile" }] + }, + { + "name": "locked_replaces_the_contract_default", + "description": "With nothing stored, the lock replaces even the contract default: source stays \"default\" and the default is reported as stored_value, exactly like a capped default.", + "keys": ["playback.subtitle_mode"], + "context": { "profile_id": "p1" }, + "constraint_bindings": [ + { + "key": "playback.subtitle_mode", + "policy_input": "forced_subtitle_mode", + "constraint": "locked" + } + ], + "constraints": { "forced_subtitle_mode": "always" }, + "expected": [ + { + "key": "playback.subtitle_mode", + "value": "always", + "source": "default", + "constrained": true, + "stored_value": "auto", + "constraint_kind": "locked" + } + ] + }, + { + "name": "subtitle_appearance_ignores_content_scopes", + "description": "playback.subtitle_appearance resolves profile_device then profile only. With a library and a series in the context, the device row still wins — and the sparse device object replaces the profile object outright rather than merging with it.", + "keys": ["playback.subtitle_appearance"], + "context": { + "profile_id": "p1", + "device_id": "d1", + "library_ids": [7], + "series_ids": ["s-101"] + }, + "stored": [ + { + "key": "playback.subtitle_appearance", + "scope": "profile", + "profile_id": "p1", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" } + }, + { + "key": "playback.subtitle_appearance", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": { "fontSize": "xxlarge", "position": "top" } + } + ], + "expected": [ + { + "key": "playback.subtitle_appearance", + "value": { "fontSize": "xxlarge", "position": "top" }, + "source": "profile_device" + } + ] + }, + { + "name": "subtitle_appearance_falls_to_profile_without_device", + "description": "Without a device identity the profile's appearance object answers, unmerged.", + "keys": ["playback.subtitle_appearance"], + "context": { "profile_id": "p1" }, + "stored": [ + { + "key": "playback.subtitle_appearance", + "scope": "profile", + "profile_id": "p1", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" } + }, + { + "key": "playback.subtitle_appearance", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": { "fontSize": "xxlarge", "position": "top" } + } + ], + "expected": [ + { + "key": "playback.subtitle_appearance", + "value": { "fontSize": "medium", "fontColor": "#ffcc00" }, + "source": "profile" + } + ] + }, + { + "name": "intro_skip_mode_defaults_to_ask", + "description": "A profile that has never chosen an intro behaviour gets the contract default, which is the same prompt the old auto_skip_intro=false produced.", + "keys": ["playback.intro_skip_mode"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [], + "expected": [ + { + "key": "playback.intro_skip_mode", + "value": "ask", + "source": "default" + } + ] + }, + { + "name": "intro_skip_mode_device_override_beats_profile", + "description": "A living-room television set to skip intros automatically does not change the profile-wide choice to leave them alone.", + "keys": ["playback.intro_skip_mode"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [ + { + "key": "playback.intro_skip_mode", + "scope": "profile", + "profile_id": "p1", + "value": "never" + }, + { + "key": "playback.intro_skip_mode", + "scope": "profile_device", + "profile_id": "p1", + "device_id": "d1", + "value": "always" + } + ], + "expected": [ + { + "key": "playback.intro_skip_mode", + "value": "always", + "source": "profile_device" + } + ] + }, + { + "name": "subtitle_appearance_uses_shared_default", + "description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.", + "keys": ["playback.subtitle_appearance"], + "context": { "profile_id": "p1", "device_id": "d1" }, + "stored": [], + "expected": [ + { + "key": "playback.subtitle_appearance", + "value": { + "fontSize": "large", + "fontFamily": "sans-serif", + "fontColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundStyle": "box", + "backgroundOpacity": 75, + "textOutline": false, + "textOutlineColor": "#000000", + "position": "bottom" + }, + "source": "default" + } + ] + } + ] +} diff --git a/shared/src/commonTest/resources/settings/v1/manifest.json b/shared/src/commonTest/resources/settings/v1/manifest.json new file mode 100644 index 000000000..b604555a8 --- /dev/null +++ b/shared/src/commonTest/resources/settings/v1/manifest.json @@ -0,0 +1,1092 @@ +{ + "api_version": 1, + "revision": 7, + "option_sets": { + "playback_audio_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "playback_subtitle_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + }, + "catalog_metadata_languages": { + "type": "language_tag", + "options": [ + { "value": "ar", "introduced_in": 1 }, + { "value": "bn", "introduced_in": 1 }, + { "value": "bg", "introduced_in": 1 }, + { "value": "zh", "introduced_in": 1 }, + { "value": "hr", "introduced_in": 1 }, + { "value": "cs", "introduced_in": 1 }, + { "value": "da", "introduced_in": 1 }, + { "value": "nl", "introduced_in": 1 }, + { "value": "en", "introduced_in": 1 }, + { "value": "fi", "introduced_in": 1 }, + { "value": "fr", "introduced_in": 1 }, + { "value": "de", "introduced_in": 1 }, + { "value": "el", "introduced_in": 1 }, + { "value": "he", "introduced_in": 1 }, + { "value": "hi", "introduced_in": 1 }, + { "value": "hu", "introduced_in": 1 }, + { "value": "id", "introduced_in": 1 }, + { "value": "it", "introduced_in": 1 }, + { "value": "ja", "introduced_in": 1 }, + { "value": "ko", "introduced_in": 1 }, + { "value": "ms", "introduced_in": 1 }, + { "value": "no", "introduced_in": 1 }, + { "value": "fa", "introduced_in": 1 }, + { "value": "pl", "introduced_in": 1 }, + { "value": "pt", "introduced_in": 1 }, + { "value": "ro", "introduced_in": 1 }, + { "value": "ru", "introduced_in": 1 }, + { "value": "sk", "introduced_in": 1 }, + { "value": "sl", "introduced_in": 1 }, + { "value": "es", "introduced_in": 1 }, + { "value": "sv", "introduced_in": 1 }, + { "value": "ta", "introduced_in": 1 }, + { "value": "te", "introduced_in": 1 }, + { "value": "th", "introduced_in": 1 }, + { "value": "tr", "introduced_in": 1 }, + { "value": "uk", "introduced_in": 1 }, + { "value": "vi", "introduced_in": 1 } + ] + } + }, + "definitions": [ + { + "key": "playback.audio_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "playback", + "label": "Preferred audio language", + "description": "Choose which spoken language Silo should prefer first.", + "recommended_control": "select", + "suggested_options": "playback_audio_languages", + "unset_label": "No preference", + "notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it." + }, + { + "key": "playback.subtitle_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "playback", + "label": "Preferred subtitle language", + "description": "Choose which subtitle language Silo should prefer first.", + "recommended_control": "select", + "suggested_options": "playback_subtitle_languages", + "unset_label": "None", + "notes": "Migrates user_profiles.subtitle_language, LibraryPlaybackPreference.subtitle_language, and SubtitlePreference.subtitle_language. SubtitlePreference.subtitle_track_index, external_subtitle_path, and track_signature stay specialized." + }, + { + "key": "playback.subtitle_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Auto" }, + { "value": "always", "label": "Always on" }, + { "value": "off", "label": "Off" } + ] + }, + "default_value": "auto", + "category": "playback", + "label": "Subtitles", + "description": "When Silo should turn subtitles on.", + "recommended_control": "select", + "notes": "The legacy empty string means unset, not a fourth mode. Migration maps \"\" to no stored row so it resolves to the next scope." + }, + { + "key": "playback.show_forced_subtitles", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"], + "resolution_order": [ + "profile_series", + "profile_library", + "profile_device", + "profile", + "default" + ], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "playback", + "label": "Show forced subtitles", + "description": "Show subtitles for foreign-language dialogue even when subtitles are off.", + "recommended_control": "switch", + "notes": "Default is true because that is what the server resolves today: user_profiles.show_forced_subtitles is NOT NULL DEFAULT true (migration 029) and profile creation sets it true. A false default here would silently turn forced subtitles off for every profile that never touched the toggle. The Has* companion booleans on LibraryPlaybackPreference and SubtitlePreference encode set-vs-unset at the library and series scopes, so migration writes rows there only where Has* is true. The profile column has no companion and cannot distinguish an explicit true from the column default, so migration writes a profile row only where the value is false — the value that differs from the default." + }, + { + "key": "playback.subtitle_appearance", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "object", "schema_ref": "subtitle-appearance.json" }, + "default_value": { + "fontSize": "large", + "fontFamily": "sans-serif", + "fontColor": "#ffffff", + "backgroundColor": "#000000", + "backgroundStyle": "box", + "backgroundOpacity": 75, + "textOutline": false, + "textOutlineColor": "#000000", + "position": "bottom" + }, + "category": "playback", + "label": "Subtitle appearance", + "description": "How subtitles are drawn during playback.", + "recommended_control": "panel", + "notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative." + }, + { + "key": "playback.preferred_quality", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "auto", "label": "Auto" }, + { "value": "480p", "label": "480p" }, + { "value": "720p", "label": "720p" }, + { "value": "1080p", "label": "1080p" }, + { "value": "2160p", "label": "2160p / 4K" }, + { "value": "original", "label": "Original quality" } + ] + }, + "default_value": "auto", + "constrained_by": { + "policy_input": "max_playback_quality", + "constraint": "ceiling" + }, + "category": "playback", + "label": "Preferred quality", + "description": "Pick the quality Silo should prefer.", + "recommended_control": "select", + "notes": "The resolution axis. Members are exactly the vocabulary the server already speaks: NormalizeQualityV3 in internal/playback/protocol_v3.go accepts auto, 480p, 720p, 1080p, 2160p and original and normalizes anything else to auto with a degradation warning. Transcode ladder rungs (328p, 720p-high, 1080p-8 and friends) are deliberately absent — they were never a third dimension, only a bitrate spelled into the resolution string. web/src/player/hooks/useTranscodeQuality.ts already decomposes them, defining 1080p-high as {resolution: 1080p, bitrate: 10000} and sending the two to the server separately, so the compound form never reached the wire. playback.max_bitrate_kbps is now that second axis, and clients compose the two into whatever presets they want to show. Members are listed ascending so the ceiling constraint has a defined direction; \"auto\" sorts lowest because it never exceeds a cap, and \"original\" highest because it is the uncapped source. Enforcing the ceiling needs internal/access/quality.go to learn both sentinels: qualityRank ranks neither today, so auto and original both tie at 0 with unset and a cap would let original through. Migrates user_profiles.quality_preference as the profile fallback. The legacy column is NOT NULL DEFAULT '1080p', and that default was the effective playback cap, so existing profiles receive explicit 1080p and 6000 kbps rows; newly created profiles use the contract's auto/null defaults. The account/profile max_playback_quality columns stay in internal/policy and are NOT settings." + }, + { + "key": "playback.max_bitrate_kbps", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "integer", + "nullable": true, + "minimum": 100, + "maximum": 200000 + }, + "default_value": null, + "unit": "kbps", + "category": "playback", + "label": "Maximum bitrate", + "description": "Cap how much bandwidth playback may use. No cap means Silo picks for the chosen resolution.", + "recommended_control": "select", + "notes": "The bitrate axis, orthogonal to playback.preferred_quality. Splitting them is what the clients were already doing: the in-player switcher sends resolution and bitrate as separate fields, and downloads (DownloadQuality in prairie-android) dropped resolution entirely and kept only a bitrate ladder. Two values rather than one compound enum means a client can offer \"1080p High\" without the server having to agree on what \"High\" means — retuning a preset is a client release, not a contract break, and it stays additive under the widening rule. null is uncapped, which is why this is nullable rather than defaulting to a large number: absent and \"as much as you like\" are the same statement, and a numeric sentinel would have to be widened every time hardware improves. The bounds are deliberately loose — 100 kbps is below any watchable stream and 200 Mbps is above any remux — because this caps a preference, not a policy; entitlement limits live in internal/policy. Migration decomposes the legacy compound values: 1080p-high becomes (1080p, 10000), 720p-medium becomes (720p, 3000), 420p becomes (480p, 720), following the bitrates in web/src/player/hooks/useTranscodeQuality.ts, so no stored preference is lost to the rejects table." + }, + { + "key": "playback.auto_skip_intro", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip intros", + "description": "Jump past intros automatically when Silo can detect them.", + "recommended_control": "switch", + "deprecated": true, + "notes": "Superseded by playback.intro_skip_mode in revision 7: true is \"always\", false is \"ask\". The boolean cannot express \"never\" (no prompt at all), which is the mode this setting was missing. It stays in the manifest because every shipped client reads it and the profile DTO carries it as a NOT NULL column, and the server mirrors the two keys at write time for one release so a preference set on an old client shows up correctly on a new one. Removing it is a follow-up, once Android, Apple and web all read the enum." + }, + { + "key": "playback.intro_skip_mode", + "introduced_in": 7, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "never", "label": "Never" }, + { "value": "ask", "label": "Ask to skip" }, + { "value": "always", "label": "Skip automatically" } + ] + }, + "default_value": "ask", + "category": "playback", + "label": "Skip intros", + "description": "What Silo does when an intro starts: leave it alone, offer a Skip Intro button, or skip it and offer an undo.", + "recommended_control": "select", + "notes": "The replacement for playback.auto_skip_intro, which could only say \"prompt\" or \"count down then skip\" and had no way to turn the prompt off. The default is \"ask\", which is exactly what auto_skip_intro=false did, so an untouched profile behaves identically across the cutover. The schema has no segmented control, so this is a select; clients that have a segmented control should use it. See docs/design/2026-08-16-intro-skip-mode.md." + }, + { + "key": "playback.auto_skip_credits", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip credits", + "description": "Move through end credits automatically when a skip is available.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_skip_recap", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Auto-skip recaps", + "description": "Skip \"previously on\" recaps automatically when Silo can detect them.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_play_next", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "playback", + "label": "Auto-play next episode", + "description": "Continue to the next episode automatically.", + "recommended_control": "switch" + }, + { + "key": "playback.auto_play_next_preview", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "category": "playback", + "label": "Preview next episode", + "description": "Show a preview of the next episode while credits play.", + "recommended_control": "switch" + }, + { + "key": "playback.next_up_prompt_seconds", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 120 }, + "default_value": 30, + "unit": "seconds", + "category": "playback", + "label": "Next up prompt", + "description": "How long before the end of an episode the next-up prompt appears.", + "recommended_control": "slider", + "notes": "Android currently writes player.next_up_prompt_seconds. That alias is migrated to this key and removed from production writes." + }, + { + "key": "catalog.metadata_language", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "language_tag", "nullable": true }, + "default_value": null, + "category": "catalog", + "label": "Metadata language", + "description": "Fallback language Silo prefers for titles, descriptions, and artwork.", + "recommended_control": "select", + "suggested_options": "catalog_metadata_languages", + "unset_label": "Library default", + "notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes. From revision 3, the private-use tag x-silo-original means resolve the target from each media item's original_language. It remains a valid value of the existing language_tag schema, so this is additive rather than a response-field type change." + }, + { + "key": "catalog.metadata_language_overrides", + "introduced_in": 3, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "metadata-language-overrides.json" + }, + "default_value": {}, + "category": "catalog", + "label": "Metadata language exceptions", + "description": "Preferred metadata language for items in specific original languages.", + "recommended_control": "panel", + "notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions." + }, + { + "key": "player.hdr_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "player", + "label": "HDR", + "description": "Allow HDR output on this device.", + "recommended_control": "switch" + }, + { + "key": "player.dolby_vision_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "player", + "label": "Dolby Vision", + "description": "Allow Dolby Vision output on this device.", + "recommended_control": "switch" + }, + { + "key": "player.dv_profile7_hdr10_fallback", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "player", + "label": "Dolby Vision Profile 7 fallback", + "description": "Play Profile 7 sources as HDR10 when this device cannot decode them natively.", + "recommended_control": "switch", + "notes": "Android currently defaults this to true before hydration. The contract default is false, matching the server and Apple." + }, + { + "key": "player.seek_cache_enabled", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "category": "player", + "label": "Seek cache", + "description": "Keep recently played segments buffered for faster seeking.", + "recommended_control": "switch" + }, + { + "key": "player.match_frame_rate", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["android", "android_tv", "tvos"], + "category": "player", + "label": "Match content frame rate", + "description": "Switch the display refresh rate to match what is playing.", + "recommended_control": "switch", + "notes": "Android keeps this device-local today: it is absent from PlaybackSettingsKeys.DeviceSettings and documented there as deliberately not synced, so it was never written to the server rather than written and rejected. Registered here because a display-matching preference belongs to the device and should follow a profile across reinstalls." + }, + { + "key": "player.playback_speed", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "number", "minimum": 0.25, "maximum": 3.0, "step": 0.05 }, + "default_value": 1.0, + "unit": "x", + "category": "player", + "label": "Playback speed", + "description": "Default playback speed on this device.", + "recommended_control": "slider", + "notes": "Range matches the server and the shipped clients: Android already clamps to 0.25..3.0 and no picker offers above 3.0. The 0.05 step is enforced by ValidateValue, not just advertised, so every client's stepper lands on values the server accepts." + }, + { + "key": "player.audio_sync_ms", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": -5000, "maximum": 5000 }, + "default_value": 0, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "unit": "milliseconds", + "category": "player", + "label": "Audio sync offset", + "description": "Shift audio earlier or later to correct lip sync on this device.", + "recommended_control": "slider" + }, + { + "key": "player.subtitle_sync_ms", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 }, + "default_value": 0, + "unit": "milliseconds", + "category": "player", + "label": "Subtitle sync offset", + "description": "Shift subtitles earlier or later on this device.", + "recommended_control": "slider" + }, + { + "key": "player.video_gravity", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "fit", "label": "Fit" }, + { "value": "fill", "label": "Fill" }, + { "value": "stretch", "label": "Stretch" } + ] + }, + "default_value": "fit", + "category": "player", + "label": "Video sizing", + "description": "How video fills the screen on this device.", + "recommended_control": "select" + }, + { + "key": "player.orientation_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "landscapeLocked", "label": "Landscape" }, + { "value": "rotateFreely", "label": "Rotate freely" } + ] + }, + "default_value": "landscapeLocked", + "platforms": ["ios", "android"], + "category": "player", + "label": "Screen orientation", + "description": "Whether the player rotates with the device.", + "recommended_control": "select" + }, + { + "key": "player.sleep_timer_default_minutes", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 240 }, + "default_value": 30, + "unit": "minutes", + "category": "player", + "label": "Default sleep timer", + "description": "Duration the sleep timer starts on when you turn it on. 0 leaves it off.", + "recommended_control": "stepper", + "notes": "Android keeps this device-local today and clamps to 0..240; it was never written to the server rather than written and rejected. The maximum matches that clamp rather than exceeding it, and the default matches Android's shipped 30, because a manifest that disagrees with the only client implementing a setting is the drift this contract exists to remove — and a default of 0 would silently turn the preset off for everyone at cutover. Raising the maximum later is additive under the widening rule: replace the bare maximum with its history so a client can still see the 240 an older server enforces. This is the duration the timer starts on, not whether one is running: the design classes a running sleep timer as private local, so only the persisted default is registered." + }, + { + "key": "ui.theme", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "midnight-cinema", "label": "Midnight Cinema" }, + { "value": "cinema-light", "label": "Cinema Light" }, + { "value": "cobalt-studio", "label": "Cobalt Studio" }, + { "value": "oxblood-noir", "label": "Oxblood Noir" }, + { "value": "evergreen-studio", "label": "Evergreen Studio" } + ] + }, + "default_value": "midnight-cinema", + "platforms": ["web"], + "category": "appearance", + "label": "Theme", + "description": "Colour theme for the Silo interface.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_theme\", which the extension bag accepted without validation. Moved from account to profile scope: appearance is per household member, and the account row is copied to every profile during migration. Carries a device override because the right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning that gives ui.text_scale one. Note that ui.custom_theme_vars and ui.custom_css stay profile-wide, so a profile's custom styling still applies on top of a device's theme override. Adding a theme is an additive enum widening. The admin-set default theme stays in server_settings and is not a user setting. Migration must also update internal/plugins/user_theme_lookup.go, which reads this value with raw SQL bound to both the old name and the account scope (SELECT value FROM user_settings WHERE user_id = $1 AND key = 'ui_theme') and feeds the X-Prairie-Theme header on every plugin request. Left alone, that query matches nothing after the rename and every plugin UI silently falls back to its own theme, with no error to notice." + }, + { + "key": "ui.text_scale", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "default", "label": "Default" }, + { "value": "large", "label": "Large" }, + { "value": "x-large", "label": "Extra large" } + ] + }, + "default_value": "default", + "platforms": ["web"], + "category": "appearance", + "label": "Text size", + "description": "Overall interface text size.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_text_scale\". Allows a device override because readable text size is partly a function of the screen you are sitting in front of." + }, + { + "key": "ui.text_weight", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "default", "label": "Default" }, + { "value": "strong", "label": "Bolder" } + ] + }, + "default_value": "default", + "platforms": ["web"], + "category": "appearance", + "label": "Text weight", + "description": "Use heavier interface text for readability.", + "recommended_control": "select", + "notes": "Renamed from the unregistered legacy key \"ui_text_weight\"." + }, + { + "key": "ui.high_contrast", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_device"], + "resolution_order": ["profile_device", "profile", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["web"], + "category": "appearance", + "label": "High contrast", + "description": "Increase contrast across the interface.", + "recommended_control": "switch", + "notes": "Renamed from the unregistered legacy key \"ui_high_contrast\"." + }, + { + "key": "ui.custom_theme_vars", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "theme-var-overrides.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Custom theme variables", + "description": "Per-token overrides applied on top of the selected theme.", + "recommended_control": "panel", + "notes": "Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON." + }, + { + "key": "ui.custom_css", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { "type": "string", "max_length": 65536, "nullable": true }, + "default_value": null, + "platforms": ["web"], + "category": "appearance", + "label": "Custom CSS", + "description": "Raw CSS applied on top of the selected theme.", + "recommended_control": "text", + "notes": "Renamed from the unregistered legacy key \"ui_custom_css\". Sanitization stays in the web client (web/src/lib/cssSanitizer.ts); the contract only bounds length. This value is per-profile and is never applied to another profile's session." + }, + { + "key": "ui.date_format", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Match device" }, + { "value": "DD/MM/YYYY" }, + { "value": "MM/DD/YYYY" }, + { "value": "YYYY-MM-DD" } + ] + }, + "default_value": "auto", + "category": "appearance", + "label": "Date format", + "description": "How dates are written across the interface.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.time_format", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "auto", "label": "Match device" }, + { "value": "12h", "label": "12-hour" }, + { "value": "24h", "label": "24-hour" } + ] + }, + "default_value": "auto", + "category": "appearance", + "label": "Time format", + "description": "How clock times are written across the interface.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.library_page_state", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-page-state.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "navigation", + "label": "Remembered library view", + "description": "Saved browse state for each library.", + "notes": "Navigation state, not a user-authored preference. Stays tied to one profile on one device and is not shown as a normal setting control." + }, + { + "key": "ui.remember_library_page_state", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile_device"], + "resolution_order": ["profile_device", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["web"], + "category": "navigation", + "label": "Remember library view", + "description": "Return to where you left off when reopening a library.", + "recommended_control": "switch" + }, + { + "key": "search.media_scope", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "all", "label": "Everything" }, + { "value": "video", "label": "Movies and series" }, + { "value": "audiobook", "label": "Audiobooks" } + ] + }, + "default_value": "video", + "category": "search", + "label": "Search scope", + "description": "What search covers by default.", + "recommended_control": "select", + "notes": "Moved from account to profile scope; the account row is copied to every profile during migration." + }, + { + "key": "ui.card_overlays", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "card-overlays.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "appearance", + "label": "Poster badges", + "description": "Which badges appear on poster cards, and where.", + "notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The admin default and the enabled kill switch stay in server_settings and are not user settings." + }, + { + "key": "ui.next_up_mode", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "enum", + "values": [ + { "value": "combined", "label": "With Continue Watching" }, + { "value": "separate", "label": "Separate row" } + ] + }, + "default_value": "combined", + "category": "navigation", + "label": "Next up episodes", + "description": "Whether upcoming episodes stay with Continue Watching or get their own row.", + "recommended_control": "select", + "notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member." + }, + { + "key": "nav.primary_menu", + "introduced_in": 5, + "persistence": "remote", + "allowed_scopes": ["profile_client", "profile_device"], + "resolution_order": ["profile_device", "profile_client", "default"], + "value_schema": { + "type": "object", + "schema_ref": "primary-menu.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "navigation", + "label": "Primary menu", + "description": "The ordered visible destinations shared by like clients.", + "recommended_control": "panel", + "notes": "Search and profile remain fixed client utilities. Home is required by primary-menu.json; omitting any other supported built-in hides it. Semantic destination identities are unique even when labels differ. A null default lets each family keep its native baseline until the user customizes it. Family scope synchronizes like clients while profile_device remains an explicit escape hatch for one screen; clients ignore built-in destinations they do not support." + }, + { + "key": "nav.shortcuts", + "introduced_in": 5, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "navigation-shortcuts.json" + }, + "default_value": { "items": [] }, + "category": "navigation", + "label": "Navigation shortcuts", + "description": "Libraries, sections, and collections pinned for use across navigation surfaces.", + "notes": "Profile-wide catalog; individual client families decide which shortcuts to place in their primary menu. Semantic destination identities are unique even when labels differ. The profile_client migration keeps ui.sidebar_pins unchanged and seeds this key from convertible legacy web pins only when this key has no authored row." + }, + { + "key": "ui.card_presentation", + "introduced_in": 5, + "persistence": "remote", + "allowed_scopes": ["profile", "profile_client", "profile_device"], + "resolution_order": ["profile_device", "profile_client", "profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "card-presentation.json" + }, + "default_value": { "poster_size": "standard", "caption": "title_metadata" }, + "platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"], + "category": "appearance", + "label": "Media cards", + "description": "Poster size and caption detail used by media cards.", + "recommended_control": "panel", + "notes": "Semantic presets roam between like devices without forcing identical pixel dimensions across platforms. A profile fallback can opt into one presentation everywhere; family and exact-device values remain more specific." + }, + { + "key": "ui.sidebar_pins", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "sidebar-pins.json", + "nullable": true + }, + "default_value": null, + "platforms": ["web"], + "category": "navigation", + "label": "Pinned sidebar items", + "description": "Sections and collections pinned into the sidebar.", + "notes": "Registered from the legacy unprefixed key sidebar_pins. Navigation state rather than an authored preference, so it has no control; it is written by the pin affordances themselves." + }, + { + "key": "ui.disabled_library_ids", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-id-list.json", + "nullable": true + }, + "default_value": null, + "category": "navigation", + "label": "Hidden libraries", + "description": "Libraries you have hidden from your own browsing.", + "notes": "Registered from the legacy unprefixed key disabled_library_ids. This is the user hiding a library from themselves — it is not an access control. Library visibility enforcement lives in internal/access and internal/policy, and nothing here may be read as a permission. Profile scope rather than profile_device because hiding a library is a statement about what you want to see, not about one screen." + }, + { + "key": "ui.library_order", + "introduced_in": 1, + "persistence": "remote", + "allowed_scopes": ["profile"], + "resolution_order": ["profile", "default"], + "value_schema": { + "type": "object", + "schema_ref": "library-id-list.json", + "nullable": true + }, + "default_value": null, + "category": "navigation", + "label": "Library order", + "description": "The order your libraries appear in.", + "notes": "Registered from the legacy unprefixed key library_order. Shares library-id-list.json with ui.disabled_library_ids: both are normalized by the same normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and duplicates. A library id absent from the list sorts after the ones present, so a stale id for a deleted library is inert and needs no cleanup hook." + }, + { + "key": "downloads.wifi_only", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Download over Wi-Fi only", + "description": "Only download while connected to Wi-Fi.", + "recommended_control": "switch", + "notes": "Contract-known local: the value governs OS-level network constraints on the device holding the files, so it does not roam. Shared semantics across Apple and Android make it contract-owned rather than private." + }, + { + "key": "downloads.keep_watched", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Keep watched downloads", + "description": "Do not suggest reclaiming space from downloads you have finished.", + "recommended_control": "switch", + "notes": "Contract-known local. Governs on-device storage cleanup prompts." + }, + { + "key": "downloads.default_quality", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { + "type": "enum", + "ordered": true, + "values": [ + { "value": "1mbps", "label": "1 Mbps" }, + { "value": "2mbps", "label": "2 Mbps" }, + { "value": "5mbps", "label": "5 Mbps" }, + { "value": "10mbps", "label": "10 Mbps" }, + { "value": "20mbps", "label": "20 Mbps" }, + { "value": "original", "label": "Original" } + ] + }, + "default_value": "original", + "platforms": ["ios", "android"], + "category": "downloads", + "label": "Download quality", + "description": "Quality preset used for new downloads.", + "recommended_control": "select", + "notes": "Contract-known local: the value is chosen on the device holding the files and is sent on each POST /downloads rather than stored server-side. Members are the DownloadQuality wire presets, ascending. Registered as client_local rather than left unregistered because it is a user-facing preference with shared semantics, and the manifest's invariant is that no production setting exists without an entry." + }, + { + "key": "subtitle.matches_device", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "playback", + "label": "Match device caption settings", + "description": "Use the operating system's caption style instead of Silo's.", + "recommended_control": "switch", + "notes": "Contract-known local: reads OS accessibility settings that only exist on the device. When enabled, playback.subtitle_appearance is not applied. Apple's existing copy separating this from profile subtitle behavior is the UX baseline. A contract key names a setting; it is not a storage key. Clients keep whatever local key they already use — Android stores this at subtitle.matches_device.local, Apple at player.subtitleMatchesSystemAppearance — so adopting the contract does not reset anyone's local preferences. The same applies to downloads.wifi_only and downloads.keep_watched, which Apple stores as downloads.wifiOnly and downloads.keepWatchedDownloads." + }, + { + "key": "player.resume_rewind_seconds", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 30 }, + "default_value": 7, + "unit": "seconds", + "platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"], + "category": "player", + "label": "Rewind on resume", + "description": "Skip back this far when resuming a partly watched item, to re-establish context. 0 turns it off.", + "recommended_control": "stepper", + "notes": "Contract-known local: it tunes playback feel on the device doing the playing. Registered so the name, range and default are shared rather than reinvented per platform." + }, + { + "key": "player.passout_threshold", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "integer", "minimum": 0, "maximum": 20 }, + "default_value": 3, + "unit": "episodes", + "platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"], + "category": "player", + "label": "Still watching prompt", + "description": "How many episodes auto-play before Silo asks whether you are still watching. 0 never asks.", + "recommended_control": "stepper", + "notes": "Contract-known local: pass-out protection counts consecutive auto-advances in one client session, which no other device can observe." + }, + { + "key": "player.picture_in_picture_enabled", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": true, + "platforms": ["ios", "macos", "android"], + "category": "player", + "label": "Picture in picture", + "description": "Keep playing in a floating window when you leave the player.", + "recommended_control": "switch", + "notes": "Contract-known local: picture-in-picture is an OS capability of the device, not a playback preference the server resolves." + }, + { + "key": "nav.show_audiobooks", + "introduced_in": 1, + "persistence": "client_local", + "allowed_scopes": ["client_local"], + "resolution_order": ["client_local", "default"], + "value_schema": { "type": "boolean" }, + "default_value": false, + "platforms": ["ios", "tvos", "macos", "android", "android_tv"], + "category": "nav", + "label": "Show audiobooks", + "description": "Show the Audiobooks section in navigation.", + "recommended_control": "switch", + "notes": "Contract-known local: an opt-in navigation surface, hidden by default, with existing Apple (AppNavPreferences.showAudiobooks) and Android parity. Android stores it locally at nav.show_audiobooks.local." + } + ] +}